feat: expose data in info command and add install-size subcommand

This commit is contained in:
Paweł Lidwin 2025-12-03 13:40:07 +01:00
parent 41aa2b1a66
commit 05be598e0a
No known key found for this signature in database
GPG key ID: C6EDF064F9FEE1E1
4 changed files with 138 additions and 81 deletions

View file

@ -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

View file

@ -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='<App Name>')
@ -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='<hostname>',
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='<IPs>', type=str,
help='Comma-separated list of IPs to bind to for downloading')
install_size_parser.add_argument('app_name', metavar='<App Name>')
install_size_parser.add_argument('--install-component', dest='install_component', action='append', metavar='<id>',
type=str, help='Specify what component should be treated as selected')
install_size_parser.add_argument('--platform', dest='platform', action='store', metavar='<Platform>', 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...')

View file

@ -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'):

View file

@ -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', []))