From 09357d9e0439e95b8ac53215ded1e660d57ef1e8 Mon Sep 17 00:00:00 2001 From: seedds Date: Wed, 4 Nov 2026 09:42:46 -0800 Subject: [PATCH] Restore the Metal HUD build default in Mac apps Forward the build's FPS choice to the Mac app, store it with the bundle and apply it after saved/window choices. Keep explicit environment overrides, current Game Mode behavior and multi-program packing. Front-ends: interface: --show-fps and --no-show-fps now also choose the Mac app HUD build default; explicit DM_METAL_HUD and saved choices retain precedence. --- dm/games/0100F43008C44000/README.md | 1 + dm/mac/launcher.c | 3 ++ dm/mac/pack_app.py | 11 ++++-- dm/tools/tests/test_nsp_mac_app.py | 60 +++++++++++++++++++++++++++-- docs/agents/symptoms.md | 1 + docs/development.md | 4 +- docs/reference/packing.md | 9 +++++ scripts/build_ipa_from_nsp.py | 9 +++-- 8 files changed, 86 insertions(+), 12 deletions(-) diff --git a/dm/games/0100F43008C44000/README.md b/dm/games/0100F43008C44000/README.md index b726adeb91..9696354040 100644 --- a/dm/games/0100F43008C44000/README.md +++ b/dm/games/0100F43008C44000/README.md @@ -102,6 +102,7 @@ General host changes first needed here; each applies to every game: - **Shader extraction reuse after translation-only updates:** [Shader extraction reruns after translation-only updates](../../../docs/reference/shaders.md#shader-extraction-reruns-after-translation-only-updates). - **Offline pipeline archive reuse across chains:** [Offline pipeline archive rebuilt after a chain-only update](../../../docs/reference/shaders.md#offline-pipeline-archive-rebuilt-after-a-chain-only-update). +- **Resolved: Mac bundles ignored `--show-fps`:** [the FPS setting and Metal HUD menu](../../../docs/reference/packing.md#the-mac-app-drops-the-fps-setting-and-metal-hud-menu). Visual confirmation in the game remains open. - **The pool enforced as on Horizon** (`mem_map_limit`, `hle_svc.c`): [the profile keys](../README.md#keys), [Maps past the application pool fail as on Horizon](../../../docs/reference/memory.md#maps-past-the-application-pool-fail-as-on-horizon). - **Trinity packs decoded with the game's own Oodle** (`trpfs_jobs` / `trinity_shaders`, `hle_oodle.c`, `DM_OODLE_HOST` / `DM_OODLE_MODULES`, set by the NSP script for any game with `ONEPACK` files): [BNSH in Trinity packs](../../../docs/reference/shaders.md#bnsh-in-trinity-packs), [01, Compressed RomFS content](../../../docs/agents/01-extract.md#compressed-romfs-content). - **SDK 19 calls:** csrng bytes, `GetAudioOutBufferCount` from the host queue, NVN state getters, `WaitForAddress` type 3, `OP_FENCE_SYNC`: [Cryptographically random bytes](../../../docs/reference/services.md#cryptographically-random-bytes), [AudioOut buffer count from the host queue](../../../docs/reference/audio.md#audioout-buffer-count-from-the-host-queue), [State read back through NVN getters](../../../docs/reference/nvn-objects.md#state-read-back-through-nvn-getters), [WaitForAddress type 3](../../../docs/reference/threads.md#waitforaddress-type-3-64-bit-wait-if-equal), [Fence syncs recorded in command buffers](../../../docs/reference/nvn-objects.md#fence-syncs-recorded-in-command-buffers). diff --git a/dm/mac/launcher.c b/dm/mac/launcher.c index 24cd44fed2..5223ac17f8 100644 --- a/dm/mac/launcher.c +++ b/dm/mac/launcher.c @@ -206,6 +206,9 @@ int main(int argc, char **argv) { if (!shown) return 0; // Quit: no game if (shown == 2) saved_choices(); cloud_saves_folder(); + // Apply the build default after saved/window choices; Metal reads the switch at its first device. + CFTypeRef fps = CFBundleGetValueForInfoDictionaryKey(CFBundleGetMainBundle(), CFSTR("DMShowFPS")); + if (fps && CFGetTypeID(fps) == CFBooleanGetTypeID()) setenv("DM_METAL_HUD", CFBooleanGetValue(fps) ? "1" : "0", 0); setenv("DM_ROOT", sup, 0); setenv("DM_ROMFS", romfs, 0); setenv("DM_TEMP", temp, 0); snprintf(p, sizeof p, "%s/Library/Application Support/nxnative/system", home); setenv("DM_SYSTEM_DIR", p, 0); // the player's own console files, for every game snprintf(p, sizeof p, "%s/msl", res); setenv("DM_MSL_DIR", p, 0); diff --git a/dm/mac/pack_app.py b/dm/mac/pack_app.py index f7c8d7c1e4..628714c33d 100755 --- a/dm/mac/pack_app.py +++ b/dm/mac/pack_app.py @@ -5,7 +5,7 @@ # aoc// (DLC), pso/ (an NVN game's pipeline archive made offline for this Mac's GPU, dm/gfx/tools/pso_offline.py: the launcher hands it to the host as DM_PSO_BUNDLE), # and dm/ (the rewritten modules and their rewriter tables, nro.tsv, the profile's shader_map.tsv / pso_manifest.tsv / c1_full.tsv / cb_extent.tsv / shader_pairs.tsv / pso_pairs.tsv, font/, env.txt, # launcher.json: the game's launcher settings, dm/games/settings.py, which the settings window and Mac front ends show), -# plus AppIcon.icns from the game's icon (sips, iconutil) and the Info.plist keys the launcher reads (DMTitleID, DMRomfsMarker, DMLanguage). +# plus AppIcon.icns from the game's icon (sips, iconutil) and the Info.plist keys the launcher reads (DMTitleID, DMRomfsMarker, DMLanguage, DMShowFPS). # The large folders are APFS clones (cp -c) when the app is on the same APFS volume as the build, so they take no space; plain copies otherwise. # Signing: the inner host first, then the bundle (codesign --force --sign ; '-' = ad-hoc), without the hardened runtime: the host maps the game's code executable, which the hardened # runtime refuses without entitlements, and so the app cannot be notarized. The bundle is stripped of its "._name" files first: on exFAT (an external drive for the build or the app) every @@ -124,7 +124,7 @@ def info_plist(a, minos, icon): 'CFBundleVersion': a.version or '1.0', 'CFBundleShortVersionString': a.version or '1.0', 'CFBundlePackageType': 'APPL', 'CFBundleExecutable': EXE, 'CFBundleInfoDictionaryVersion': '6.0', 'LSMinimumSystemVersion': minos, 'LSArchitecturePriority': ['arm64'], 'NSHighResolutionCapable': True, 'NSBluetoothAlwaysUsageDescription': 'Connect Switch 2 Joy-Con and Pro Controllers over Bluetooth.', - 'LSApplicationCategoryType': a.category or 'public.app-category.games', 'LSSupportsGameMode': False, 'DMTitleID': a.title_id.upper(), 'DMRomfsMarker': a.marker or '', 'DMLanguage': a.lang or 'en-US', 'DMSaveSyncChoice': 1} + 'LSApplicationCategoryType': a.category or 'public.app-category.games', 'LSSupportsGameMode': False, 'DMTitleID': a.title_id.upper(), 'DMRomfsMarker': a.marker or '', 'DMLanguage': a.lang or 'en-US', 'DMSaveSyncChoice': 1, 'DMShowFPS': a.show_fps} if icon: p['CFBundleIconFile'] = 'AppIcon' if a.settings: listed = json.loads(Path(a.settings).read_text(encoding='utf-8'))['settings'] @@ -322,7 +322,11 @@ def pack(a): if pairs: shutil.copyfile(pairs, dm / 'pso_pairs.tsv') # the split's copy, for a profile without the table if fonts: copy(fonts, dm / 'font', False) if a.env_file: shutil.copyfile(a.env_file, dm / 'env.txt') - if a.settings: shutil.copyfile(a.settings, dm / 'launcher.json') + if a.settings: + listed = json.loads(Path(a.settings).read_text(encoding='utf-8')) + for setting in listed['settings']: + if setting['env'] == 'DM_METAL_HUD': setting['default'] = '1' if a.show_fps else '0' + (dm / 'launcher.json').write_text(json.dumps(listed, indent=1, ensure_ascii=False) + '\n', encoding='utf-8') step('shader libraries'); msl_dst = res / 'msl' / (a.msl_name or msl.name); msl_dst.parent.mkdir(exist_ok=True); copy_msl(msl, msls, msl_dst, clone['msl']) if aoc: step('add-on content'); copy([aoc], res / 'aoc', clone['aoc']) if pso: step('pipeline archive'); (res / 'pso').mkdir(); copy(pso, res / 'pso', clone['pso']) @@ -382,6 +386,7 @@ def parse(argv): ap.add_argument('--html', help='the offline HTML pages (nx_extract --html), which go beside the RomFS') ap.add_argument('--program', action='append', help='another program of a game of several programs: index, rewritten modules, RomFS, Mac shader libraries, tables, offline pages (tab-separated)') ap.add_argument('--env-file', help='environment switches baked in (Resources/dm/env.txt)') + ap.add_argument('--show-fps', action=argparse.BooleanOptionalAction, default=False, help='enable the Metal performance HUD by default (DM_METAL_HUD overrides it at launch)') ap.add_argument('--settings', help='the game\'s launcher settings (dm/games/settings.py output; Resources/dm/launcher.json, which the Mac front end reads)') ap.add_argument('--title-id', required=True); ap.add_argument('--name', required=True) ap.add_argument('--version'); ap.add_argument('--bundle-id'); ap.add_argument('--category'); ap.add_argument('--lang'); ap.add_argument('--marker', help='the profile\'s romfs_marker') diff --git a/dm/tools/tests/test_nsp_mac_app.py b/dm/tools/tests/test_nsp_mac_app.py index f805fcec17..d4abf26057 100644 --- a/dm/tools/tests/test_nsp_mac_app.py +++ b/dm/tools/tests/test_nsp_mac_app.py @@ -201,6 +201,15 @@ def test_unchanged_reuses_before_partial_clone_compile_and_sign(self): self.assert_reused() self.assertEqual((partial / 'leave').read_text(), 'untouched') + def test_show_fps_change_repacks_and_keeps_the_new_default(self): + self.args.show_fps = False + self.pack(); self.assert_reused() + self.args.show_fps = True + self.assert_repacked() + info = plistlib.loads((Path(self.args.out) / 'Contents/Info.plist').read_bytes()) + self.assertIs(info.get('DMShowFPS'), True) + self.assert_reused() + def test_each_selected_file_content_invalidates_even_with_same_stat(self): self.pack() names = ['dmhost', 'patched/main.patched.bin', 'patched/main.fixups.tsv', 'patched/main.gates.tsv', 'patched/main.cuts.tsv', @@ -532,12 +541,13 @@ def test_saved_choices(self): {'env': 'DM_FPS_HOLD', 'kind': 'toggle', 'section': 'Performance', 'label': 'Hold', 'note': '', 'default': 'on', 'on': 'on', 'off': 'off'}, {'env': 'DM_LANG', 'kind': 'choice', 'section': 'Game', 'label': 'Language', 'note': '', 'default': 'fr', 'options': [['fr', 'French'], ['ja', 'Japanese']]}, {'env': 'DM_BOTH', 'kind': 'choice', 'section': 'Game', 'label': 'Both', 'note': '', 'default': 'bundle', 'options': [['bundle', 'b'], ['player', 'p'], ['saved', 's']]}, - {'env': 'DM_BAKED', 'kind': 'toggle', 'section': 'Game', 'label': 'Baked', 'note': '', 'default': '1', 'on': '1', 'off': '0'}]} + {'env': 'DM_BAKED', 'kind': 'toggle', 'section': 'Game', 'label': 'Baked', 'note': '', 'default': '1', 'on': '1', 'off': '0'}, + {'env': 'DM_METAL_HUD', 'kind': 'toggle', 'section': 'Performance', 'label': 'Performance HUD', 'note': '', 'default': '0', 'on': '1', 'off': '0'}]} sup = self.tmp / f'home/Library/Application Support/nxnative/{TID}'; sup.mkdir(parents=True, exist_ok=True); (sup / 'env.txt').write_text('DM_BOTH=player\n') def pack(name, settings): (self.tmp / f'{name}.json').write_text(json.dumps(settings)) - a = types.SimpleNamespace(**vars(self.args)); a.out = str(self.tmp / f'{name}.app'); a.icon = None; a.bundle_id = domain; a.settings = str(self.tmp / f'{name}.json') + a = types.SimpleNamespace(**vars(self.args)); a.out = str(self.tmp / f'{name}.app'); a.icon = None; a.bundle_id = domain; a.settings = str(self.tmp / f'{name}.json'); a.show_fps = True with mock.patch.object(pack_app, 'sign', lambda *x: None), mock.patch('sys.stdout'): pack_app.pack(a) return self.tmp / f'{name}.app' @@ -551,11 +561,12 @@ def save(key, *value): subprocess.run(['defaults', 'write', domain, f'launcher.{ app = pack('Saved', listed) self.assertEqual(plistlib.loads((app / 'Contents/Info.plist').read_bytes())['DMSettings'][:3], [{'env': 'DM_FPS_TARGET', 'values': ['30', '60']}, {'env': 'DM_RENDER_SCALE', 'min': 0.5, 'max': 1.0}, {'env': 'DM_FPS_HOLD', 'values': ['on', 'off']}]) - for key, value in [('DM_FPS_TARGET', '30'), ('DM_RENDER_SCALE', '0.75'), ('DM_FPS_HOLD', 'maybe'), ('DM_LANG', 'ja'), ('DM_BOTH', 'saved'), ('DM_BAKED', '0')]: save(key, '-string', value) + for key, value in [('DM_FPS_TARGET', '30'), ('DM_RENDER_SCALE', '0.75'), ('DM_FPS_HOLD', 'maybe'), ('DM_LANG', 'ja'), ('DM_BOTH', 'saved'), ('DM_BAKED', '0'), ('DM_METAL_HUD', '0')]: save(key, '-string', value) save('DM_OPMODE', '-string', '0') # not one of this build's settings r = run(app) # from Finder or the Dock: no environment of its own self.assertEqual((r['DM_FPS_TARGET'], r['DM_RENDER_SCALE'], r.get('DM_FPS_HOLD')), ('30', '0.75', None)) # a value the toggle never takes is ignored self.assertEqual(r['DM_LANG'], 'ja') # a saved choice before the packed language + self.assertEqual(r['DM_METAL_HUD'], '0') # saved HUD off wins over --show-fps self.assertEqual((r['DM_BOTH'], r['DM_BAKED']), ('player', '1')) # the player's env.txt and the bundle's win over a saved choice self.assertNotIn('DM_OPMODE', r) self.assertEqual((r['DM_ENV_FILE_KEYS'], r['DM_ENV_LAUNCH_KEYS']), ('DM_BOTH DM_BAKED', '')) # what the settings window keeps: the env files' variables @@ -566,6 +577,7 @@ def save(key, *value): subprocess.run(['defaults', 'write', domain, f'launcher.{ self.assertEqual(r['DM_FPS_TARGET'], '30') self.assertEqual(subprocess.run(['defaults', 'read', domain, 'launcher.DM_FPS_TARGET'], capture_output=True, text=True).stdout.strip(), '30') r = run(app, DM_SAVED_CHOICES='0') + self.assertEqual(r['DM_METAL_HUD'], '1') # the build default when saved choices are ignored self.assertEqual((r.get('DM_FPS_TARGET'), r.get('DM_RENDER_SCALE'), r['DM_LANG']), (None, None, 'fr')) save('DM_RENDER_SCALE', '-float', '0.75') # not a string self.assertNotIn('DM_RENDER_SCALE', run(app)) @@ -581,6 +593,36 @@ def save(key, *value): subprocess.run(['defaults', 'write', domain, f'launcher.{ subprocess.run(['defaults', 'delete', domain], capture_output=True) Path(os.path.expanduser(f'~/Library/Preferences/{domain}.plist')).unlink(missing_ok=True) + def test_show_fps_reaches_the_host_and_environment_overrides_win(self): + # The real packer and launcher must deliver the build choice before the host creates Metal's device. + with tempfile.TemporaryDirectory(dir=TEST_ROOT) as tmp: + tmp = Path(tmp) + a = types.SimpleNamespace(**vars(self.args)); a.out = str(tmp / 'HUD.app'); a.icon = None + source = {'game': TID, 'settings': [{'env': 'DM_METAL_HUD', 'kind': 'toggle', 'default': '0', 'on': '1', 'off': '0'}]} + a.settings = str(tmp / 'launcher.json'); Path(a.settings).write_text(json.dumps(source)) + cases = [('default', None, None, None), ('bundle', '0', None, None), + ('player', '0', '1', None), ('process', '0', '1', '0')] + for enabled in (False, True): + a.show_fps = enabled + with contextlib.redirect_stdout(io.StringIO()): pack_app.pack(a) + packed = json.loads((Path(a.out) / 'Contents/Resources/dm/launcher.json').read_text()) + self.assertEqual(packed['settings'][0]['default'], '1' if enabled else '0') + self.assertEqual(json.loads(Path(a.settings).read_text()), source) # never rewrite the input settings + for name, baked, player, process in cases: + with self.subTest(enabled=enabled, source=name): + home = tmp / f'home-{enabled}-{name}'; home.mkdir() + sup = home / f'Library/Application Support/nxnative/{TID}'; sup.mkdir(parents=True) + bundle_env = Path(a.out) / 'Contents/Resources/dm/env.txt' + bundle_env.write_text('' if baked is None else f'DM_METAL_HUD={baked}\n') + if player is not None: (sup / 'env.txt').write_text(f'DM_METAL_HUD={player}\n') + env = dict(HOME=str(home), PATH='/usr/bin:/bin', DM_LAUNCHER='0', DM_SAVED_CHOICES='0') + if process is not None: env['DM_METAL_HUD'] = process + r = subprocess.run([Path(a.out) / 'Contents/MacOS/nxnative'], env=env, capture_output=True, timeout=30) + self.assertEqual(r.returncode, 7, r.stdout + r.stderr) + values = dict(line.split('=', 1) for line in (home / 'host_run.txt').read_text().splitlines()) + want = {'default': '1' if enabled else '0', 'bundle': '0', 'player': '1', 'process': '0'}[name] + self.assertEqual(values.get('DM_METAL_HUD'), want) + def test_signed(self): # ad-hoc, both the host and the bundle, never the hardened runtime (it refuses the executable memory the host makes for the game's code) for target in (self.app, self.app / 'Contents/MacOS/dmhost'): subprocess.run(['codesign', '--verify', '--strict', target], check=True, capture_output=True) @@ -768,11 +810,21 @@ def sw_vers(cmd, *a, **k): return subprocess.CompletedProcess(cmd, 0, '26.1\n', with mock.patch.object(nsp, 'run', fake_run), mock.patch.object(nsp, 'PROG', self.prog), mock.patch.object(subprocess, 'run', sw_vers), mock.patch.object(nsp, 'chain_of', lambda repo: '0123456789ab'), \ mock.patch.object(nsp, 'mac_gpu_arch', lambda *a: 'applegpu_g14s'), mock.patch.object(nsp, 'say', self.said.append): nsp.mac_app(sys.executable, REPO, out, 0x01008CF01BAAC000, tmp, tmp / 'ex', tmp / 'patched', tmp / 'dmhost', tmp / 'font', tmp / 'shader/cache' if gfx == 'nvn' else tmp / 'shader', - kw.get('aoc'), gfx, 'en-US', g, 'Game', '1.0.0', 'org.nxnative.01008cf01baac000', None, kw.get('sign', ''), {}, tmp, gpus=kw.get('gpus', ''), prebake=kw.get('prebake', False)) + kw.get('aoc'), gfx, 'en-US', g, 'Game', '1.0.0', 'org.nxnative.01008cf01baac000', None, kw.get('sign', ''), {}, tmp, gpus=kw.get('gpus', ''), prebake=kw.get('prebake', False), + **({'show_fps': kw['show_fps']} if 'show_fps' in kw else {})) self.pso_cmd = next((c for c in ran if len(c) > 1 and c[1].endswith('pso_offline.py')), None) # before the packer self.settings_cmd = next(c for c in ran if len(c) > 1 and c[1].endswith('dm/games/settings.py')) + self.pack_argv = ran[-1] c = ran[-1]; return {c[i]: c[i + 1] for i in range(3, len(c) - 1) if c[i].startswith('--')}, tmp + def test_show_fps_build_default_and_command_line_override(self): + for default, flags, wanted in [(True, [], '--show-fps'), (False, [], '--no-show-fps'), + (False, ['--show-fps'], '--show-fps'), (True, ['--no-show-fps'], '--no-show-fps')]: + with self.subTest(default=default, flags=flags), mock.patch.object(nsp, 'SHOW_FPS', default): + a = nsp.parse_args(['--platform', 'mac-app'] + flags) + self.pack_cmd('nvn', **({'show_fps': a.show_fps} if a.show_fps is not None else {})) + self.assertIn(wanted, self.pack_argv) + def test_pack_command(self): o, tmp = self.pack_cmd('nvn') self.assertNotIn('--pso-pairs', o) # no split copy diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 5ad31fcfd7..dd82bc8a87 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -20,6 +20,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - `vk_shaders.py` stops with `OSError: [Errno 7] Argument list too long: 'xcrun'` after the Metal compiles, in the `metallib` link. → [The Vulkan shader step fails with `Argument list too long: 'xcrun'`](../reference/packing.md#the-vulkan-shader-step-fails-with-argument-list-too-long-xcrun) - the shader split stops with `the split shader tables differ from game profile 's`, on the game version the profile registers. → [The split shader tables differ from a game profile's](../reference/packing.md#the-split-shader-tables-differ-from-a-game-profiles) - `dmsh_build.py` stops with `[RED] holds no record of shader , which the shader map names: the package and the map come from different shader_split.py runs`, with `(its records carry no keys: it was split before they did)` for a package from before chain `3df44a5a4e0c`. Before that chain: wrong shaders wherever the game drew, such as a black screen with sound. → [The shader package and the shader map come from different splits](../reference/packing.md#the-shader-package-and-the-shader-map-come-from-different-splits) +- a Mac app built with `--show-fps` has neither the Metal performance overlay nor its Metal HUD menu. → [The Mac app drops the FPS setting and Metal HUD menu](../reference/packing.md#the-mac-app-drops-the-fps-setting-and-metal-hud-menu) - AltServer stops installing an IPA with "The file “b-.img” couldn’t be saved in the folder “msl”", although the RomFS stays out of it (`--romfs separate`). Other install tools install the same IPA. → [AltServer cannot install a file of 2 GiB or more](../reference/packing.md#altserver-cannot-install-a-file-of-2-gib-or-more) - packing succeeds, but `unzip -t` or Python's `ZipFile.testzip()` reports a bad CRC for a large deflated file. → [A streamed file changes size and the IPA fails its CRC check](../reference/packing.md#a-streamed-file-changes-size-and-the-ipa-fails-its-crc-check) - `devicectl` copying a large RomFS folder fails with POSIX error 24 (`EMFILE`) or spends most of its time on small files; deleting an old folder during iOS startup hits the launch watchdog. A push after changing extracted files can also keep sending old assets if an image is reused solely because it exists. → [RomFS folder transfers fail or leave stale game files](../reference/packing.md#romfs-folder-transfers-fail-or-leave-stale-game-files) diff --git a/docs/development.md b/docs/development.md index 44c96be81d..f9b6d17683 100644 --- a/docs/development.md +++ b/docs/development.md @@ -585,7 +585,7 @@ The bundle holds what the IPA holds, with the Mac's shader libraries: ``` .app/Contents/ - Info.plist name, version, bundle ID, the profile's app_category, LSSupportsGameMode false (reference/frame-rate.md); NSBluetoothAlwaysUsageDescription for Switch 2 controllers (reference/services.md); DMTitleID, DMRomfsMarker, DMLanguage, DMModuleFiles and DMSettings for the launcher + Info.plist name, version, bundle ID, the profile's app_category, LSSupportsGameMode false (reference/frame-rate.md); NSBluetoothAlwaysUsageDescription for Switch 2 controllers (reference/services.md); DMTitleID, DMRomfsMarker, DMLanguage, DMShowFPS, DMModuleFiles and DMSettings for the launcher MacOS/nxnative the launcher (dm/mac/launcher.c, linked by pack_app.py with swiftc and its settings window: dm/mac/Launcher.swift, LauncherSettings.swift and LauncherRow.swift) MacOS/dmhost the Mac host built for the profile Resources/romfs.img the packed version's RomFS image @@ -596,6 +596,8 @@ The bundle holds what the IPA holds, with the Mac's shader libraries: Resources/AppIcon.icns ``` +`--show-fps` enables Apple's Metal performance HUD and its Metal HUD menu in a Mac app; `--no-show-fps` disables that default. With neither option, the script uses `SHOW_FPS` (on by default), as for the iPhone's frame-rate counter. The settings window starts with that choice, and saved preferences or `DM_METAL_HUD=0` or `1` in the app's environment files override it without rebuilding. The development-only `--platform mac` command continues to use `DM_METAL_HUD` directly. + The shader pairs are what the pipeline prebuild and the hybrid pipelines read ([Hybrid pipelines](#hybrid-pipelines)), as in the IPA; a profile without `pso_pairs.tsv` gets the shader split's copy (`--pso-pairs`). An app without them builds no base: [Shader-pair tables in a Mac app](reference/shaders.md#shader-pair-tables-in-a-mac-app). An NVN game's app also carries its pipeline archive made offline ([The pipeline archive made offline](#the-pipeline-archive-made-offline)), built by `pso_offline.py --platform macos` for this Mac's GPU (`MTLDevice.architecture`, e.g. `applegpu_g14s` for an M2 Max; `--gpu` names others, `none` builds none) into `_build/pso/mac` and made again only when its inputs change, with a compute pipeline for each compute shader beside the bases. The launcher hands `Resources/pso` to the host as `DM_PSO_BUNDLE`, as the iOS app's `main.m` does: the first start builds no base (`pipeline archive bundled with the app: holds the bases of every shader pair (16 of 16 probed bases found …)`), and the bases (about 615 MB for Breath of the Wild) stay in the app instead of the player's `~/Library/Application Support`. On a Mac with another GPU, or after a macOS update that changed the GPU compiler, the app builds them there as before. `PSO_GPUS` is shared with the IPA, so a value that does not name this Mac's GPU (`g18p`, an iPhone's) still builds what it names, and the console says that this Mac's app will build its own bases. When every eligible pair declares function constants, `pso_offline.py` makes no archive and the app is packed without one. diff --git a/docs/reference/packing.md b/docs/reference/packing.md index 49095a5bd2..6e39b5d617 100644 --- a/docs/reference/packing.md +++ b/docs/reference/packing.md @@ -118,6 +118,15 @@ - **Fix:** general, in the package format. `shader_split.py` writes each record's key (its 8 bytes) at record +0x08, in header bytes that were zero; `dmsh_build.py` `record_index` indexes a package by those keys and `rec_of` finds a row's record by its key, so a row's index no longer matters and a package that does not hold a row's shader stops the build before the first job. The control section is searched from +0x38, after the header. The map keeps its index column, so the profiles' tables did not change. A package from before the change carries no keys: the NSP script splits again by itself (its key covers `shader_split.py`), while a tool that hands `dmsh_build.py` an older package, such as `regress.py`'s use of a game's `_build/shader/pkg`, needs that build run again first. `test_shader_package_keys` covers both sides. - **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md) ([issue #1841](https://github.com/seedds/nxnative/issues/1841), diagnosed by its reporter). A build through NX2Apple 1.3.0 kept `ps_lib.bin` from an older split (4,711 records) beside a map from a split after 04df08d4 (4,707 rows), so the present pass's pixel shader `29e3c9dd92b8a571` was translated from a terrain shader's record and drew black. +## Mac apps + +### The Mac app drops the FPS setting and Metal HUD menu + +- **Symptom:** a Mac app built with `--show-fps` has neither the Metal performance overlay nor its Metal HUD menu. +- **Cause:** the NSP script returned from its Mac packaging path before applying `SHOW_FPS` / `--show-fps`. The older bundler carried that preference into the app, but the current launcher received no HUD default. The Mac host enables Apple's HUD through `DM_METAL_HUD`, and Apple adds its menu when the HUD is enabled. +- **Fix:** `scripts/build_ipa_from_nsp.py` passes the resolved choice to `dm/mac/pack_app.py`, which stores `DMShowFPS` in `Info.plist`. `dm/mac/launcher.c` sets `DM_METAL_HUD` from it before spawning the host, after reading environment files and the player's saved or window choices so those retain precedence. The packed `launcher.json` HUD row uses the same build default. `--no-show-fps` disables the bundled default; changing the option invalidates bundle reuse. +- **Found on:** [Pokémon Legends: Z-A](../../dm/games/0100F43008C44000/README.md); applies to every Mac app. The restored menu still needs a visual check in a running game. + ## IPA archives ### AltServer cannot install a file of 2 GiB or more diff --git a/scripts/build_ipa_from_nsp.py b/scripts/build_ipa_from_nsp.py index 9346fecdd0..5ac13b0a33 100755 --- a/scripts/build_ipa_from_nsp.py +++ b/scripts/build_ipa_from_nsp.py @@ -80,7 +80,7 @@ # empty = by the game's profile: its pso_gpu key (whitespace-separated architectures, joined with commas) when it sets pso_mtl4 on (the Metal 4 path, the only one that reads the archive on iOS, is on for it), # else 'g18p' for a game without the key, and 'none' without pso_mtl4 on # PLATFORM 'mac-app': the Mac app's archive (Resources/pso), empty = this Mac's GPU (e.g. 'g14s' for an M2 Max), 'none' = the app builds its bases on its first start -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 +SHOW_FPS = True # iPhone: frame-rate counter (DM_SHOW_FPS overrides); Mac app: Metal performance HUD (DM_METAL_HUD overrides in the app's env.txt) MOTION = True # True passes a gyro controller's motion (on iOS also the phone's own) to games that read it; False builds with DM_MOTION=0, for when motion misbehaves (DM_MOTION=1 in the app's Documents/env.txt turns it back on 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 = /profile, i.e. /_build/profile unless WORK_DIR is set; the profile is <this>/<title ID>/) @@ -2933,7 +2933,7 @@ def mac_app_name(name): # the app's file name: the game name with the characte return re.sub(r'\s*[:/]\s*', ' - ', name).strip(' .-') or 'nxnative' -def mac_app(py, repo, out, app, work, game, patched, host, font, msl, aoc, gfx, lang, g, name, version, bundle_id, icon, sign, env, logs, env_file='', gpus='', programs=None, control=None, prebake=False): +def mac_app(py, repo, out, app, work, game, patched, host, font, msl, aoc, gfx, lang, g, name, version, bundle_id, icon, sign, env, logs, env_file='', gpus='', programs=None, control=None, prebake=False, show_fps=None): """The last stage of PLATFORM 'mac-app': pack the Mac app (dm/mac/pack_app.py) from what a Mac-only build made: the Mac host and font (mac_host), the packed version's RomFS and rewritten modules, the Mac shader libraries (msl: <shader>/cache, whose b-<chain> goes in, or a Vulkan or GL game's <shader>, whose vk / gl goes in), the DLC, the profile's tables (with the shader pairs the pipeline prebuild reads), the pipeline archive made offline for this Mac (mac_pso; gpus: --gpu / PSO_GPUS), the launcher settings and an environment file. @@ -2951,6 +2951,7 @@ def mac_app(py, repo, out, app, work, game, patched, host, font, msl, aoc, gfx, run([py, repo / 'dm/games/settings.py', g.id, patched, control or game / 'control.nacp', settings], logs / 'mac_settings.log', env=dict(env, GFXAPI=gfx, SETTINGS_PLATFORM='mac', DM_LANG=lang)) cmd = [py, repo / 'dm/mac/pack_app.py', out, '--host', host, '--patched', patched, '--romfs', rfs.source(game), '--msl', lib, '--tables', g.table('shader_map.tsv').parent, '--font', font, '--title-id', f'{app:016X}', '--name', name, '--bundle-id', bundle_id, '--lang', lang, '--sign', sign or '-'] + cmd += ['--show-fps' if (SHOW_FPS if show_fps is None else show_fps) else '--no-show-fps'] split_pairs = work / 'shader/pso_pairs.tsv' # the split's copy of pso_pairs.tsv, for a profile without the table (as mac_run's DM_PSO_PAIRS) for opt, v in (('--version', version), ('--category', g.get('app_category')), ('--marker', g.get('romfs_marker')), ('--aoc', aoc), ('--icon', icon), ('--pso', pso), ('--pso-pairs', split_pairs if gfx == 'nvn' and not g.table('pso_pairs.tsv').is_file() and split_pairs.is_file() else None), @@ -3015,7 +3016,7 @@ def parse_args(argv): '--no-prebake: the app builds them on its first start (default: the PREBAKE setting, on)') ap.add_argument('--seen-shaders', action=argparse.BooleanOptionalAction, help='--platform mac or mac-app: build only the shaders the profile\'s playtests used (its shader_seen.tsv) and those the last runs\' logs reported missing, not the whole map: ' 'minutes instead of hours, but areas the list does not cover draw without the rest until this runs again (default: the SEEN_SHADERS setting, off)') - 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('--show-fps', action=argparse.BooleanOptionalAction, help='show the frame-rate counter on iPhone or the Metal performance HUD in a Mac app (default: the SHOW_FPS setting, on)') ap.add_argument('--motion', action=argparse.BooleanOptionalAction, help='pass a gyro controller\'s motion (on iOS also the phone\'s own) to games that read it; --no-motion builds with DM_MOTION=0, for when motion misbehaves (default: the MOTION 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('--program', type=int, default=None, help='a multi-program application (Super Mario 3D All-Stars): pack program N of the NSPs as a game of its own, title ID = the application\'s + N (default: the PROGRAM_INDEX setting, 0; a game whose profile lists programs packs them all as one from program 0: this is for bring-up)') @@ -3379,7 +3380,7 @@ def derive_shader_tables(reason): # called only after the strict DLC-only or a programs = other_programs(args, g) if not icon: say(('no game icon: the NUP carries none' if nup else 'no game icon: a dump folder has no control data') + '; the app\'s icon will be blank; --icon / ICON or --fallback-icon / FALLBACK_ICON gives it one' if dump else '[RED] no game icon in the extraction (an older extraction did not save it: delete extract/ and run again); the app\'s icon will be blank') mac_app(py, repo, out_ipa, app, work, game, patched, host, font, ios, aoc, gfx, lang, g, app_name, app_version, bundle_id, icon, args.mac_sign or MAC_SIGN, env, logs, env_file, - (args.gpu or PSO_GPUS).strip(), programs, control, PREBAKE if args.prebake is None else args.prebake) + (args.gpu or PSO_GPUS).strip(), programs, control, PREBAKE if args.prebake is None else args.prebake, show_fps=args.show_fps) rmtree(work / 'tmp') say(f'done: {out_ipa} ({tree_size(out_ipa)[1] / 2**30:.1f} GB); ' f'open it from Finder. Its save is in ~/Library/Application Support/nxnative/{app:016X}/, its log in ~/Library/Logs/nxnative/{app:016X}.log')