Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dm/games/0100F43008C44000/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions dm/mac/launcher.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions dm/mac/pack_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# aoc/<index>/ (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 <identity>; '-' = 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
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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')
Expand Down
60 changes: 56 additions & 4 deletions dm/tools/tests/test_nsp_mac_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading