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();
+ }
+ }
+ }
+}