From e2b9187eddcfbc5b8919db040226f5d914a6b241 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:32:42 +0300 Subject: [PATCH] lgndr: add support for encrypted manifests and chunks (chunksV5) --- misc/requirements.in | 2 +- pyproject.toml | 2 +- rare/components/dialogs/install/dialog.py | 4 - rare/components/tabs/downloads/thread.py | 34 -------- rare/lgndr/cli.py | 60 +++---------- rare/lgndr/downloader/mp/manager.py | 20 +---- rare/lgndr/glue/arguments.py | 1 - rare/models/game.py | 7 +- rare/models/install.py | 1 - .../ui/components/dialogs/install/advanced.py | 68 ++++++--------- .../ui/components/dialogs/install/advanced.ui | 86 +++++++------------ 11 files changed, 83 insertions(+), 202 deletions(-) diff --git a/misc/requirements.in b/misc/requirements.in index 5e300d2999..2049096040 100644 --- a/misc/requirements.in +++ b/misc/requirements.in @@ -4,7 +4,7 @@ setuptools-scm requests < 3.0 PySide6-Essentials >= 6.8.1 QtAwesome -legendary-gl @ https://github.com/RareDevs/legendary/archive/8cedfbb.zip +legendary-gl @ https://github.com/RareDevs/legendary/archive/5b3453c.zip orjson vdf @ https://github.com/solsticegamestudios/vdf/archive/be1f7220238022f8b29fe747f0b643f280bfdb6e.zip pywin32 ; platform_system == "Windows" diff --git a/pyproject.toml b/pyproject.toml index 5b5be586ad..29a0cefd7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "requests < 3.0", "PySide6-Essentials >= 6.8.1", "QtAwesome", - "legendary-gl @ git+https://github.com/RareDevs/legendary@8cedfbb", + "legendary-gl @ git+https://github.com/RareDevs/legendary@5b3453c", "orjson", "vdf @ https://github.com/solsticegamestudios/vdf/archive/be1f7220238022f8b29fe747f0b643f280bfdb6e.zip", "pywin32 ; platform_system == 'Windows'", diff --git a/rare/components/dialogs/install/dialog.py b/rare/components/dialogs/install/dialog.py index d317877264..7e69367f29 100644 --- a/rare/components/dialogs/install/dialog.py +++ b/rare/components/dialogs/install/dialog.py @@ -131,9 +131,6 @@ def __init__(self, settings: RareAppSettings, rgame: "RareGame", options: Instal self.advanced.ui.read_files_check.setChecked(options.read_files) self.advanced.ui.read_files_check.checkStateChanged.connect(self._on_option_changed) - self.advanced.ui.use_signed_urls_check.setChecked(options.always_use_signed_urls) - self.advanced.ui.use_signed_urls_check.checkStateChanged.connect(self._on_option_changed) - self.advanced.ui.dl_optimizations_check.setChecked(options.order_opt) self.advanced.ui.dl_optimizations_check.checkStateChanged.connect(self._on_option_changed) @@ -227,7 +224,6 @@ def get_options(self): self._options.max_workers = self.advanced.ui.max_workers_spin.value() self._options.shared_memory = self.advanced.ui.max_memory_spin.value() self._options.read_files = self.advanced.ui.read_files_check.isChecked() - self._options.always_use_signed_urls = self.advanced.ui.use_signed_urls_check.isChecked() self._options.order_opt = self.advanced.ui.dl_optimizations_check.isChecked() self._options.force = self.advanced.ui.force_download_check.isChecked() self._options.ignore_space = self.advanced.ui.ignore_space_check.isChecked() diff --git a/rare/components/tabs/downloads/thread.py b/rare/components/tabs/downloads/thread.py index c2d928ed88..85353de2c3 100644 --- a/rare/components/tabs/downloads/thread.py +++ b/rare/components/tabs/downloads/thread.py @@ -75,41 +75,11 @@ def run(self): result = DlResultModel(self.item.options) result.app_title = self.rgame.app_title - ticket_a, ticket_b = multiprocessing.Pipe() - sign_a, sign_b = multiprocessing.Pipe() - - def ticket_creator_thread(): - t = threading.current_thread() - while not getattr(t, "stop", False): - if ticket_b.poll(1): - catalog_item_id, build_version, app_name, namespace, label, platform = ticket_b.recv() - ticket_b.send( - self.core.egs.get_download_ticket(catalog_item_id, build_version, app_name, namespace, label, platform) - ) - - def chunk_url_sign_thread(): - t = threading.current_thread() - while not getattr(t, "stop", False): - if sign_b.poll(1): - ticket, chunk_paths = sign_b.recv() - signed_chunk_urls = self.core.egs.get_signed_chunk_urls(ticket, chunk_paths) - if self.item.options.disable_https: - for key in signed_chunk_urls: - signed_chunk_urls[key] = signed_chunk_urls[key].replace("https://", "http://") - sign_b.send(signed_chunk_urls) - - ticket_thread = threading.Thread(target=ticket_creator_thread) - sign_thread = threading.Thread(target=chunk_url_sign_thread) - start_t = time.time() try: self.item.download.dlm.logging_queue = cli.logging_queue self.item.download.dlm.proc_debug = self.debug - self.item.download.dlm.ticket_pipe = ticket_a - self.item.download.dlm.sign_pipe = sign_a - ticket_thread.start() - sign_thread.start() self.item.download.dlm.start() self.rgame.state = RareGame.State.DOWNLOADING self.rgame.signals.progress.start.emit() @@ -192,10 +162,6 @@ def chunk_url_sign_thread(): return finally: - ticket_thread.stop = True - sign_thread.stop = True - ticket_thread.join() - sign_thread.join() self._finish(result) def _handle_postinstall(self, postinstall, igame): diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 7a6c2c2d5a..e58bbbec40 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,10 +1,8 @@ import functools import logging -import multiprocessing import os import queue import subprocess -import threading import time from typing import Optional, Union, Tuple @@ -214,8 +212,7 @@ def install_game(self, args: LgndrInstallGameArgs) -> Optional[Tuple[DLManager, override_delta_manifest=args.override_delta_manifest, preferred_cdn=args.preferred_cdn, disable_https=args.disable_https, - bind_ip=args.bind_ip, - always_use_signed_urls=args.always_use_signed_urls) + bind_ip=args.bind_ip) # game is either up-to-date or hasn't changed, so we have nothing to do if not analysis.dl_size and not game.is_dlc: @@ -245,43 +242,13 @@ def install_game_real(self, args: LgndrInstallGameRealArgs, dlm: DLManager, game 'install/import/move applications at a time.') return ret - ticket_a, ticket_b = multiprocessing.Pipe() - sign_a, sign_b = multiprocessing.Pipe() - - def ticket_creator_thread(): - t = threading.current_thread() - while not getattr(t, 'stop', False): - if ticket_b.poll(1): - catalog_item_id, build_version, app_name, namespace, label, platform = ticket_b.recv() - ticket_b.send(self.core.egs.get_download_ticket(catalog_item_id, build_version, app_name, - namespace, label, platform)) - - def chunk_url_sign_thread(): - t = threading.current_thread() - while not getattr(t, 'stop', False): - if sign_b.poll(1): - ticket, chunk_paths = sign_b.recv() - signed_chunk_urls = self.core.egs.get_signed_chunk_urls(ticket, chunk_paths) - if args.disable_https: - for key in signed_chunk_urls: - signed_chunk_urls[key] = signed_chunk_urls[key].replace('https://', 'http://') - sign_b.send(signed_chunk_urls) - - - ticket_thread = threading.Thread(target=ticket_creator_thread) - sign_thread = threading.Thread(target=chunk_url_sign_thread) - start_t = time.time() try: # set up logging stuff (should be moved somewhere else later) dlm.logging_queue = self.logging_queue dlm.proc_debug = args.dlm_debug - dlm.ticket_pipe = ticket_a - dlm.sign_pipe = sign_a - ticket_thread.start() - sign_thread.start() dlm.start() while dlm.is_alive(): try: @@ -350,12 +317,6 @@ def chunk_url_sign_thread(): logger.info(f'Finished installation process in {end_t - start_t:.02f} seconds.') return ret - finally: - ticket_thread.stop = True - sign_thread.stop = True - ticket_thread.join() - sign_thread.join() - @unlock_installed.__func__ def install_game_cleanup(self, game: Game, igame: InstalledGame, repair_mode: bool = False, repair_file: str = '') -> None: @@ -499,7 +460,7 @@ def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], pr args.app_name = self._resolve_aliases(args.app_name) if not self.core.is_installed(args.app_name): logger.error(f'Game "{args.app_name}" is not installed') - return + return None logger.info(f'Loading installed manifest for "{args.app_name}"') igame = self.core.get_installed_game(args.app_name) @@ -507,9 +468,11 @@ def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], pr logger.error(f'Install path "{igame.install_path}" does not exist, make sure all necessary mounts ' f'are available. If you previously deleted the game folder without uninstalling, run ' f'"legendary uninstall -y {igame.app_name}" and reinstall from scratch.') - return + return None manifest_data, _ = self.core.get_installed_manifest(args.app_name) + manifest_secrets = dict() + if manifest_data is None: if repair_mode: if not repair_online: @@ -518,18 +481,23 @@ def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], pr logger.warning('No manifest could be loaded, the file may be missing. Downloading the latest manifest.') game = self.core.get_game(args.app_name, platform=igame.platform) - manifest_data, _, _ = self.core.get_cdn_manifest(game, igame.platform) + manifest_data, _, _, manifest_secrets = self.core.get_cdn_manifest(game, igame.platform) # Rare: Save the manifest if we downloaded it because it was missing - self.core.lgd.save_manifest(game.app_name, manifest_data, - version=self.core.load_manifest(manifest_data).meta.build_version, + manifest = self.core.load_manifest(manifest_data) + manifest.decrypt(manifest_secrets) + self.core.lgd.save_manifest(game.app_name, manifest, + version=manifest.meta.build_version, platform=igame.platform) else: logger.critical(f'Manifest appears to be missing! To repair, run "legendary repair ' f'{args.app_name} --repair-and-update", this will however redownload all files ' f'that do not match the latest manifest in their entirety.') - return + return None manifest = self.core.load_manifest(manifest_data) + if not manifest.decrypt(manifest_secrets): + logger.critical('Unable to decrypt the manifest. The key appears to be missing. Please report this on GitHub.') + return None files = sorted(manifest.file_manifest_list.elements, key=lambda a: a.filename.lower()) diff --git a/rare/lgndr/downloader/mp/manager.py b/rare/lgndr/downloader/mp/manager.py index b516629a41..cbf09c3735 100644 --- a/rare/lgndr/downloader/mp/manager.py +++ b/rare/lgndr/downloader/mp/manager.py @@ -43,7 +43,6 @@ def run_real(self): self.writer_queue = MPQueue(-1) self.dl_result_q = MPQueue(-1) self.writer_result_q = MPQueue(-1) - self.signed_chunks_q = MPQueue(-1) self.log.info(f'Starting download workers...') @@ -54,7 +53,7 @@ def run_real(self): w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, self.dl_result_q, self.shared_memory.name, logging_queue=self.logging_queue, - dl_timeout=self.dl_timeout, bind_addr=bind_ip) + dl_timeout=self.dl_timeout, bind_addr=bind_ip, secrets=self.manifest_secrets) self.children.append(w) w.start() @@ -80,13 +79,11 @@ def run_real(self): # synchronization conditions shm_cond = Condition() task_cond = Condition() - sig_chunks_cond = Condition() - self.conditions = [shm_cond, task_cond, sig_chunks_cond] + self.conditions = [shm_cond, task_cond] # start threads s_time = time.perf_counter() - self.threads.append(Thread(target=self.chunk_signing_manager, args=(sig_chunks_cond,))) - self.threads.append(Thread(target=self.download_job_manager, args=(task_cond, shm_cond, sig_chunks_cond))) + self.threads.append(Thread(target=self.download_job_manager, args=(task_cond, shm_cond))) self.threads.append(Thread(target=self.dl_results_handler, args=(task_cond,))) self.threads.append(Thread(target=self.fw_results_handler, args=(shm_cond,))) @@ -179,9 +176,8 @@ def run_real(self): # Rare: queue of control signals try: signals: DLManagerSignals = self.signals_queue.get(timeout=0.5) - self.log.warning('Immediate stop requested!') if signals.kill: - # lk: graceful but not what legendary does + self.log.warning('Immediate stop requested!') self.running = False # send conditions to unlock threads if they aren't already for cond in self.conditions: @@ -206,7 +202,6 @@ def run_real(self): self.log.info('Waiting for installation to finish...') self.writer_queue.put_nowait(TerminateWorkerTask()) - self.signed_chunks_q.put_nowait((TerminateWorkerTask(), None)) writer_p.join(timeout=10.0) if writer_p.exitcode is None: @@ -231,7 +226,6 @@ def run_real(self): ('Writer jobs', self.writer_queue), ('Download results', self.dl_result_q), ('Writer results', self.writer_result_q), - ('Signed chunks', self.signed_chunks_q) ] for name, q in queues: self.log.debug(f'Cleaning up queue "{name}"') @@ -242,12 +236,6 @@ def run_real(self): q.close() q.join_thread() - # clean up connections - pipes = [self.sign_pipe, self.ticket_pipe] - for pipe in pipes: - if pipe is not None: - pipe.close() - # clean up resume file if self.resume_file and not kill_request: try: diff --git a/rare/lgndr/glue/arguments.py b/rare/lgndr/glue/arguments.py index c1e3946c47..c75e7aae1d 100644 --- a/rare/lgndr/glue/arguments.py +++ b/rare/lgndr/glue/arguments.py @@ -92,7 +92,6 @@ class LgndrInstallGameArgs: skip_sdl: bool = False disable_https: bool = False bind_ip: str = "" - always_use_signed_urls: bool = False # FIXME: move to LgndrInstallGameRealArgs skip_dlcs: bool = False with_dlcs: bool = False diff --git a/rare/models/game.py b/rare/models/game.py index 10d1d15b8e..837c38bb4f 100644 --- a/rare/models/game.py +++ b/rare/models/game.py @@ -537,9 +537,12 @@ def sdl_data(self, platform: str) -> Optional[Dict[str, Dict]]: if self.igame is not None and not self.has_update: manifest_data = self.core.lgd.load_manifest(self.app_name, self.igame.version, self.igame.platform) + manifest = self.core.load_manifest(manifest_data) else: - manifest_data, _, _ = self.core.get_cdn_manifest(self.game, platform) - manifest = self.core.load_manifest(manifest_data) + manifest_data, _, _, manifest_secrets = self.core.get_cdn_manifest(self.game, platform=platform) + manifest = self.core.load_manifest(manifest_data) + manifest.decrypt(manifest_secrets) + manifest_install_tags = set() for fm in manifest.file_manifest_list.elements: for tag in fm.install_tags: diff --git a/rare/models/install.py b/rare/models/install.py index 777a53aee9..cc5d93f89f 100644 --- a/rare/models/install.py +++ b/rare/models/install.py @@ -27,7 +27,6 @@ class InstallOptionsModel: ignore_space: bool = False reset_sdl: bool = False disable_https: bool = False - always_use_signed_urls: bool = True skip_dlcs: bool = False with_dlcs: bool = False # Rare's internal arguments diff --git a/rare/ui/components/dialogs/install/advanced.py b/rare/ui/components/dialogs/install/advanced.py index 6e4df106ad..c3e82051fe 100644 --- a/rare/ui/components/dialogs/install/advanced.py +++ b/rare/ui/components/dialogs/install/advanced.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'advanced.ui' ## -## Created by: Qt User Interface Compiler version 6.9.1 +## Created by: Qt User Interface Compiler version 6.10.1 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -17,7 +17,7 @@ class Ui_InstallDialogAdvanced(object): def setupUi(self, InstallDialogAdvanced): if not InstallDialogAdvanced.objectName(): InstallDialogAdvanced.setObjectName(u"InstallDialogAdvanced") - InstallDialogAdvanced.resize(409, 228) + InstallDialogAdvanced.resize(393, 209) InstallDialogAdvanced.setWindowTitle(u"InstallDialogAdvanced") self.main_layout = QFormLayout(InstallDialogAdvanced) self.main_layout.setObjectName(u"main_layout") @@ -91,72 +91,61 @@ def setupUi(self, InstallDialogAdvanced): self.main_layout.setWidget(2, QFormLayout.ItemRole.FieldRole, self.install_prereqs_check) + self.read_files_label = QLabel(InstallDialogAdvanced) + self.read_files_label.setObjectName(u"read_files_label") + + self.main_layout.setWidget(3, QFormLayout.ItemRole.LabelRole, self.read_files_label) + + self.read_files_check = QCheckBox(InstallDialogAdvanced) + self.read_files_check.setObjectName(u"read_files_check") + self.read_files_check.setFont(font) + + self.main_layout.setWidget(3, QFormLayout.ItemRole.FieldRole, self.read_files_check) + self.dl_optimizations_label = QLabel(InstallDialogAdvanced) self.dl_optimizations_label.setObjectName(u"dl_optimizations_label") - self.main_layout.setWidget(5, QFormLayout.ItemRole.LabelRole, self.dl_optimizations_label) + self.main_layout.setWidget(4, QFormLayout.ItemRole.LabelRole, self.dl_optimizations_label) self.dl_optimizations_check = QCheckBox(InstallDialogAdvanced) self.dl_optimizations_check.setObjectName(u"dl_optimizations_check") self.dl_optimizations_check.setFont(font) self.dl_optimizations_check.setChecked(False) - self.main_layout.setWidget(5, QFormLayout.ItemRole.FieldRole, self.dl_optimizations_check) + self.main_layout.setWidget(4, QFormLayout.ItemRole.FieldRole, self.dl_optimizations_check) self.force_download_label = QLabel(InstallDialogAdvanced) self.force_download_label.setObjectName(u"force_download_label") - self.main_layout.setWidget(6, QFormLayout.ItemRole.LabelRole, self.force_download_label) + self.main_layout.setWidget(5, QFormLayout.ItemRole.LabelRole, self.force_download_label) self.force_download_check = QCheckBox(InstallDialogAdvanced) self.force_download_check.setObjectName(u"force_download_check") self.force_download_check.setFont(font) - self.main_layout.setWidget(6, QFormLayout.ItemRole.FieldRole, self.force_download_check) + self.main_layout.setWidget(5, QFormLayout.ItemRole.FieldRole, self.force_download_check) self.ignore_space_label = QLabel(InstallDialogAdvanced) self.ignore_space_label.setObjectName(u"ignore_space_label") - self.main_layout.setWidget(7, QFormLayout.ItemRole.LabelRole, self.ignore_space_label) + self.main_layout.setWidget(6, QFormLayout.ItemRole.LabelRole, self.ignore_space_label) self.ignore_space_check = QCheckBox(InstallDialogAdvanced) self.ignore_space_check.setObjectName(u"ignore_space_check") self.ignore_space_check.setFont(font) - self.main_layout.setWidget(7, QFormLayout.ItemRole.FieldRole, self.ignore_space_check) + self.main_layout.setWidget(6, QFormLayout.ItemRole.FieldRole, self.ignore_space_check) self.download_only_label = QLabel(InstallDialogAdvanced) self.download_only_label.setObjectName(u"download_only_label") - self.main_layout.setWidget(8, QFormLayout.ItemRole.LabelRole, self.download_only_label) + self.main_layout.setWidget(7, QFormLayout.ItemRole.LabelRole, self.download_only_label) self.download_only_check = QCheckBox(InstallDialogAdvanced) self.download_only_check.setObjectName(u"download_only_check") self.download_only_check.setFont(font) - self.main_layout.setWidget(8, QFormLayout.ItemRole.FieldRole, self.download_only_check) - - self.read_files_label = QLabel(InstallDialogAdvanced) - self.read_files_label.setObjectName(u"read_files_label") - - self.main_layout.setWidget(3, QFormLayout.ItemRole.LabelRole, self.read_files_label) - - self.read_files_check = QCheckBox(InstallDialogAdvanced) - self.read_files_check.setObjectName(u"read_files_check") - self.read_files_check.setFont(font) - - self.main_layout.setWidget(3, QFormLayout.ItemRole.FieldRole, self.read_files_check) - - self.use_signed_urls_label = QLabel(InstallDialogAdvanced) - self.use_signed_urls_label.setObjectName(u"use_signed_urls_label") - - self.main_layout.setWidget(4, QFormLayout.ItemRole.LabelRole, self.use_signed_urls_label) - - self.use_signed_urls_check = QCheckBox(InstallDialogAdvanced) - self.use_signed_urls_check.setObjectName(u"use_signed_urls_check") - self.use_signed_urls_check.setFont(font) - - self.main_layout.setWidget(4, QFormLayout.ItemRole.FieldRole, self.use_signed_urls_check) + self.main_layout.setWidget(7, QFormLayout.ItemRole.FieldRole, self.download_only_check) self.retranslateUi(InstallDialogAdvanced) @@ -179,6 +168,11 @@ def retranslateUi(self, InstallDialogAdvanced): self.install_prereqs_check.setToolTip("") #endif // QT_CONFIG(tooltip) self.install_prereqs_check.setText("") + self.read_files_label.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Read from files", None)) +#if QT_CONFIG(tooltip) + self.read_files_check.setToolTip(QCoreApplication.translate("InstallDialogAdvanced", u"Read duplicated parts from already saved files, do not keep them in memory.", None)) +#endif // QT_CONFIG(tooltip) + self.read_files_check.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Increases storage I/O, reduces RAM usage.", None)) self.dl_optimizations_label.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Enable reordering", None)) #if QT_CONFIG(tooltip) self.dl_optimizations_check.setToolTip(QCoreApplication.translate("InstallDialogAdvanced", u"Enable reordering optimization to reduce RAM requirements during download (may have adverse results for some titles).", None)) @@ -199,16 +193,6 @@ def retranslateUi(self, InstallDialogAdvanced): self.download_only_check.setToolTip(QCoreApplication.translate("InstallDialogAdvanced", u"Do not install app and do not run prerequisite installers after download.", None)) #endif // QT_CONFIG(tooltip) self.download_only_check.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Do not try to install.", None)) - self.read_files_label.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Read from files", None)) -#if QT_CONFIG(tooltip) - self.read_files_check.setToolTip(QCoreApplication.translate("InstallDialogAdvanced", u"Read duplicated parts from already saved files, do not keep them in memory.", None)) -#endif // QT_CONFIG(tooltip) - self.read_files_check.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Increases storage I/O, reduces RAM usage.", None)) - self.use_signed_urls_label.setText(QCoreApplication.translate("InstallDialogAdvanced", u"Use signed URLs", None)) -#if QT_CONFIG(tooltip) - self.use_signed_urls_check.setToolTip(QCoreApplication.translate("InstallDialogAdvanced", u"Always use signed chunk URLs, even if the Epic API indicates not to.", None)) -#endif // QT_CONFIG(tooltip) - self.use_signed_urls_check.setText("") pass # retranslateUi diff --git a/rare/ui/components/dialogs/install/advanced.ui b/rare/ui/components/dialogs/install/advanced.ui index 5679cfeb35..c5c0e36823 100644 --- a/rare/ui/components/dialogs/install/advanced.ui +++ b/rare/ui/components/dialogs/install/advanced.ui @@ -6,8 +6,8 @@ 0 0 - 409 - 228 + 393 + 209 @@ -135,14 +135,36 @@ - + + + + Read from files + + + + + + + + true + + + + Read duplicated parts from already saved files, do not keep them in memory. + + + Increases storage I/O, reduces RAM usage. + + + + Enable reordering - + @@ -160,14 +182,14 @@ - + Force redownload - + @@ -182,14 +204,14 @@ - + Ignore free space - + @@ -204,14 +226,14 @@ - + Download only - + @@ -226,50 +248,6 @@ - - - - Read from files - - - - - - - - true - - - - Read duplicated parts from already saved files, do not keep them in memory. - - - Increases storage I/O, reduces RAM usage. - - - - - - - Use signed URLs - - - - - - - - true - - - - Always use signed chunk URLs, even if the Epic API indicates not to. - - - - - -