diff --git a/README.md b/README.md index 32fa4af..5fa5c6c 100644 --- a/README.md +++ b/README.md @@ -746,8 +746,6 @@ wrapper = "/path/with spaces/gamemoderun" no_wine = true ; Override the executable launched for this game, for example to bypass a launcher (e.g. Borderlands) override_exe = relative/path/to/file.exe -; Disable selective downloading for this title -disable_sdl = true [AppName3] ; Command to run before launching the gmae diff --git a/legendary/cli.py b/legendary/cli.py index 383cc55..029f8af 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -28,7 +28,7 @@ from legendary.utils.custom_parser import HiddenAliasSubparsersAction from legendary.utils.env import is_windows_mac_or_pyi from legendary.lfs.eos import add_registry_entries, query_registry_entries, remove_registry_entries from legendary.lfs.utils import validate_files, clean_filename -from legendary.utils.selective_dl import get_sdl_data, LGDEvaluationContext, EXTRA_FUNCTIONS +from legendary.utils.selective_dl import get_sdl_data, LGDEvaluationContext, parse_components_selection, EXTRA_FUNCTIONS from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_file_search # todo custom formatter for cli logger (clean info, highlighted error/warning) @@ -929,36 +929,7 @@ class LegendaryCLI: install_tags = set() if sdl_data: - for element in sdl_data['Data']: - if element.get('IsRequired', 'false').lower() == 'true': - install_tags.update(element.get('Tags', [])) - if element['UniqueId'] not in install_components: - install_components.append(element['UniqueId']) - continue - if element.get('Invisible', 'false').lower() == 'true': - tk = Tokenizer(element['InvisibleSelectedExpression'], context) - tk.extend_functions(EXTRA_FUNCTIONS) - tk.compile() - if tk.execute(''): - logger.info('Selecting invisible component') - install_tags.update(element.get('Tags', [])) - if element['UniqueId'] not in install_components: - install_components.append(element['UniqueId']) - - # The ids may change from revision to revision, this property lets us match options against older options - upgrade_id = element.get('UpgradePathLogic') - if upgrade_id and upgrade_id in install_components: - install_tags.update(element.get('Tags', [])) - # Replace component id with upgraded one - install_components = [element['UniqueId'] if el == upgrade_id else el for el in install_components] - - if element['UniqueId'] in install_components: - install_tags.update(element.get('Tags', [])) - - if element.get('ConfigHandler'): - for child in element.get('Children', []): - if child['UniqueId'] in install_components: - install_tags.update(child.get('Tags', [])) + parse_components_selection(sdl_data, context, install_components, install_tags) if install_components: self.core.lgd.config.set(game.app_name, 'install_components', ','.join(install_components)) @@ -1845,19 +1816,41 @@ class LegendaryCLI: else: manifest_info.append(InfoItem('Uninstaller', 'uninstaller', None, None)) - install_tags = {''} - for fm in manifest.file_manifest_list.elements: - for tag in fm.install_tags: - install_tags.add(tag) - if sdl_data: + context = LGDEvaluationContext(self.core) install_tags_human = [] + install_tags_data = [] for element in sdl_data['Data']: if not element.get('Title'): continue + is_required = element.get('IsRequired','false')=='true' + is_default_selected = element.get('IsDefaultSelected','false')=='true' + if is_default_selected and element.get('DefaultSelectedExpression'): + tk = Tokenizer(element['DefaultSelectedExpression'], context) + tk.extend_functions(EXTRA_FUNCTIONS) + tk.compile() + is_default_selected = tk.execute('') + # This mapping abstracts expressions away from info command + # marking default for options that apply to given user + mapped_element = { + 'UniqueId': element.get('UniqueId'), + 'IsRequired': is_required, + 'IsDefaultSelected': is_default_selected + } + install_tags_data.append(mapped_element) + + for key in element.keys(): + if key.endswith('_translate'): + mapped_element[key] = element[key] == 'true' + elif key.startswith('Title') or key.startswith('Description'): + mapped_element[key] = element[key] + required_txt = ' (required)' if is_required else '' if element.get('Children'): + mapped_element['ConfigHandler'] = element['ConfigHandler'] + mapped_element['Children'] = element['Children'] + install_tags_human.append(f'{element["Title"]}{required_txt}') for child in element.get('Children', []): install_tags_human.append(f'\t{child["UniqueId"]} - {child["Title"]}') @@ -1865,8 +1858,9 @@ class LegendaryCLI: install_tags_human.append(f'{element["UniqueId"]} - {element["Title"]}{required_txt}') else: install_tags_human = '(none)' + install_tags_data = None - manifest_info.append(InfoItem('Install components', 'install_components', install_tags_human, sdl_data.get('Data'))) + manifest_info.append(InfoItem('Install components', 'install_components', install_tags_human, install_tags_data)) # file and chunk count manifest_info.append(InfoItem('Files', 'num_files', manifest.file_manifest_list.count, manifest.file_manifest_list.count)) @@ -1882,44 +1876,6 @@ class LegendaryCLI: manifest_info.append(InfoItem('Download size (compressed)', 'download_size', chunk_size, total_size)) - # if there are install tags break down size by tag - """ - tag_disk_size = [] - tag_disk_size_human = [] - tag_download_size = [] - tag_download_size_human = [] - if len(install_tags) > 1: - longest_tag = max(max(len(t) for t in install_tags), len('(empty)')) - for tag in install_tags: - # sum up all file sizes for the tag - human_tag = tag or '(empty)' - tag_files = [fm for fm in manifest.file_manifest_list.elements if - (tag in fm.install_tags) or (not tag and not fm.install_tags)] - tag_file_size = sum(fm.file_size for fm in tag_files) - tag_disk_size.append(dict(tag=tag, size=tag_file_size, count=len(tag_files))) - tag_file_size_human = '{:.02f} GiB'.format(tag_file_size / 1024 / 1024 / 1024) - tag_disk_size_human.append(f'{human_tag.ljust(longest_tag)} - {tag_file_size_human} ' - f'(Files: {len(tag_files)})') - # tag_disk_size_human.append(f'Size: {tag_file_size_human}, Files: {len(tag_files)}, Tag: "{tag}"') - # accumulate chunk guids used for this tag and count their size too - tag_chunk_guids = set() - for fm in tag_files: - for cp in fm.chunk_parts: - tag_chunk_guids.add(cp.guid_num) - - tag_chunk_size = sum(c.file_size for c in manifest.chunk_data_list.elements - if c.guid_num in tag_chunk_guids) - tag_download_size.append(dict(tag=tag, size=tag_chunk_size, count=len(tag_chunk_guids))) - tag_chunk_size_human = '{:.02f} GiB'.format(tag_chunk_size / 1024 / 1024 / 1024) - tag_download_size_human.append(f'{human_tag.ljust(longest_tag)} - {tag_chunk_size_human} ' - f'(Chunks: {len(tag_chunk_guids)})') - - manifest_info.append(InfoItem('Disk size by install tag', 'tag_disk_size', - tag_disk_size_human or 'N/A', tag_disk_size)) - manifest_info.append(InfoItem('Download size by install tag', 'tag_download_size', - tag_download_size_human or 'N/A', tag_download_size)) - """ - if not args.json: def print_info_item(item: InfoItem): if item.value is None: @@ -2652,7 +2608,49 @@ class LegendaryCLI: igame.install_path = new_path self.core.install_game(igame) logger.info('Finished.') + + def get_install_size(self, args): + args.app_name = self._resolve_aliases(args.app_name) + game = self.core.get_game(args.app_name, update_meta=False, platform=args.platform) + if not game: + game = self.core.get_game(args.app_name, update_meta=True, platform=args.platform) + + version = game.app_version(args.platform) + manifest_data = self.core.lgd.load_manifest(game.app_name, version, args.platform) + if not manifest_data: + manifest_data, _ = self.core.get_cdn_manifest(game, platform=args.platform) + self.core.lgd.save_manifest(game.app_name, manifest_data, version, args.platform) + + manifest = self.core.load_manifest(manifest_data) + sdl_data = get_sdl_data(self.core.lgd.egl_content_path, game.app_name, version) or {} + install_components = args.install_component or [] + install_tags = set() + if sdl_data: + context = LGDEvaluationContext(self.core) + context.selection.update(install_components) + parse_components_selection(sdl_data, context, install_components, install_tags) + + files = manifest.file_manifest_list.elements + filtered = [f for f in files if any(tag in install_tags for tag in f.install_tags)] + + calculated_chunks = set() + install_size = 0 + download_size = 0 + for file in filtered: + install_size += file.file_size + for chunk in file.chunk_parts: + if chunk.guid_num in calculated_chunks: + continue + data = manifest.chunk_data_list.get_chunk_by_guid_num(chunk.guid_num) + download_size += data.file_size + calculated_chunks.add(chunk.guid_num) + + if args.json: + print(json.dumps({'download': download_size, 'install': install_size})) + else: + print(f'- Download size: {download_size / 1024 / 1024 / 1024:.02f} GiB') + print(f'- Install size: {install_size / 1024 / 1024 / 1024:.02f} GiB') def main(): # Set output encoding to UTF-8 if not outputting to a terminal @@ -2710,6 +2708,8 @@ def main(): # hidden commands have no help text get_token_parser = subparsers.add_parser('get-token') + install_size_parser = subparsers.add_parser('install-size') + # Positional arguments install_parser.add_argument('app_name', help='Name of the app', metavar='') @@ -2816,8 +2816,6 @@ def main(): help='Reset selective downloading choices (requires repair to download new components)') install_parser.add_argument('--skip-sdl', dest='skip_sdl', action='store_true', help='Skip SDL prompt and continue with defaults (only required game data)') - install_parser.add_argument('--disable-sdl', dest='disable_sdl', action='store_true', - help='Disable selective downloading for title, reset existing configuration (if any)') install_parser.add_argument('--preferred-cdn', dest='preferred_cdn', action='store', metavar='', help='Set the hostname of the preferred CDN to use when available') install_parser.add_argument('--no-https', dest='disable_https', action='store_true', @@ -2829,6 +2827,16 @@ def main(): install_parser.add_argument('--bind', dest='bind_ip', action='store', metavar='', type=str, help='Comma-separated list of IPs to bind to for downloading') + install_size_parser.add_argument('app_name', metavar='') + install_size_parser.add_argument('--install-component', dest='install_component', action='append', metavar='', + type=str, help='Specify what component should be treated as selected') + + install_size_parser.add_argument('--platform', dest='platform', action='store', metavar='', type=str, + help='Platform for install (default: Windows)') + + install_size_parser.add_argument('--json', dest='json', action='store_true', + help='Print information as JSON') + uninstall_parser.add_argument('--keep-files', dest='keep_files', action='store_true', help='Keep files but remove game from Legendary database') uninstall_parser.add_argument('--skip-uninstaller', dest='skip_uninstaller', action='store_true', @@ -3142,6 +3150,8 @@ def main(): cli.crossover_setup(args) elif args.subparser_name == 'move': cli.move(args) + elif args.subparser_name == 'install-size': + cli.get_install_size(args) except KeyboardInterrupt: logger.info('Command was aborted via KeyboardInterrupt, cleaning up...') diff --git a/legendary/utils/cli.py b/legendary/utils/cli.py index 9a421b4..239fa2e 100644 --- a/legendary/utils/cli.py +++ b/legendary/utils/cli.py @@ -55,7 +55,10 @@ def sdl_prompt(sdl_data, title, context): choices = [] required_categories = {} for element in sdl_data['Data']: - if (element.get('IsRequired', 'false').lower() == 'true' and not 'Children' in element) or element.get('Invisible', 'false').lower() == 'true': + is_required = element.get('IsRequired', 'false').lower() == 'true' + has_children = 'Children' in element + is_invisible = element.get('Invisible', 'false').lower() == 'true' + if (is_required and not has_children) or is_invisible: continue if element.get('ConfigHandler'): diff --git a/legendary/utils/selective_dl.py b/legendary/utils/selective_dl.py index b5e273b..ab61789 100644 --- a/legendary/utils/selective_dl.py +++ b/legendary/utils/selective_dl.py @@ -11,7 +11,22 @@ def has_access(context, app): def is_selected(context, input): return input in context.selection -EXTRA_FUNCTIONS = {'HasAccess': has_access, "IsComponentSelected": is_selected} +false_lambda = lambda c,i: False + +EXTRA_FUNCTIONS = { + 'HasAccess': has_access, + "IsComponentSelected": is_selected, + "D3D12FeatureDataOptions1Check": false_lambda, + "D3D12FeatureDataOptions2Check": false_lambda, + "D3D12FeatureDataOptions3Check": false_lambda, + "D3D12FeatureDataOptions4Check": false_lambda, + "D3D12FeatureDataOptions5Check": false_lambda, + "D3D12FeatureDataOptions6Check": false_lambda, + "D3D12FeatureDataOptions7Check": false_lambda, + "D3D12FeatureDataOptions9Check": false_lambda, + "D3D12FeatureDataOptions9Check": false_lambda, + "IsIntelAtomic64EmulationSupported": false_lambda +} class LGDEvaluationContext(EvaluationContext): def __init__(self, core): @@ -45,3 +60,34 @@ def get_sdl_data(location, app_name, app_version): if applying_meta: return applying_meta[-1] return None + +def parse_components_selection(sdl_data, eval_context, install_components, install_tags): + for element in sdl_data['Data']: + if element.get('IsRequired', 'false').lower() == 'true': + install_tags.update(element.get('Tags', [])) + if element['UniqueId'] not in install_components: + install_components.append(element['UniqueId']) + continue + if element.get('Invisible', 'false').lower() == 'true': + tk = Tokenizer(element['InvisibleSelectedExpression'], eval_context) + tk.extend_functions(EXTRA_FUNCTIONS) + tk.compile() + if tk.execute(''): + install_tags.update(element.get('Tags', [])) + if element['UniqueId'] not in install_components: + install_components.append(element['UniqueId']) + + # The ids may change from revision to revision, this property lets us match options against older options + upgrade_id = element.get('UpgradePathLogic') + if upgrade_id and upgrade_id in install_components: + install_tags.update(element.get('Tags', [])) + # Replace component id with upgraded one + install_components = [element['UniqueId'] if el == upgrade_id else el for el in install_components] + + if element['UniqueId'] in install_components: + install_tags.update(element.get('Tags', [])) + + if element.get('ConfigHandler'): + for child in element.get('Children', []): + if child['UniqueId'] in install_components: + install_tags.update(child.get('Tags', []))