build_ipa_from_nsp.py: game-neutral, survey mode, app name from the NSP

- The game comes from the NSPs' title ID and its profile in
  dm/games/; the versions to extract, the accepted build IDs, the
  modules to rewrite and the tables to compare all come from the
  profile. TITLE / BUILDS are gone from the script. Work folder:
  <NSP folder>/<id>_build/.
- A title with no profile is surveyed instead:
  <NSP folder>/<title ID>_survey/survey.md lists the NSPs, modules and
  build IDs, SDK version and components, Unity version, engine / API
  markers, RomFS folders, magics and extensions, insn_census counts,
  imports vs host hooks, and notes. Fire Emblem Engage was surveyed
  this way.
- APP_NAME setting; empty uses the game name from the base NSP's
  NACP (compressed title areas decoded), then the profile's app_name.
- PACKAGE and OUT_IPA default to the profile's package and ipa.
- Tested on Mario end to end from fresh extraction: the IPA is named
  "Super Mario Bros. Wonder" and carries the current chain's tables.
This commit is contained in:
seedds committed 2026-10-14 11:15:55 +08:00
1 parent 8783deee5b
commit 5d4d66a19b
1 file changed
+225 -64
+225 -64
View File
@@ -1,31 +1,30 @@
#!/usr/bin/env python3
# 从 NSP 一路出未签名 IPA:解包(nx_extract)→ 改写代码模块(patch_nso.py)→ 拆出着色器并与仓内表核对(shader_split.py)→ iOS 着色器库(dmsh_build.py)→ build_ipa.sh。
# 从 NSP 一路出未签名 IPA:认游戏(标题号 → dm/games/<名>/ 游戏档案)→ 解包(nx_extract)→ 改写代码模块(patch_nso.py)→ 拆出着色器并与档案里的表核对(shader_split.py)
# → iOS 着色器库(dmsh_build.py)→ build_ipa.sh。没有对应档案的游戏只做粗查(<NSP 文件夹>/<标题号>_survey/survey.md),不出 IPA。
# 在 Spyder 里打开本文件按 F5 运行(也可 python3 build_ipa_from_nsp.py);下面「设置」留空的两项运行时会问。
# 只用三样东西:本仓库、放 NSP 的文件夹(基础版 v0 + 更新 1.2.1 都要:着色器表是两版的并集;DLC 跳过,本移植不读追加内容)、prod.keys。
# 所有中间产物都在 NSP 文件夹里的 smbw_build/:extract/(解出的游戏文件,出包后默认删掉)、tools/、patched_*/、shader/(着色器包与缓存,留着下次出包快)、logs/(每步的完整输出)、tmp/、ipa/。
# 只用三样东西:本仓库、放一个游戏 NSP 的文件夹(基础版 + 档案 shader_versions 列的更新都要;DLC 跳过,本移植不读追加内容)、prod.keys。
# 所有中间产物都在 NSP 文件夹里的 <名>_build/:extract/(解出的游戏文件,出包后默认删掉)、tools/、patched_*/、shader/(着色器包与缓存,留着下次出包快)、logs/(每步的完整输出)、tmp/、ipa/。
# 着色器翻译器用仓内的 dm/gfx/tools/dmsh_xlate_bin(与 dmsh_chain.lock 指纹一致的那份;重编出的 dll 指纹不同,dmsh_build 会拒绝)。
# 运行本文件的 Python 要有 lz4、capstone、fonttools;另需 Xcode、xcodegen、.NET SDK 10、zstd、spirv-cross。首次约 35 分钟(大头是着色器库),再跑约 10 分钟(着色器库走缓存)。密钥值一律不打印。
# Unsigned IPA straight from the NSPs: extract (nx_extract) → rewrite the code modules (patch_nso.py) → split out the shaders and check them against the in-repo tables (shader_split.py) → iOS shader libraries (dmsh_build.py) → build_ipa.sh.
# 运行本文件的 Python 要有 lz4、capstone、fonttools;另需 Xcode、xcodegen、.NET SDK 10、zstd、spirv-cross。马力欧首次约 35 分钟(大头是着色器库),再跑约 10 分钟(着色器库走缓存)。密钥值一律不打印。
# Unsigned IPA straight from the NSPs: identify the game (title ID → game profile dm/games/<id>/) → extract (nx_extract) → rewrite the code modules (patch_nso.py) → split out the shaders and check them against the profile's tables (shader_split.py)
# → iOS shader libraries (dmsh_build.py) → build_ipa.sh. A game with no matching profile is only surveyed (<NSP folder>/<title ID>_survey/survey.md); no IPA.
# Open this file in Spyder and press F5 (or run python3 build_ipa_from_nsp.py); the two settings below that are left empty are asked for when it runs.
# Uses only three things: this repository, the folder holding the NSPs (both the base game v0 and update 1.2.1 are required: the shader tables are the union of both versions; DLC is skipped, this port does not load add-on content), and prod.keys.
# Every intermediate file is under smbw_build/ in the NSP folder: extract/ (the extracted game files, deleted after the IPA is built by default), tools/, patched_*/, shader/ (shader package and caches, kept so the next build is fast), logs/ (each step's full output), tmp/, ipa/.
# Uses only three things: this repository, a folder holding one game's NSPs (the base game plus the updates listed in the profile's shader_versions are required; DLC is skipped, this port does not load add-on content), and prod.keys.
# Every intermediate file is under <id>_build/ in the NSP folder: extract/ (the extracted game files, deleted after the IPA is built by default), tools/, patched_*/, shader/ (shader package and caches, kept so the next build is fast), logs/ (each step's full output), tmp/, ipa/.
# 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).
# The Python running this file needs lz4, capstone and fonttools; also needed: Xcode, xcodegen, .NET SDK 10, zstd, spirv-cross. The first run takes about 35 minutes (mostly the shader libraries); later runs take about 10 (the shader libraries come from the cache). Key values are never printed.
import collections, ctypes, getpass, os, re, shutil, struct, subprocess, sys, threading, time
# The Python running this file needs lz4, capstone and fonttools; also needed: Xcode, xcodegen, .NET SDK 10, zstd, spirv-cross. For Mario the first run takes about 35 minutes (mostly the shader libraries); later runs take about 10 (the shader libraries come from the cache). Key values are never printed.
import collections, ctypes, getpass, os, re, shutil, struct, subprocess, sys, threading, time, zlib
from pathlib import Path
# ---- 设置 | Settings ----
NSP_DIR = '' # 放 NSP 的文件夹(留空 = 运行时问) | folder holding the NSPs (empty = ask at run time)
PROD_KEYS = '' # prod.keys 的路径(留空 = 运行时问) | path of prod.keys (empty = ask at run time)
PACKAGE = 'v327680' # 打进 IPA 的版本:'v327680'(1.2.1)或 'v0' | version packed into the IPA: 'v327680' (1.2.1) or 'v0'
OUT_IPA = '~/Desktop/SMBWMobile.ipa' # 输出的 IPA | output IPA
KEEP_EXTRACTED = False # 出包后保留解出的游戏文件(下次省去解包,约 3.6 GB) | keep the extracted game files after the IPA is built (saves extraction next time, about 3.6 GB)
PACKAGE = '' # 打进 IPA 的版本(留空 = 游戏档案的 package;马力欧 'v327680' 即 1.2.1,或 'v0') | version packed into the IPA (empty = the profile's package; Mario 'v327680' i.e. 1.2.1, or 'v0')
OUT_IPA = '' # 输出的 IPA(留空 = ~/Desktop/<档案的 ipa>) | output IPA (empty = ~/Desktop/<the profile's ipa>)
APP_NAME = '' # 主屏上的 App 名(留空 = 基础版 NSP 里的游戏名) | app name on the home screen (empty = the game name in the base NSP)
KEEP_EXTRACTED = False # 出包后保留解出的游戏文件(下次省去解包;与游戏文件一样大) | keep the extracted game files after the IPA is built (saves extraction next time; as big as the game files)
DM_ENV_FILE = '' # 可选:诊断包的环境开关文件(每行 KEY=VALUE) | optional: environment switches for a diagnostic build (one KEY=VALUE per line)
TITLE = 0x010015100B514000 # Super Mario Bros. Wonder 的标题号 | title ID of Super Mario Bros. Wonder
# 本移植认得的构建(main / sdk 构建号,见 README「Update 1.2.1」);别的构建上写死的地址不成立,直接停 | Builds this port knows (main / sdk build IDs, see README "Update 1.2.1"); fixed addresses do not hold on other builds, so stop
BUILDS = {'v0': {'main': 'cd6e42aee7934f4d8393cd43893ae25e', 'sdk': '3053b22935e32ddf15ef81997603e5c2'},
'v327680': {'main': 'ff773e90972d544eb79406eaa65396d5', 'sdk': 'e4bd58cac28cf13a08fb417aa013cdcb'}}
BEAT = 60 # quiet 步骤的进度间隔(秒) | progress interval of quiet steps (seconds)
TOOL_PATHS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/local/share/dotnet', os.path.expanduser('~/.dotnet')] # Spyder 等图形程序拿不到 shell 的 PATH | GUI apps such as Spyder do not get the shell's PATH
@@ -112,23 +111,58 @@ def version_of(path):
return int(m.group(1)) if m else None
def find_nsps(nsp_dir):
base, updates, unknown = None, [], []
def scan_nsps(nsp_dir):
"""按游戏归组:{应用标题号: {'base': 路径, 'updates': [(版本, 路径)], 'dlc': [(标题号, 版本, 路径)]}}
Group by game: {application title ID: {'base': path, 'updates': [(version, path)], 'dlc': [(title ID, version, path)]}}"""
fams, unknown = {}, []
for p in sorted(nsp_dir.glob('*.nsp')):
tid = title_of(p)
if tid is None: unknown.append(p); continue
low = tid & 0xFFF
kind, app = ('base', tid) if low == 0 else ('update', tid & ~0xFFF) if low == 0x800 else ('dlc', (tid & ~0xFFF) - 0x1000)
if app != TITLE: say(f'忽略(不是 Super Mario Bros. Wonder)| ignored (not Super Mario Bros. Wonder): {p.name}'); continue
if kind == 'dlc': say(f'跳过 DLC(本移植不读追加内容)| skipping DLC (this port does not load add-on content): {p.name}'); continue
if kind == 'base': base = p
else: updates.append(p)
if base is None and len(unknown) == 1: base = unknown[0] # 卡带转出的基础版可能既无标签也无票据;构建号随后核对 | a base game converted from a cartridge may have neither tags nor a ticket; build IDs are checked afterwards
if base is None: raise BuildError(f'{nsp_dir} 里没找到基础版 NSP(标题号 {TITLE:016X})| no base game NSP found (title ID {TITLE:016X})')
upd = [u for u in updates if version_of(u) == 327680] or (updates if len(updates) == 1 else [])
if not upd: raise BuildError('没找到 1.2.1 更新 NSP(v327680):着色器表是 v0 + 1.2.1 的并集,两版都要 | no update 1.2.1 NSP (v327680) found: the shader tables are the union of v0 + 1.2.1, both are required')
say(f'基础版 | base: {base.name}'); say(f'更新 | update: {upd[0].name}')
return base, upd[0]
low = tid & 0xFFF # 基础版 …000,更新 …800,DLC = 基础版 + 0x1000 + 序号 | base …000, update …800, DLC = base + 0x1000 + index
app = tid if low == 0 else tid & ~0xFFF if low == 0x800 else (tid & ~0xFFF) - 0x1000
f = fams.setdefault(app, {'base': None, 'updates': [], 'dlc': []})
if low == 0: f['base'] = p
elif low == 0x800: f['updates'].append((version_of(p), p))
else: f['dlc'].append((tid, version_of(p), p))
if len(fams) == 1 and len(unknown) == 1 and next(iter(fams.values()))['base'] is None: # 卡带转出的基础版可能既无标签也无票据;构建号随后核对 | a base game converted from a cartridge may have neither tags nor a ticket; build IDs are checked afterwards
next(iter(fams.values()))['base'] = unknown[0]
if not fams: raise BuildError(f'{nsp_dir} 里没有认得出标题号的 NSP | no NSP with a readable title ID in {nsp_dir}')
if len(fams) > 1: raise BuildError('文件夹里有不止一个游戏,一个文件夹放一个游戏 | more than one game in the folder, keep one game per folder: ' + ', '.join(f'{t:016X}' for t in fams))
app, fam = next(iter(fams.items()))
if fam['base'] is None: raise BuildError(f'{nsp_dir} 里没找到基础版 NSP(标题号 {app:016X})| no base game NSP found (title ID {app:016X})')
return app, fam
def pick_nsps(fam, versions):
"""每个要的版本对应的(基础版, 更新或 None):v0 = 只基础版,v<N> = 基础版 + 版本号 N 的更新
For each wanted version, (base game, update or None): v0 = the base game alone, v<N> = the base game + the update with version number N"""
for tid, _, p in fam['dlc']: say(f'跳过 DLC(本移植不读追加内容)| skipping DLC (this port does not load add-on content): {p.name}')
say(f'基础版 | base: {fam["base"].name}')
out = {}
for ver in versions:
if ver == 'v0': out[ver] = (fam['base'], None); continue
n = int(ver[1:])
upd = [u for v, u in fam['updates'] if v == n] or [u for v, u in fam['updates'] if v is None and len(fam['updates']) == 1] # 文件名没写版本号时只认唯一的那个,构建号随后核对 | without a version in the file name only a single update is accepted; build IDs are checked afterwards
if not upd: raise BuildError(f'没找到更新 {ver} 的 NSP(游戏档案要这些版本:{", ".join(versions)})| no NSP for update {ver} (the game profile needs these versions: {", ".join(versions)})')
say(f'更新 {ver} | update {ver}: {upd[0].name}'); out[ver] = (fam['base'], upd[0])
return out
def nacp_name(d): # 解包目录里 control.nacp 的游戏名(nx_extract 存的;旧的解包没有则空;解不出像样的名字也空) | the game name from control.nacp in an extraction folder (saved by nx_extract; empty for older extractions, or when no sensible name decodes)
p = d / 'control.nacp'
if not p.exists(): return ''
b = p.read_bytes(); t = b[:0x3000]
try: # 新 SDK 把名称区压缩:u16 长度 + raw deflate,解开是 32 种语言 × 0x300(马力欧 1.2.1 就是) | newer SDKs compress the title area: a u16 size + raw deflate, inflating to 32 languages × 0x300 (Mario 1.2.1 does)
z = zlib.decompress(b[2:2 + struct.unpack_from('<H', b)[0]], -15)
if len(z) == 0x6000: t = z
except zlib.error: pass # 未压缩 | not compressed
for i in range(len(t) // 0x300): # 每种语言 0x300 字节,名称占前 0x200;取第一个非空的(0 = 美式英语) | 0x300 bytes per language, the name in the first 0x200; take the first non-empty one (0 = American English)
n = t[i * 0x300:i * 0x300 + 0x200].split(b'\0', 1)[0]
if not n: continue
try: n = n.decode('utf-8')
except UnicodeDecodeError: return ''
return n if n.isprintable() else ''
return ''
def can_clone(d): # 该卷能否做 APFS 克隆(nx_extract 用它省空间,不支持时整份写出) | whether this volume supports APFS clones (nx_extract uses them to save space; without them it writes full copies)
@@ -140,6 +174,111 @@ def can_clone(d): # 该卷能否做 APFS 克隆(nx_extract 用它省空间
rmtree(a); rmtree(b)
# ---- 新游戏粗查(没有对应游戏档案时) | Survey of a new game (when no game profile matches) ----
MAGICS = [(b'UnityFS\0', 'UnityFS'), (b'UnityWeb', 'UnityWeb'), (b'UnityRaw', 'UnityRaw'), (b'BNSH', 'BNSH'), (b'BAHS', 'BAHS'), (b'SARC', 'SARC'), (b'Yaz0', 'Yaz0'),
(bytes.fromhex('28b52ffd'), 'zstd'), (b'FRES', 'bfres'), (b'BNTX', 'BNTX'), (b'BWAV', 'BWAV'), (b'FSAR', 'BFSAR'), (b'CPK ', 'CRI CPK'), (b'CRID', 'CRI USM'),
(b'@UTF', 'CRI UTF'), (b'AKPK', 'Wwise pck'), (b'BKHD', 'Wwise bnk'), (b'FSB5', 'FMOD'), (b'\x89PNG', 'PNG'), (b'RIFF', 'RIFF'), (b'OggS', 'Ogg'), (b'PK\x03\x04', 'zip'), (b'NSO0', 'NSO')]
MARKERS = [(b'nvnBootstrapLoader', 'NVN'), (b'vkCreateInstance', 'Vulkan'), (b'eglGetProcAddress', 'EGL / OpenGL'), (b'il2cpp_', 'Unity IL2CPP'), (b'UnityEngine', 'Unity'),
(b'sead::', 'Nintendo sead'), (b'agl::', 'Nintendo agl'), (b'gsys::', 'Nintendo gsys'), (b'Unreal', 'Unreal'), (b'UE4', 'Unreal 4'), (b'Havok', 'Havok'), (b'CRIWARE', 'CRIWARE'), (b'Wwise', 'Wwise')]
def nso_image(path): # 解出 NSO 的三段 | decompress an NSO's three segments
import lz4.block
d = path.read_bytes(); flags = struct.unpack_from('<I', d, 0xc)[0]
segs = [struct.unpack_from('<III', d, 0x10 + i * 0x10) for i in range(3)]; csz = struct.unpack_from('<III', d, 0x60)
img = bytearray(segs[2][1] + segs[2][2])
for i, (fo, mo, sz) in enumerate(segs):
raw = d[fo:fo + csz[i]]
img[mo:mo + sz] = lz4.block.decompress(raw, uncompressed_size=sz) if flags >> i & 1 else raw
return bytes(img), d[0x40:0x50].hex(), [sz for _, _, sz in segs]
def survey(nsp_dir, app, fam, keys, repo, py, env):
work = nsp_dir / f'{app:016X}_survey'; logs, ex = work / 'logs', work / 'extract'
say(f'没有 {app:016X} 的游戏档案:只做粗查,不出 IPA | no game profile for {app:016X}: survey only, no IPA')
nx = work / 'tools/nx_extract/nx_extract'
run(['dotnet', 'build', repo / 'dm/tools/nx_extract', '-c', 'Release', '-o', nx.parent, '-v', 'quiet', '-nologo'], logs / 'nx_extract_build.log', env=env)
ups = sorted(fam['updates'], key=lambda u: u[0] or 0)
upd = ups[-1][1] if ups else None
tmp = work / 'extract.partial'; rmtree(tmp)
say(f'解出代码模块与 RomFS 清单(不写游戏文件)| extracting the code modules and a RomFS manifest (no game files written) → {ex}')
run([nx, keys, fam['base'], upd or '-', tmp, '--manifest'], logs / 'extract.log')
swap_in(tmp, ex)
xlog = (logs / 'extract.log').read_text(errors='replace')
dv = re.search(r'display version (\S+)', xlog)
R = [f'# 粗查 | Survey: {nacp_name(ex) or "?"} ({app:016X})', '', f'- 显示版本 | display version: {dv.group(1) if dv else "?"}', f'- 基础版 | base: `{fam["base"].name}` ({fam["base"].stat().st_size / 2**30:.2f} GB)']
R += [f'- 更新 | update: `{p.name}` (v{v}, {p.stat().st_size / 2**30:.2f} GB){"(粗查用的这一个 | used for this survey)" if p == upd else ""}' for v, p in ups]
R += [f'- DLC: `{p.name}` ({p.stat().st_size / 1024:.0f} KB{",只有授权、无内容 | licence only, no content" if p.stat().st_size < 1 << 20 else ""})' for _, _, p in fam['dlc']]
# 代码模块 | Code modules
R += ['', '## 代码模块 | Code modules', '', '| 模块 module | 构建号 build ID | text / ro / data (MB) |', '|---|---|---|']
found, sdkver, comps, unity, mods = {}, set(), set(), set(), []
for p in sorted((ex / 'ExeFS').iterdir()):
if p.read_bytes()[:4] != b'NSO0': continue
img, bid, szs = nso_image(p); mods.append(p)
R.append(f'| {p.name} | `{bid}` | ' + ' / '.join(f'{z / 2**20:.1f}' for z in szs) + ' |')
sdkver |= {m.decode() for m in re.findall(rb'(?i)NintendoSDK_nnSdk-([0-9_]+)', img)} # SDK 12 写作 NintendoSdk_,16 写作 NintendoSDK_ | SDK 12 spells it NintendoSdk_, SDK 16 NintendoSDK_
comps |= {m.decode() for m in re.findall(rb'SDK MW\+Nintendo\+([\w]+-[0-9_]+)', img)}
unity |= {m.decode() for m in re.findall(rb'20[12][0-9]\.[0-9]+\.[0-9]+f[0-9]', img)} if p.name == 'main' else set()
for pat, name in MARKERS:
if pat in img: found.setdefault(name, []).append(p.name)
npdm = ex / 'ExeFS/main.npdm'
if npdm.exists():
d = npdm.read_bytes(); a = d.find(b'ACI0')
if a >= 0: R.append(f'\n- main.npdm 程序号 | program ID: `{struct.unpack_from("<Q", d, a + 0x10)[0]:016X}`')
R.append(f'- SDK: {", ".join(sorted(sdkver)) or "?"}(马力欧 | Mario: 16_2_0 / 16_2_12;D2R ≈ 12)')
R.append('- SDK 组件 | SDK components: ' + (', '.join(sorted(comps)) or '—'))
if unity: R.append('- Unity: ' + ', '.join(sorted(unity)))
R.append('- 引擎与接口标记 | engine and API markers: ' + ('; '.join(f'{k} ({", ".join(v)})' for k, v in found.items()) or '—'))
extra = [m.name for m in mods if m.name not in ('main', 'sdk', 'rtld')]
# RomFS 清单 | RomFS manifest
tops, exts, mags, nfile, nbytes = collections.Counter(), collections.Counter(), collections.Counter(), 0, 0
topb, magb = collections.Counter(), collections.Counter()
for line in (ex / 'romfs_manifest.tsv').read_text(errors='replace').splitlines():
if line.startswith('#'): continue
path, size, head = line.split('\t'); size = int(size); h = bytes.fromhex(head)
nfile += 1; nbytes += size
parts = path.strip('/').split('/')
top = '/'.join(parts[:2]) if len(parts) > 2 else parts[0] if len(parts) > 1 else '(根 | root)' # 两层:Unity 全在 Data/ 下 | two levels: Unity keeps everything under Data/
tops[top] += 1; topb[top] += size
exts[os.path.splitext(path)[1].lower() or '(无 | none)'] += 1
mag = next((n for m, n in MAGICS if h.startswith(m)), None)
if mag: mags[mag] += 1; magb[mag] += size
R += ['', f'## RomFS: {nfile} 个文件 | files, {nbytes / 2**30:.2f} GB', '', '| 顶层 top level | 文件 files | GB |', '|---|---|---|']
R += [f'| {t} | {n} | {topb[t] / 2**30:.2f} |' for t, n in tops.most_common(20)]
R += ['', '| 文件头 header magic | 文件 files | GB |', '|---|---|---|'] + [f'| {m} | {n} | {magb[m] / 2**30:.2f} |' for m, n in mags.most_common()]
R += ['', '扩展名 | extensions: ' + ', '.join(f'`{e}` {n}' for e, n in exts.most_common(20))]
# 改写点统计与 SDK 接口 | Rewrite-site census and SDK surface
R += ['', '## 改写点统计 | Rewrite-site census (insn_census.py)', '', '```']
for m in mods:
if m.name == 'rtld': continue # rtld 由宿主代替 | rtld is replaced by the host
say(f'统计改写点 | counting rewrite sites: {m.name}')
run([py, repo / 'dm/tools/insn_census.py', m], logs / f'census_{m.name}.log', env=env, quiet=True)
R += (logs / f'census_{m.name}.log').read_text(errors='replace').rstrip().splitlines()
R += ['```', '', '## 导入与宿主钩子 | Imports vs host hooks (nso_syms.py)', '', '```']
run([py, repo / 'dm/tools/nso_syms.py', repo / 'dm/host', '-', ex / 'ExeFS'], logs / 'nso_syms.log', env=env, quiet=True)
R += (logs / 'nso_syms.log').read_text(errors='replace').rstrip().splitlines() + ['```']
# 对移植意味着什么 | What it means for a port
notes = []
if 'NVN' not in found: notes.append('没找到 NVN:宿主只实现了 NVN | no NVN found: the host implements only NVN')
if extra: notes.append(f'除 main / sdk 外还有 {", ".join(extra)}:装载器能装最多 8 个模块,但打包与改写目前只处理 main + sdk | modules besides main / sdk: {", ".join(extra)}; the loader takes up to 8 modules, but packaging and rewriting handle only main + sdk today')
if mags['UnityFS'] or 'Unity IL2CPP' in found: notes.append('Unity:着色器在 UnityFS 包里,shader_split.py 读不到,要先写 UnityFS 读取与 Unity 的 NVN 着色器格式 | Unity: shaders sit inside UnityFS bundles, which shader_split.py cannot read; a UnityFS reader and Unity\'s NVN shader format come first')
if not (mags['BNSH'] or mags['BAHS']) and not mags['UnityFS']: notes.append('顶层没有 BNSH / BAHS:着色器在压缩包或别的容器里,要先找到 | no BNSH / BAHS at the top level: the shaders are inside compressed or other containers and must be located first')
major = {v.split('_')[0] for v in sdkver}
if not major: notes.append('没找到 SDK 版本串 | no SDK version string found')
elif major != {'16'}: notes.append(f'SDK {"/".join(sorted(major))},不是马力欧的 16:NVN 对象尺寸、API 版本与服务布局按 SDK 不同(宿主已从 D2R 的 SDK 12 改成 16 的尺寸),按 docs/agents/03 逐项核对 | SDK {"/".join(sorted(major))}, not Mario\'s 16: NVN object sizes, API version and service layouts differ by SDK (the host was converted from D2R\'s SDK 12 to SDK 16 sizes); check them one by one per docs/agents/03')
if any(l.split()[:1] == ['nn::ro'] for l in R): notes.append('导入 nn::ro(运行时装 NRO 模块):宿主没有 | imports nn::ro (loads NRO modules at run time): not in the host')
if fam['dlc']: notes.append('有 DLC:宿主还没有 MountAddOnContent | DLC present: the host has no MountAddOnContent yet')
R += ['', '## 对移植意味着什么 | What it means for a port', ''] + [f'- {n}' for n in notes or ['—']]
R += ['', '下一步见 docs/agents(02 粗查、03 起机)。 | Next steps: docs/agents (02 survey, 03 bring-up).']
(work / 'survey.md').write_text('\n'.join(R) + '\n')
for n in notes: say(n)
say(f'粗查报告 | survey report: {work / "survey.md"}')
# ---- 各步 | Steps ----
def check_prereqs(env, xlate):
missing = [m for m in ('lz4.block', 'capstone', 'fontTools') if not _importable(m)]
@@ -155,49 +294,51 @@ def _importable(m):
except ImportError: return False
def extract(nx, keys, ex, name, base, upd, cmp_dir, logs):
dst = ex / name
if (dst / 'ExeFS' / 'main').exists() and (dst / 'RomFS' / 'System' / 'Resource').is_dir():
def extract(nx, keys, ex, name, base, upd, cmp_dir, logs, g):
dst, marker = ex / name, g.get('romfs_marker')
if (dst / 'ExeFS' / 'main').exists() and (dst / 'RomFS' / marker).exists():
say(f'沿用已解出的 {name} | reusing the extracted {name}: {dst}')
else:
tmp = ex / (name + '.partial'); rmtree(tmp)
say(f'解包 {name} | extracting {name} → {dst}')
run([nx, keys, base, upd or '-', tmp] + ([cmp_dir] if cmp_dir else []), logs / f'extract_{name}.log')
check_build(tmp, name); swap_in(tmp, dst)
check_build(dst, name)
check_build(tmp, name, g); swap_in(tmp, dst)
check_build(dst, name, g)
return dst
def check_build(d, name):
for m, want in BUILDS[name].items():
def check_build(d, name, g): # 每个模块的构建号要是档案 build 行登记的 | every module's build ID must be the one registered in the profile's build row
want = g.builds()[name]
for m, bid in want.items():
if m == 'display': continue
got = build_id(d / 'ExeFS' / m)
if got != want: raise BuildError(f'{m} 构建号 {got} 不是本移植认得的 {name}({want})| {m} build ID {got} is not the {name} this port knows ({want})')
if got != bid: raise BuildError(f'{m} 构建号 {got} 不是游戏档案 {g.id} 登记的 {name}({bid})| {m} build ID {got} is not the {name} registered in game profile {g.id} ({bid})')
def rewrite(py, repo, exe_dir, out, env, logs):
def rewrite(py, repo, exe_dir, out, env, logs, g):
tmp = out.with_name(out.name + '.partial'); rmtree(tmp); tmp.mkdir(parents=True)
for m in ('main', 'sdk'):
for m in g.words('modules'):
say(f'改写 {m} | rewriting {m}')
run([py, repo / 'dm/tools/patch_nso.py', exe_dir / m, tmp, m], logs / f'rewrite_{m}.log', env=dict(env, DM_RULESET='native'))
swap_in(tmp, out)
def split_shaders(py, repo, roms, mains, pkg, env, logs):
def split_shaders(py, repo, roms, mains, pkg, env, logs, g):
tmp = pkg.with_name(pkg.name + '.partial'); rmtree(tmp); (tmp / 'tables').mkdir(parents=True)
say('拆出着色器 | splitting out the shaders')
names = ('shader_map.tsv', 'shader_facts.tsv', 'shader_pairs.tsv')
run([py, repo / 'dm/gfx/tools/shader_split.py', ','.join(map(str, roms)), ','.join(map(str, mains)), tmp / 'pkg'] + [tmp / 'tables' / n for n in names], logs / 'shader_split.log', env=env)
# 表与仓内逐字节相同 = 游戏文件与仓库建表用的是同一份;不同就别往下出包(仓内表不改) | Tables byte-identical to the repo's = the same game files the repo's tables were built from; if not, stop (the repo's tables are left alone)
diff = [n for n in names if (tmp / 'tables' / n).read_bytes() != (repo / 'dm/gfx/nvn' / n).read_bytes()]
if diff: raise BuildError(f'拆出的着色器表与仓内不同 | the split shader tables differ from the repository\'s: {", ".join(diff)}(游戏文件不是 v0 + 1.2.1?| game files not v0 + 1.2.1?)')
# 表与档案里的逐字节相同 = 游戏文件与建表用的是同一份;不同就别往下出包(档案里的表不改) | Tables byte-identical to the profile's = the same game files the tables were built from; if not, stop (the profile's tables are left alone)
diff = [n for n in names if (tmp / 'tables' / n).read_bytes() != g.table(n).read_bytes()]
if diff: raise BuildError(f'拆出的着色器表与游戏档案 {g.id} 的不同 | the split shader tables differ from game profile {g.id}\'s: {", ".join(diff)}(游戏文件不是档案登记的版本?| game files not the versions the profile registers?)')
swap_in(tmp / 'pkg', pkg); rmtree(tmp)
def build_shaders(py, repo, sh, xlate, env, logs):
say('出 iOS 着色器库(首次约 30 分钟,之后走缓存约 7 分钟)| building the iOS shader libraries (about 30 minutes the first time, about 7 with the cache)')
def build_shaders(py, repo, sh, xlate, env, logs, g):
say('出 iOS 着色器库(马力欧首次约 30 分钟,之后走缓存约 7 分钟)| building the iOS shader libraries (Mario: about 30 minutes the first time, about 7 with the cache)')
ios = sh / 'ios'
run([py, repo / 'dm/gfx/tools/dmsh_build.py', sh / 'pkg', sh / 'cache'], logs / 'dmsh_build.log', env=dict(env, XLATE=str(xlate), IOSOUT=str(ios)), quiet=True)
chain = re.search(r'#define DMSH_CHAIN "(.*)"', (repo / 'dm/gfx/nvn/dmsh_chain.h').read_text()).group(1)
chain = re.search(r'#define DMSH_CHAIN "(.*)"', g.table('dmsh_chain.h').read_text()).group(1)
libs = ios / f'b-{chain}'
n = sum(1 for _ in libs.glob('*_b.metallib')) if libs.is_dir() else 0
if n == 0: raise BuildError(f'没出 iOS 着色器库 | no iOS shader libraries in {libs}')
@@ -209,40 +350,60 @@ def main():
here = Path(globals().get('__file__') or sys.argv[0]).resolve()
repo = here.parents[3] # dm/ios/tools/本文件 | dm/ios/tools/this file
if not (repo / 'dm/tools/nx_extract/Program.cs').exists(): raise BuildError(f'从 {here} 推不出仓库根目录 | cannot find the repository root from {here}')
sys.path.insert(0, str(repo / 'dm/games')); import game as gamemod # 游戏档案 | game profiles
nsp_dir = Path(os.path.expanduser(NSP_DIR) or ask('NSP 文件夹 | NSP folder: ')).resolve()
keys = Path(os.path.expanduser(PROD_KEYS) or ask('prod.keys 路径 | path of prod.keys: ')).resolve()
if not nsp_dir.is_dir(): raise BuildError(f'不是文件夹 | not a folder: {nsp_dir}')
if not keys.is_file(): raise BuildError(f'没有这个文件 | no such file: {keys}')
if PACKAGE not in BUILDS: raise BuildError(f"PACKAGE 只能是 | PACKAGE must be one of: {', '.join(BUILDS)}")
out_ipa = Path(os.path.expanduser(OUT_IPA))
work = nsp_dir / 'smbw_build'
xlate = repo / 'dm/gfx/tools/dmsh_xlate_bin/dmsh_xlate'
# DM_ROOT / WORK 指到 smbw_build:各工具的缺省路径与临时目录都不出它 | DM_ROOT / WORK point at smbw_build: the tools' default paths and temporary directories all stay inside it
env = dict(os.environ, PATH=os.pathsep.join(TOOL_PATHS + [os.environ.get('PATH', '/usr/bin:/bin:/usr/sbin:/sbin')]), DM_ROOT=str(work), WORK=str(work / 'tmp'))
env = dict(os.environ, PATH=os.pathsep.join(TOOL_PATHS + [os.environ.get('PATH', '/usr/bin:/bin:/usr/sbin:/sbin')]))
env.setdefault('USER', getpass.getuser()) # xcodegen 没有 USER 就失败(Couldn't find current username) | xcodegen fails without USER (Couldn't find current username)
logs = work / 'logs'
py = sys.executable
app, fam = scan_nsps(nsp_dir)
g = gamemod.by_title(app)
if g is None: # 没有游戏档案:只粗查 | no game profile: survey only
missing = [m for m in ('lz4.block', 'capstone') if not _importable(m)]
if missing: raise BuildError(f'当前 Python({sys.executable})缺模块 | the current Python is missing modules: {", ".join(missing)}')
if not shutil.which('dotnet', path=env['PATH']): raise BuildError('找不到 dotnet(.NET SDK 10)| dotnet not found (.NET SDK 10)')
return survey(nsp_dir, app, fam, keys, repo, py, env)
say(f'游戏档案 | game profile: {g.id}')
builds, need = g.builds(), g.words('shader_versions')
pkg_ver = PACKAGE or g.get('package')
if pkg_ver not in builds: raise BuildError(f"PACKAGE 只能是游戏档案登记的版本 | PACKAGE must be a version the game profile registers: {', '.join(builds)}")
versions = need + ([pkg_ver] if pkg_ver not in need else [])
out_ipa = Path(os.path.expanduser(OUT_IPA or f'~/Desktop/{g.get("ipa")}'))
work = nsp_dir / f'{g.id}_build'
xlate = repo / 'dm/gfx/tools/dmsh_xlate_bin/dmsh_xlate'
# DM_GAME 选档案;DM_ROOT / WORK 指到 <名>_build:各工具的缺省路径与临时目录都不出它 | DM_GAME picks the profile; DM_ROOT / WORK point at <id>_build: the tools' default paths and temporary directories all stay inside it
env.update(DM_GAME=g.id, DM_ROOT=str(work), WORK=str(work / 'tmp'))
logs = work / 'logs'
check_prereqs(env, xlate)
base, upd = find_nsps(nsp_dir)
picks = pick_nsps(fam, versions)
ex = work / 'extract'; ex.mkdir(parents=True, exist_ok=True); (work / 'tmp').mkdir(exist_ok=True)
if free_gb(work) < 12 and not (ex / 'v327680').exists(): raise BuildError(f'{work} 所在的卷剩余不足 12 GB | less than 12 GB free on the volume of {work}')
gb = sum({p.stat().st_size for b, u in picks.values() for p in (b, u) if p}) / 2**30 + 6 # 解包 + 着色器缓存 + 余量 | extraction + shader cache + slack
if free_gb(work) < gb and not all((ex / v).exists() for v in versions): raise BuildError(f'{work} 所在的卷剩余不足 {gb:.0f} GB | less than {gb:.0f} GB free on the volume of {work}')
nx = work / 'tools/nx_extract/nx_extract'
say('编译 nx_extract | building nx_extract')
run(['dotnet', 'build', repo / 'dm/tools/nx_extract', '-c', 'Release', '-o', nx.parent, '-v', 'quiet', '-nologo'], logs / 'nx_extract_build.log', env=env)
v0 = extract(nx, keys, ex, 'v0', base, None, None, logs)
v1 = extract(nx, keys, ex, 'v327680', base, upd, v0 / 'RomFS' if can_clone(ex) else None, logs)
game = v1 if PACKAGE == 'v327680' else v0
dirs, clone = {}, None
for ver in versions: # 基础版先单独解,后面的更新拿它的 RomFS 做克隆比对 | the base game is extracted alone first; later updates clone against its RomFS
b, u = picks[ver]
dirs[ver] = extract(nx, keys, ex, ver, b, u, clone, logs, g)
if u is None and clone is None and can_clone(ex): clone = dirs[ver] / 'RomFS'
game = dirs[pkg_ver]
base = next((dirs[v] for v in versions if picks[v][1] is None), dirs[versions[0]]) # 基础版单独解出的目录 | the folder the base game was extracted to alone
app_name = APP_NAME or nacp_name(base) or g.get('app_name') # 设置 > 基础版 NSP 里的游戏名 > 档案 | setting > game name in the base NSP > profile
say(f'App 名 | app name: {app_name}')
patched = work / f'patched_{PACKAGE}'
rewrite(py, repo, game / 'ExeFS', patched, env, logs)
patched = work / f'patched_{pkg_ver}'
rewrite(py, repo, game / 'ExeFS', patched, env, logs, g)
sh = work / 'shader'
split_shaders(py, repo, [v0 / 'RomFS', v1 / 'RomFS'], [v0 / 'ExeFS/main', v1 / 'ExeFS/main'], sh / 'pkg', env, logs)
ios = build_shaders(py, repo, sh, xlate, env, logs)
split_shaders(py, repo, [dirs[v] / 'RomFS' for v in need], [dirs[v] / 'ExeFS/main' for v in need], sh / 'pkg', env, logs, g)
ios = build_shaders(py, repo, sh, xlate, env, logs, g)
if free_gb(out_ipa.parent) < 5: raise BuildError(f'{out_ipa.parent} 所在的卷剩余不足 5 GB | less than 5 GB free on the volume of {out_ipa.parent}')
say(f'出 IPA | building the IPA → {out_ipa}')
ipa_env = dict(env, W=str(work / 'ipa'), PATCHED=str(patched), ROMFS=str(game / 'RomFS'), MSL_IOS=str(ios), FONT_PY=py)
ipa_env = dict(env, GAME=g.id, W=str(work / 'ipa'), PATCHED=str(patched), ROMFS=str(game / 'RomFS'), MSL_IOS=str(ios), FONT_PY=py, DM_DISPLAY_NAME=app_name)
if DM_ENV_FILE: ipa_env['DM_ENV_FILE'] = os.path.expanduser(DM_ENV_FILE)
run(['bash', repo / 'dm/ios/tools/build_ipa.sh', out_ipa], logs / 'build_ipa.log', env=ipa_env)
if not out_ipa.is_file(): raise BuildError(f'没出 IPA | no IPA at {out_ipa}')