diff --git a/src/Ryujinx.Ava/Assets/Locales/en_US.json b/src/Ryujinx.Ava/Assets/Locales/en_US.json index 6ac9ae948..7648edfc4 100644 --- a/src/Ryujinx.Ava/Assets/Locales/en_US.json +++ b/src/Ryujinx.Ava/Assets/Locales/en_US.json @@ -73,7 +73,10 @@ "GameListContextMenuCreateShortcut": "Create Application Shortcut", "GameListContextMenuCreateShortcutToolTip": "Create a Desktop Shortcut that launches the selected Application", "GameListContextMenuCreateShortcutToolTipMacOS": "Create a shortcut in macOS's Applications folder that launches the selected Application", + "GameListContextMenuTrimXCI": "Check and Trim XCI File", + "GameListContextMenuTrimXCIToolTip": "Check and Trim XCI File to Save Disk Space", "StatusBarGamesLoaded": "{0}/{1} Games Loaded", + "StatusBarXCIFileTrimming": "Trimming XCI File '{0}'", "StatusBarSystemVersion": "System Version: {0}", "LinuxVmMaxMapCountDialogTitle": "Low limit for memory mappings detected", "LinuxVmMaxMapCountDialogTextPrimary": "Would you like to increase the value of vm.max_map_count to {0}", @@ -592,6 +595,16 @@ "SelectDlcDialogTitle": "Select DLC files", "SelectUpdateDialogTitle": "Select update files", "SelectModDialogTitle": "Select mod directory", + "TrimXCIFileDialogTitle": "Check and Trim XCI File", + "TrimXCIFileDialogPrimaryText": "This function will first check the empty space and then trim the XCI File to save disk space.", + "TrimXCIFileDialogSecondaryText": "Current File Size: {0:n} MB\nGame Data Size: {1:n} MB\nDisk Space Savings: {2:n} MB", + "TrimXCIFileNoTrimNecessary": "XCI File does not need to be trimmed. Check logs for further details", + "TrimXCIFileReadOnlyFileCannotFix": "XCI File is Read Only and could not be made writable. Check logs for further details", + "TrimXCIFileFileSizeChanged": "XCI File has changed in size since it was scanned. Please check the file is not being written to and try again.", + "TrimXCIFileFreeSpaceCheckFailed": "XCI File has data in the free space area, it is not safe to trim", + "TrimXCIFileInvalidXCIFile": "XCI File contains invalid data. Check logs for further details", + "TrimXCIFileFileIOWriteError": "XCI File could not be opened for writing. Check logs for further details", + "TrimXCIFileFailedPrimaryText": "Trimming of the XCI file failed", "UserProfileWindowTitle": "User Profiles Manager", "CheatWindowTitle": "Cheats Manager", "DlcWindowTitle": "Manage Downloadable Content for {0} ({1})", @@ -601,6 +614,7 @@ "DlcWindowHeading": "{0} Downloadable Content(s)", "ModWindowHeading": "{0} Mod(s)", "UserProfilesEditProfile": "Edit Selected", + "Continue": "Continue", "Cancel": "Cancel", "Save": "Save", "Discard": "Discard", diff --git a/src/Ryujinx.Ava/Common/XCIFileTrimmerLog.cs b/src/Ryujinx.Ava/Common/XCIFileTrimmerLog.cs new file mode 100644 index 000000000..339b89c2c --- /dev/null +++ b/src/Ryujinx.Ava/Common/XCIFileTrimmerLog.cs @@ -0,0 +1,24 @@ +using Ryujinx.Ava.UI.ViewModels; + +namespace Ryujinx.Ava.Common +{ + class XCIFileTrimmerLog : Ryujinx.Common.Logging.XCIFileTrimmerLog + { + private readonly MainWindowViewModel _viewModel; + + public XCIFileTrimmerLog(MainWindowViewModel viewModel) + { + _viewModel = viewModel; + } + + public override void Progress(long current, long total, string text, bool complete) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _viewModel.StatusBarProgressMaximum = (int)(total); + _viewModel.StatusBarProgressValue = (int)(current); + }); + } + } + +} diff --git a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml index 5bdeb8ad3..632ca6c24 100644 --- a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml +++ b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml @@ -17,6 +17,11 @@ Header="{locale:Locale GameListContextMenuCreateShortcut}" IsEnabled="{Binding CreateShortcutEnabled}" ToolTip.Tip="{OnPlatform Default={locale:Locale GameListContextMenuCreateShortcutToolTip}, macOS={locale:Locale GameListContextMenuCreateShortcutToolTipMacOS}}" /> + !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0; + public bool TrimXCIEnabled => Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(SelectedApplication.Path, new Common.XCIFileTrimmerLog(this)); + public bool CreateShortcutEnabled => !ReleaseInformation.IsFlatHubBuild; public string LoadHeading @@ -469,6 +473,28 @@ namespace Ryujinx.Ava.UI.ViewModels } } + public bool StatusBarProgressStatusVisible + { + get => _statusBarProgressStatusVisible; + set + { + _statusBarProgressStatusVisible = value; + + OnPropertyChanged(); + } + } + + public string StatusBarProgressStatusText + { + get => _statusBarProgressStatusText; + set + { + _statusBarProgressStatusText = value; + + OnPropertyChanged(); + } + } + public string FifoStatusText { get => _fifoStatusText; @@ -1708,6 +1734,107 @@ namespace Ryujinx.Ava.UI.ViewModels } } } + + public async void ProcessTrimResult(String filename, Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome operationOutcome) + { + string notifyUser = null; + + switch (operationOutcome) + { + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.NoTrimNecessary: + notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileNoTrimNecessary]; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.ReadOnlyFileCannotFix: + notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileReadOnlyFileCannotFix]; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FreeSpaceCheckFailed: + notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileFreeSpaceCheckFailed]; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.InvalidXCIFile: + notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileInvalidXCIFile]; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FileIOWriteError: + notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileFileIOWriteError]; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FileSizeChanged: + notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileFileSizeChanged]; + break; + } + + if (notifyUser != null) + { + await ContentDialogHelper.CreateWarningDialog( + LocaleManager.Instance[LocaleKeys.TrimXCIFileFailedPrimaryText], + notifyUser + ); + } + } + + public async Task TrimXCIFile(string filename) + { + if (filename == null) + { + return; + } + + var trimmer = new Ryujinx.Common.Utilities.XCIFileTrimmer(filename, new Common.XCIFileTrimmerLog(this)); + + if (trimmer.CanBeTrimmed) + { + var savings = (double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0; + var currentFileSize = (double)trimmer.FileSizeB / 1024.0 / 1024.0; + var cartDataSize = (double)trimmer.DataSizeB / 1024.0 / 1024.0; + var secondaryText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TrimXCIFileDialogSecondaryText, currentFileSize, cartDataSize, savings); + + var result = await ContentDialogHelper.CreateConfirmationDialog( + LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogPrimaryText], + secondaryText, + LocaleManager.Instance[LocaleKeys.Continue], + LocaleManager.Instance[LocaleKeys.Cancel], + LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogTitle] + ); + + if (result == UserResult.Yes) + { + Thread XCIFileTrimThread = new(() => + { + Dispatcher.UIThread.Post(() => + { + StatusBarProgressStatusText = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarXCIFileTrimming, Path.GetFileName(filename)); + StatusBarProgressStatusVisible = true; + StatusBarProgressMaximum = 1; + StatusBarProgressValue = 0; + StatusBarVisible = true; + }); + + try + { + var operationOutcome = trimmer.Trim(); + + Dispatcher.UIThread.Post(() => + { + ProcessTrimResult(filename, operationOutcome); + }); + } + finally + { + Dispatcher.UIThread.Post(() => + { + StatusBarProgressStatusVisible = false; + StatusBarProgressStatusText = string.Empty; + StatusBarVisible = false; + }); + } + }) + { + Name = "GUI.XCFileTrimmerThread", + IsBackground = true, + }; + XCIFileTrimThread.Start(); + } + } + } + #endregion } } diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml b/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml index f9e192e62..373d19414 100644 --- a/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml +++ b/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml @@ -36,6 +36,7 @@ IsVisible="{Binding EnableNonGameRunningControls}"> + @@ -60,9 +61,16 @@ VerticalAlignment="Center" IsVisible="{Binding EnableNonGameRunningControls}" Text="{locale:Locale StatusBarGamesLoaded}" /> + _cartSizesGB = new() + { + { 0xFA, 1 }, + { 0xF8, 2 }, + { 0xF0, 4 }, + { 0xE0, 8 }, + { 0xE1, 16 }, + { 0xE2, 32 } + }; + + private static long RecordsToByte(long records) + { + return 512 + (records * 512); + } + + public static bool CanTrim(string filename, ILog log = null) + { + if (System.IO.Path.GetExtension(filename).ToUpperInvariant() == ".XCI") + { + var trimmer = new XCIFileTrimmer(filename, log); + return trimmer.CanBeTrimmed; + } + + return false; + } + + private ILog _log; + private string _filename; + private FileStream _fileStream; + private BinaryReader _binaryReader; + private long _offsetB, _dataSizeB, _cartSizeB, _fileSizeB; + private bool _fileOK = true; + private bool _freeSpaceChecked = false; + private bool _freeSpaceValid = false; + + public enum OperationOutcome + { + InvalidXCIFile, + NoTrimNecessary, + NoUntrimPossible, + FreeSpaceCheckFailed, + FileIOWriteError, + ReadOnlyFileCannotFix, + FileSizeChanged, + Successful + } + + public enum LogType + { + Info, + Warn, + Error, + Progress + } + + public interface ILog + { + public void Write(LogType logType, string text); + public void Progress(long current, long total, string text, bool complete); + } + + public bool FileOK => _fileOK; + public bool Trimmed => _fileOK && FileSizeB < UntrimmedFileSizeB; + public bool ContainsKeyArea => _offsetB != 0; + public bool CanBeTrimmed => _fileOK && FileSizeB > TrimmedFileSizeB; + public bool CanBeUntrimmed => _fileOK && FileSizeB < UntrimmedFileSizeB; + public bool FreeSpaceChecked => _fileOK && _freeSpaceChecked; + public bool FreeSpaceValid => _fileOK && _freeSpaceValid; + public long DataSizeB => _dataSizeB; + public long CartSizeB => _cartSizeB; + public long FileSizeB => _fileSizeB; + public long DiskSpaceSavedB => CartSizeB - FileSizeB; + public long DiskSpaceSavingsB => CartSizeB - DataSizeB; + public long TrimmedFileSizeB => _offsetB + _dataSizeB; + public long UntrimmedFileSizeB => _offsetB + _cartSizeB; + + public ILog Log + { + get => _log; + set => _log = value; + } + + public String Filename + { + get => this._filename; + set + { + this._filename = value; + Reset(); + } + } + + public long Pos + { + get => this._fileStream.Position; + set => this._fileStream.Position = value; + } + + public XCIFileTrimmer(string path, ILog log = null) + { + this.Log = log; + this.Filename = path; + ReadHeader(); + } + + public void CheckFreeSpace() + { + if (this.FreeSpaceChecked) + return; + + try + { + if (this.CanBeTrimmed) + { + this._freeSpaceValid = false; + + OpenReaders(); + + try + { + this.Pos = this.TrimmedFileSizeB; + var buffer = new byte[BufferSize]; + var readSizeB = this.FileSizeB - this.TrimmedFileSizeB; + var reads = readSizeB / XCIFileTrimmer.BufferSize; + long read = 0; + + var time = Performance.Measure(() => + { + try + { + while (true) + { + var bytes = _fileStream.Read(buffer, 0, XCIFileTrimmer.BufferSize); + if (bytes == 0) + break; + + if (buffer.Take(bytes).AsParallel().Any(b => b != XCIFileTrimmer.PaddingByte)) + { + Log?.Write(LogType.Warn, "Free space is NOT valid"); + return; + } + Log?.Progress(read, reads, "Verifying file can be trimmed", false); + read++; + } + } + finally + { + Log?.Progress(reads, reads, "Verifying file can be trimmed", true); + } + }); + + if (time.TotalSeconds > 0) + { + Log?.Write(LogType.Info, $"Checked at {readSizeB / (double)XCIFileTrimmer.BytesInAMegabyte / time.TotalSeconds:N} Mb/sec"); + } + + Log?.Write(LogType.Info, "Free space is valid"); + this._freeSpaceValid = true; + } + finally + { + CloseReaders(); + } + + } + else + { + Log?.Write(LogType.Warn, "There is no free space to check."); + this._freeSpaceValid = false; + } + } + finally + { + this._freeSpaceChecked = true; + } + } + + protected void Reset() + { + this._freeSpaceChecked = false; + this._freeSpaceValid = false; + ReadHeader(); + } + + public OperationOutcome Trim() + { + if (!this.FileOK) + { + return OperationOutcome.InvalidXCIFile; + } + + if (!this.CanBeTrimmed) + { + return OperationOutcome.NoTrimNecessary; + } + + if (!this.FreeSpaceChecked) + { + CheckFreeSpace(); + } + + if (!this.FreeSpaceValid) + { + return OperationOutcome.FreeSpaceCheckFailed; + } + + Log?.Write(LogType.Info, "Trimming..."); + + try + { + var info = new FileInfo(this.Filename); + if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + { + try + { + Log?.Write(LogType.Info, "Attempting to remove ReadOnly attribute"); + File.SetAttributes(this.Filename, info.Attributes & ~FileAttributes.ReadOnly); + } + catch (Exception e) + { + Log?.Write(LogType.Error, e.ToString()); + return OperationOutcome.ReadOnlyFileCannotFix; + } + } + + if (info.Length != this.FileSizeB) + { + Log?.Write(LogType.Error, "File size has changed, cannot safely trim."); + return OperationOutcome.FileSizeChanged; + } + + var outfileStream = new FileStream(_filename, FileMode.Open, FileAccess.Write, FileShare.Write); + + try + { + outfileStream.SetLength(this.TrimmedFileSizeB); + return OperationOutcome.Successful; + } + finally + { + outfileStream.Close(); + Reset(); + } + } + catch (Exception e) + { + Log?.Write(LogType.Error, e.ToString()); + return OperationOutcome.FileIOWriteError; + } + } + + public OperationOutcome Untrim() + { + if (!this.FileOK) + { + return OperationOutcome.InvalidXCIFile; + } + + if (!this.CanBeUntrimmed) + { + return OperationOutcome.NoUntrimPossible; + } + + try + { + Log?.Write(LogType.Info, "Untrimming..."); + + var info = new FileInfo(this.Filename); + if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + { + try + { + Log?.Write(LogType.Info, "Attempting to remove ReadOnly attribute"); + File.SetAttributes(this.Filename, info.Attributes & ~FileAttributes.ReadOnly); + } + catch (Exception e) + { + Log?.Write(LogType.Error, e.ToString()); + return OperationOutcome.ReadOnlyFileCannotFix; + } + } + + if (info.Length != this.FileSizeB) + { + Log?.Write(LogType.Error, "File size has changed, cannot safely untrim."); + return OperationOutcome.FileSizeChanged; + } + + var outfileStream = new FileStream(this._filename, FileMode.Append, FileAccess.Write, FileShare.Write); + var buffer = new byte[BufferSize]; + Array.Fill(buffer, XCIFileTrimmer.PaddingByte); + var bytesToWriteB = this.UntrimmedFileSizeB - this.FileSizeB; + var bytesLeftToWriteB = bytesToWriteB; + var writes = bytesLeftToWriteB / XCIFileTrimmer.BufferSize; + var write = 0; + + try + { + var time = Performance.Measure(() => + { + try + { + while (bytesLeftToWriteB > 0) + { + var bytesToWrite = Math.Min(XCIFileTrimmer.BufferSize, bytesLeftToWriteB); + outfileStream.Write(buffer, 0, (int)bytesToWrite); + bytesLeftToWriteB -= bytesToWrite; + Log?.Progress(write, writes, "Writing padding data...", false); + write++; + } + } + finally + { + Log?.Progress(write, writes, "Writing padding data...", true); + } + }); + + if (time.TotalSeconds > 0) + { + Log?.Write(LogType.Info, $"Wrote at {bytesToWriteB / (double)XCIFileTrimmer.BytesInAMegabyte / time.TotalSeconds:N} Mb/sec"); + } + + return OperationOutcome.Successful; + } + finally + { + outfileStream.Close(); + Reset(); + } + } + catch (Exception e) + { + Log?.Write(LogType.Error, e.ToString()); + return OperationOutcome.FileIOWriteError; + } + } + + protected void OpenReaders() + { + if (_binaryReader == null) + { + this._fileStream = new FileStream(this._filename, FileMode.Open, FileAccess.Read, FileShare.Read); + this._binaryReader = new BinaryReader(this._fileStream); + } + } + + protected void CloseReaders() + { + if (this._binaryReader != null && this._binaryReader.BaseStream != null) + this._binaryReader.Close(); + this._binaryReader = null; + this._fileStream = null; + GC.Collect(); + } + + private void ReadHeader() + { + try + { + OpenReaders(); + + try + { + // Attempt without key area + var success = CheckAndReadHeader(false); + + if (!success) + { + // Attempt with key area + success = CheckAndReadHeader(true); + } + + this._fileOK = success; + } + finally + { + CloseReaders(); + } + } + catch (Exception ex) + { + Log?.Write(LogType.Error, ex.Message); + this._fileOK = false; + this._dataSizeB = 0; + this._cartSizeB = 0; + this._fileSizeB = 0; + this._offsetB = 0; + } + } + + private bool CheckAndReadHeader(bool assumeKeyArea) + { + // Read file size + this._fileSizeB = _fileStream.Length; + if (_fileSizeB < 32 * 1024) + { + Log?.Write(LogType.Error, "The source file doesn't look like an XCI file as the data size is too small"); + return false; + } + + // Setup offset + this._offsetB = (long)(assumeKeyArea ? XCIFileTrimmer.CartKeyAreaSize : 0); + + // Check header + this.Pos = _offsetB + XCIFileTrimmer.HeaderFilePos; + var head = System.Text.Encoding.ASCII.GetString(_binaryReader.ReadBytes(4)); + if (head != XCIFileTrimmer.HeaderMagicValue) + { + if (!assumeKeyArea) + { + Log?.Write(LogType.Warn, $"Incorrect header found, file mat contain a key area..."); + } + else + { + Log?.Write(LogType.Error, "The source file doesn't look like an XCI file as the header is corrupted"); + } + + return false; + } + + // Read Cart Size + this.Pos = _offsetB + XCIFileTrimmer.CartSizeFilePos; + var cartSizeId = _binaryReader.ReadByte(); + if (!_cartSizesGB.TryGetValue(cartSizeId, out long cartSizeNGB)) + { + Log?.Write(LogType.Error, "The source file doesn't look like an XCI file as the Cartridge Size is incorrect"); + return false; + } + this._cartSizeB = cartSizeNGB * XCIFileTrimmer.CartSizeMBinFormattedGB * XCIFileTrimmer.BytesInAMegabyte; + + // Read data size + this.Pos = _offsetB + XCIFileTrimmer.DataSizeFilePos; + var records = (long)BitConverter.ToUInt32(_binaryReader.ReadBytes(4), 0); + this._dataSizeB = RecordsToByte(records); + + return true; + } + } +} diff --git a/src/Ryujinx/Ui/MainWindow.cs b/src/Ryujinx/Ui/MainWindow.cs index 1ecbb9ea0..fa8aabb59 100644 --- a/src/Ryujinx/Ui/MainWindow.cs +++ b/src/Ryujinx/Ui/MainWindow.cs @@ -132,6 +132,7 @@ namespace Ryujinx.Ui [GUI] ScrolledWindow _gameTableWindow; [GUI] Label _gpuName; [GUI] Label _progressLabel; + [GUI] Label _progressStatusLabel; [GUI] Label _firmwareVersionLabel; [GUI] Gtk.ProgressBar _progressBar; [GUI] Box _viewBox; @@ -719,6 +720,34 @@ namespace Ryujinx.Ui }); } + public void StartProgress(string action) + { + Application.Invoke(delegate + { + _progressStatusLabel.Text = action; + _progressStatusLabel.Visible = true; + _progressBar.Fraction = 0; + }); + } + + public void UpdateProgress(double percentage) + { + Application.Invoke(delegate + { + _progressBar.Fraction = percentage; + }); + } + + public void EndProgress() + { + Application.Invoke(delegate + { + _progressStatusLabel.Text = String.Empty; + _progressStatusLabel.Visible = false; + _progressBar.Fraction = 1.0; + }); + } + public void UpdateGameTable() { if (_updatingGameTable || _gameLoaded) diff --git a/src/Ryujinx/Ui/MainWindow.glade b/src/Ryujinx/Ui/MainWindow.glade index 58d5d9558..85e73123b 100644 --- a/src/Ryujinx/Ui/MainWindow.glade +++ b/src/Ryujinx/Ui/MainWindow.glade @@ -667,6 +667,22 @@ 1 + + + False + False + 10 + 5 + 2 + 2 + + + + False + True + 2 + + 200 @@ -680,7 +696,7 @@ True True - 2 + 3 diff --git a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs index 162c172d9..fa818ec80 100644 --- a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs +++ b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs @@ -25,6 +25,7 @@ namespace Ryujinx.Ui.Widgets private MenuItem _openPtcDirMenuItem; private MenuItem _openShaderCacheDirMenuItem; private MenuItem _createShortcutMenuItem; + private MenuItem _trimXCIMenuItem; private void InitializeComponent() { @@ -198,6 +199,15 @@ namespace Ryujinx.Ui.Widgets }; _createShortcutMenuItem.Activated += CreateShortcut_Clicked; + // + // _trimXCIMenuItem + // + _trimXCIMenuItem = new MenuItem("Check and Trim XCI File") + { + TooltipText = "Check and Trim XCI File to Save Disk Space." + }; + _trimXCIMenuItem.Activated += TrimXCI_Clicked; + ShowComponent(); } @@ -213,6 +223,7 @@ namespace Ryujinx.Ui.Widgets _manageSubMenu.Append(_openShaderCacheDirMenuItem); Add(_createShortcutMenuItem); + Add(_trimXCIMenuItem); Add(new SeparatorMenuItem()); Add(_openSaveUserDirMenuItem); Add(_openSaveDeviceDirMenuItem); diff --git a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs index eb9f52d73..056d11649 100644 --- a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs +++ b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs @@ -79,6 +79,7 @@ namespace Ryujinx.Ui.Widgets _extractLogoMenuItem.Sensitive = hasNca; _createShortcutMenuItem.Sensitive = !ReleaseInformation.IsFlatHubBuild; + _trimXCIMenuItem.Sensitive = Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(_titleFilePath, new XCIFileTrimmerLog(_parent)); PopupAtPointer(null); } @@ -640,5 +641,88 @@ namespace Ryujinx.Ui.Widgets byte[] appIcon = new ApplicationLibrary(_virtualFileSystem).GetApplicationIcon(_titleFilePath, ConfigurationState.Instance.System.Language); ShortcutHelper.CreateAppShortcut(_titleFilePath, _titleName, _titleIdText, appIcon); } + + private void ProcessTrimResult(String filename, Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome operationOutcome) + { + string notifyUser = null; + + switch (operationOutcome) + { + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.NoTrimNecessary: + notifyUser = "XCI File does not need to be trimmed. Check logs for further details"; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.ReadOnlyFileCannotFix: + notifyUser = "XCI File is Read Only and could not be made writable. Check logs for further details"; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FreeSpaceCheckFailed: + notifyUser = "XCI File has data in the free space area, it is not safe to trim"; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.InvalidXCIFile: + notifyUser = "XCI File contains invalid data. Check logs for further details"; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FileIOWriteError: + notifyUser = "XCI File could not be opened for writing. Check logs for further details"; + break; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FileSizeChanged: + notifyUser = "XCI File has changed in size since it was scanned. Please check the file is not being written to and try again."; + break; + } + + if (notifyUser != null) + { + GtkDialog.CreateWarningDialog("Trimming of the XCI file failed", notifyUser); + } + } + + private void TrimXCI_Clicked(object sender, EventArgs args) + { + if (_titleFilePath == null) + { + return; + } + + var trimmer = new Ryujinx.Common.Utilities.XCIFileTrimmer(_titleFilePath, new XCIFileTrimmerLog(_parent)); + + if (trimmer.CanBeTrimmed) + { + var savings = (double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0; + var currentFileSize = (double)trimmer.FileSizeB / 1024.0 / 1024.0; + var cartDataSize = (double)trimmer.DataSizeB / 1024.0 / 1024.0; + + using MessageDialog confirmationDialog = GtkDialog.CreateConfirmationDialog( + $"This function will first check the empty space and then trim the XCI File to save disk space. Continue?", + $"Current File Size: {currentFileSize:n} MB\n" + + $"Game Data Size: {cartDataSize:n} MB\n" + + $"Disk Space Savings: {savings:n} MB\n" + ); + + if (confirmationDialog.Run() == (int)ResponseType.Yes) + { + Thread xciFileTrimmerThread = new(() => + { + _parent.StartProgress($"Trimming file '{_titleFilePath}"); + + try + { + var oeprationOutcome = trimmer.Trim(); + + Gtk.Application.Invoke(delegate + { + ProcessTrimResult(_titleFilePath, oeprationOutcome); + }); + } + finally + { + _parent.EndProgress(); + } + }) + { + Name = "GUI.XCIFileTrimmerThread", + IsBackground = true, + }; + xciFileTrimmerThread.Start(); + } + } + } } } diff --git a/src/Ryujinx/Ui/XCIFileTrimmerLog.cs b/src/Ryujinx/Ui/XCIFileTrimmerLog.cs new file mode 100644 index 000000000..db46cbde1 --- /dev/null +++ b/src/Ryujinx/Ui/XCIFileTrimmerLog.cs @@ -0,0 +1,27 @@ +using Ryujinx.Common.Logging; +using System; + +namespace Ryujinx.Ui +{ + public class XCIFileTrimmerLog : Ryujinx.Common.Logging.XCIFileTrimmerLog + { + private readonly MainWindow _mainWindow; + + public XCIFileTrimmerLog(MainWindow mainWindow) + { + _mainWindow = mainWindow; + } + + public override void Progress(long current, long total, string text, bool complete) + { + if (!complete) + { + _mainWindow.UpdateProgress((double)current / (double)total); + } + else + { + _mainWindow.EndProgress(); + } + } + } +}