Build and ship offline Metal pipeline archives (#226)

Compile eligible shader-pair bases on the Mac with metal-tt, package one
archive per selected iPhone GPU, and use a matching bundle before falling
back to on-device preparation. Carry the profile depth mode through packing
and include the pulling libraries required by the bundled bases.

Integrate PR #226 with four confirmed fixes: validate archive container
bounds before invoking Metal; require at least one actual successful bundle
probe; hash consumed library bytes into offline cache keys; and emit device
remainder lists for constant-only plans instead of aborting packaging.
Keep game measurements in the game note and document the general findings.

Validation on current main: 52 real Metal bundle checks, 57 archive checks,
79 prewarm checks on each pipeline path, 73 Python regressions covering
offline tools, packaging, documentation and chain checks, and compilation
of all 133 iOS source files. No new iPhone gameplay validation. The archive guard validates
container structure, not private Metal payloads.

Co-authored-by: v0lumehi <aljic.zijad@gmail.com>
This commit is contained in:
seeddsandv0lumehi committed 2026-10-26 10:52:50 +08:00
1 parent c6c4fac5c3
commit 92fc2f2711
35 files changed
+986 -51

No files matched your search

+13 -1
View File
@@ -48,6 +48,9 @@ LANGUAGE = '' # game language, a code or a name: 'ja',
# DM_LANG=<code> in the app's Documents/env.txt changes it without a rebuild
KEEP_EXTRACTED = True # keep the extracted game files after the IPA is built (saves extraction next time; as big as the game files); False deletes them
DM_ENV_FILE = '' # optional: environment switches for a diagnostic build (one KEY=VALUE per line)
PSO_GPUS = '' # NVN games: GPU architectures the app gets the shader pairs' pipeline bases for, made on this Mac (metal-tt, comma-separated: 'g18p' = A19 / A19 Pro of the iPhone 17 family;
# another device's name is in its log when the app has none for it), so the device does not build them on its first start; each adds about 650 MB to the app (Breath of the Wild) and minutes to a first build; 'none' = no archive;
# empty = by the game's profile: 'g18p' when it sets pso_mtl4 on (the Metal 4 path, the only one that reads the archive on iOS, is on for it), else 'none'
SHOW_FPS = True # True shows the game's frame rate on the iPhone (frames on screen per second, top left); DM_SHOW_FPS=0 / 1 in the app's Documents/env.txt changes it without a rebuild
SURVEY_ONLY = False # a game with no profile in dm/games: only write the survey, no provisional profile and no IPA
PROFILE_OUT = '' # where a provisional profile goes (empty = <NSP folder>/<title ID>_build/profile; the profile is <this>/<title ID>/)
@@ -1471,6 +1474,7 @@ class IpaWatch:
s = re.sub(r'^\[[\d:]+\] ', '', line)
with self.lock: # a poll sees a step change whole
if s.startswith('generate Gen/'): self.state = 'gen'; PROG.set(step='generating sources')
elif s.startswith('pipeline archive for'): PROG.set(step='pipeline archive (metal-tt)')
elif s.startswith('xcodebuild'):
units = len(os.listdir(self.ios / 'Gen/src')) + 2 + sum(1 for p in (self.ios / 'Sources').iterdir() if p.suffix in ('.c', '.m')) # + Gen/main/main.c and Gen/embed.S
self.state, self.t = 'xcode', time.time(); PROG.set(step='xcodebuild', done=0, total=units, unit='files', est=True) # an incremental build compiles fewer
@@ -1548,9 +1552,10 @@ def mac_run(work, game, patched, host, font, msl, aoc, gfx, lang, env, logs, g,
if not g.table('pso_pairs.tsv').is_file() and (work / 'shader/pso_pairs.tsv').is_file(): renv['DM_PSO_PAIRS'] = work / 'shader/pso_pairs.tsv' # split_shaders' copy
# The pipeline prebuild (docs/development.md, Hybrid pipelines): the pulling base of every shader pair into run/pso_archive, so the first run's draws have stand-ins instead
# of waiting for the Metal compiler. The host's tool mode skips it when pso_archive/prebuilt.tsv matches (chain version, depth mode, pair list): Breath of the Wild's
# 12,755 pairs took about 3 minutes on an M2 Max the first time. It assumes the GL depth mode; a game that sets near=0 builds its bases again in the background in the app
# 12,755 pairs took about 3 minutes on an M2 Max the first time. It takes the depth mode from the profile's depth_mode key (default gl, NVN's); with a wrong one the game builds its bases again in the app
PROG.add(1); PROG.stage('Pipeline prebuild', step='building')
penv = dict(env, **{k: str(v) for k, v in renv.items()}, DM_PSO_PREBUILD='tool')
if g.get('depth_mode', 'gl') == 'zero': penv['DM_PSO_PREBUILD_DEPTH'] = 'zero' # a game that sets the near = 0 depth mode (the profile says so: the host learns it only at run time)
run([host], logs / 'pso_prebuild.log', env=penv, cwd=rd)
PROG.end()
q = lambda v: shlex.quote(str(v))
@@ -1610,6 +1615,8 @@ def parse_args(argv):
'it must be one the game supports, which the console lists')
ap.add_argument('--keep-extracted', action=argparse.BooleanOptionalAction, help='keep the extracted game files after the IPA is built, for the next build (default: the KEEP_EXTRACTED setting, on)')
ap.add_argument('--env-file', help='environment switches for a diagnostic build (one KEY=VALUE per line)')
ap.add_argument('--gpu', help='NVN games: GPU architectures the app gets its pipeline archive for, made on this Mac with metal-tt, comma-separated: g18p (A19 / A19 Pro), or the name a device\'s log gives when the app has no archive for its GPU; '
'none = no archive, the device builds the bases on its first start (default: the PSO_GPUS setting; when that is empty, g18p for a game whose profile sets pso_mtl4 on, else none)')
ap.add_argument('--show-fps', action=argparse.BooleanOptionalAction, help='show the game\'s frame rate on the iPhone, frames on screen per second at the top left (default: the SHOW_FPS setting, on)')
ap.add_argument('--survey-only', action='store_true', help='a game with no profile: only the survey, no provisional profile and no IPA')
ap.add_argument('--allow-damaged', action='store_true', help='a damaged NSP (files failing the NCA hash check): write them as read and go on instead of stopping')
@@ -1853,6 +1860,11 @@ def main(args=None):
if env_file: ipa_env['DM_ENV_FILE'] = str(Path(os.path.expanduser(env_file)).resolve())
show_fps = SHOW_FPS if args.show_fps is None else args.show_fps
ipa_env['DM_SHOW_FPS'] = '1' if show_fps else '0'
gpus = (args.gpu or PSO_GPUS).strip()
if gpus and not re.fullmatch(r'none|(applegpu_)?g\w+(,(applegpu_)?g\w+)*', gpus): raise BuildError(f'--gpu / PSO_GPUS takes GPU architectures such as g18p,g17p, or none: {gpus}')
if not gpus: gpus = 'g18p' if g.get('pso_mtl4', 'off') == 'on' else 'none' # by the profile, as build_ipa.sh decides without DM_PSO_GPUS: only the Metal 4 path reads the archive on iOS
ipa_env['DM_PSO_GPUS'] = gpus
if gfx == 'nvn': say(f'pipeline archive: {"none, the device builds the bases on its first start if it takes the Metal 4 path (--gpu g18p builds one)" if gpus == "none" else gpus + " (made on this Mac with metal-tt; --gpu none leaves it out)"}')
if show_fps: say('frame-rate counter: on (DM_SHOW_FPS=0 in the app\'s Documents/env.txt turns it off)')
if prepare_xcode:
PROG.stage('prepare Xcode')