diff --git a/src/Ryujinx.Common/Logging/LogClass.cs b/src/Ryujinx.Common/Logging/LogClass.cs
index 1b404a06..a4117580 100644
--- a/src/Ryujinx.Common/Logging/LogClass.cs
+++ b/src/Ryujinx.Common/Logging/LogClass.cs
@@ -72,5 +72,6 @@ namespace Ryujinx.Common.Logging
TamperMachine,
UI,
Vic,
+ XCIFileTrimmer
}
}
diff --git a/src/Ryujinx.Common/Logging/XCIFileTrimmerLog.cs b/src/Ryujinx.Common/Logging/XCIFileTrimmerLog.cs
new file mode 100644
index 00000000..fb11432b
--- /dev/null
+++ b/src/Ryujinx.Common/Logging/XCIFileTrimmerLog.cs
@@ -0,0 +1,30 @@
+using Ryujinx.Common.Utilities;
+
+namespace Ryujinx.Common.Logging
+{
+ public class XCIFileTrimmerLog : XCIFileTrimmer.ILog
+ {
+ public virtual void Progress(long current, long total, string text, bool complete)
+ {
+ }
+
+ public void Write(XCIFileTrimmer.LogType logType, string text)
+ {
+ switch (logType)
+ {
+ case XCIFileTrimmer.LogType.Info:
+ Logger.Notice.Print(LogClass.XCIFileTrimmer, text);
+ break;
+ case XCIFileTrimmer.LogType.Warn:
+ Logger.Warning?.Print(LogClass.XCIFileTrimmer, text);
+ break;
+ case XCIFileTrimmer.LogType.Error:
+ Logger.Error?.Print(LogClass.XCIFileTrimmer, text);
+ break;
+ case XCIFileTrimmer.LogType.Progress:
+ Logger.Info?.Print(LogClass.XCIFileTrimmer, text);
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs b/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs
new file mode 100644
index 00000000..e33863bd
--- /dev/null
+++ b/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs
@@ -0,0 +1,507 @@
+using Ryujinx.Common.Logging;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+
+namespace Ryujinx.Common.Utilities
+{
+ internal static class Performance
+ {
+ internal static TimeSpan Measure(Action action)
+ {
+ var sw = new Stopwatch();
+ sw.Start();
+
+ try
+ {
+ action();
+ }
+ finally
+ {
+ sw.Stop();
+ }
+
+ return sw.Elapsed;
+ }
+ }
+
+ public sealed class XCIFileTrimmer
+ {
+ private const long BytesInAMegabyte = 1024 * 1024;
+ private const int BufferSize = 8 * (int)BytesInAMegabyte;
+
+ private const long CartSizeMBinFormattedGB = 952;
+ private const int CartKeyAreaSize = 0x1000;
+ private const byte PaddingByte = 0xFF;
+ private const int HeaderFilePos = 0x100;
+ private const int CartSizeFilePos = 0x10D;
+ private const int DataSizeFilePos = 0x118;
+ private const string HeaderMagicValue = "HEAD";
+
+ ///
+ /// Cartridge Sizes (ByteIdentifier, SizeInGB)
+ ///
+ private static readonly Dictionary _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 (Path.GetExtension(filename).Equals(".XCI", StringComparison.InvariantCultureIgnoreCase))
+ {
+ var trimmer = new XCIFileTrimmer(filename, log);
+ return trimmer.CanBeTrimmed;
+ }
+
+ return false;
+ }
+
+ public static bool CanUntrim(string filename, ILog log = null)
+ {
+ if (Path.GetExtension(filename).Equals(".XCI", StringComparison.InvariantCultureIgnoreCase))
+ {
+ var trimmer = new XCIFileTrimmer(filename, log);
+ return trimmer.CanBeUntrimmed;
+ }
+
+ 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 => _filename;
+ set
+ {
+ _filename = value;
+ Reset();
+ }
+ }
+
+ public long Pos
+ {
+ get => _fileStream.Position;
+ set => _fileStream.Position = value;
+ }
+
+ public XCIFileTrimmer(string path, ILog log = null)
+ {
+ Log = log;
+ Filename = path;
+ ReadHeader();
+ }
+
+ public void CheckFreeSpace()
+ {
+ if (FreeSpaceChecked)
+ return;
+
+ try
+ {
+ if (CanBeTrimmed)
+ {
+ _freeSpaceValid = false;
+
+ OpenReaders();
+
+ try
+ {
+ Pos = TrimmedFileSizeB;
+ bool freeSpaceValid = true;
+ long readSizeB = FileSizeB - TrimmedFileSizeB;
+
+ TimeSpan time = Performance.Measure(() =>
+ {
+ freeSpaceValid = CheckPadding(readSizeB);
+ });
+
+ if (time.TotalSeconds > 0)
+ {
+ Log?.Write(LogType.Info, $"Checked at {readSizeB / (double)XCIFileTrimmer.BytesInAMegabyte / time.TotalSeconds:N} Mb/sec");
+ }
+
+ if (freeSpaceValid)
+ Log?.Write(LogType.Info, "Free space is valid");
+
+ _freeSpaceValid = freeSpaceValid;
+ }
+ finally
+ {
+ CloseReaders();
+ }
+
+ }
+ else
+ {
+ Log?.Write(LogType.Warn, "There is no free space to check.");
+ _freeSpaceValid = false;
+ }
+ }
+ finally
+ {
+ _freeSpaceChecked = true;
+ }
+ }
+
+ private bool CheckPadding(long readSizeB)
+ {
+ long maxReads = readSizeB / XCIFileTrimmer.BufferSize;
+ long read = 0;
+ var buffer = new byte[BufferSize];
+
+ while (true)
+ {
+ int bytes = _fileStream.Read(buffer, 0, XCIFileTrimmer.BufferSize);
+ if (bytes == 0)
+ break;
+
+ Log?.Progress(read, maxReads, "Verifying file can be trimmed", false);
+ if (buffer.Take(bytes).AsParallel().Any(b => b != XCIFileTrimmer.PaddingByte))
+ {
+ Log?.Write(LogType.Warn, "Free space is NOT valid");
+ return false;
+ }
+
+ read++;
+ }
+
+ return true;
+ }
+
+ private void Reset()
+ {
+ _freeSpaceChecked = false;
+ _freeSpaceValid = false;
+ ReadHeader();
+ }
+
+ public OperationOutcome Trim()
+ {
+ if (!FileOK)
+ {
+ return OperationOutcome.InvalidXCIFile;
+ }
+
+ if (!CanBeTrimmed)
+ {
+ return OperationOutcome.NoTrimNecessary;
+ }
+
+ if (!FreeSpaceChecked)
+ {
+ CheckFreeSpace();
+ }
+
+ if (!FreeSpaceValid)
+ {
+ return OperationOutcome.FreeSpaceCheckFailed;
+ }
+
+ Log?.Write(LogType.Info, "Trimming...");
+
+ try
+ {
+ var info = new FileInfo(Filename);
+ if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
+ {
+ try
+ {
+ Log?.Write(LogType.Info, "Attempting to remove ReadOnly attribute");
+ File.SetAttributes(Filename, info.Attributes & ~FileAttributes.ReadOnly);
+ }
+ catch (Exception e)
+ {
+ Log?.Write(LogType.Error, e.ToString());
+ return OperationOutcome.ReadOnlyFileCannotFix;
+ }
+ }
+
+ if (info.Length != 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(TrimmedFileSizeB);
+ return OperationOutcome.Successful;
+ }
+ finally
+ {
+ outfileStream.Close();
+ Reset();
+ }
+ }
+ catch (Exception e)
+ {
+ Log?.Write(LogType.Error, e.ToString());
+ return OperationOutcome.FileIOWriteError;
+ }
+ }
+
+ public OperationOutcome Untrim()
+ {
+ if (!FileOK)
+ {
+ return OperationOutcome.InvalidXCIFile;
+ }
+
+ if (!CanBeUntrimmed)
+ {
+ return OperationOutcome.NoUntrimPossible;
+ }
+
+ try
+ {
+ Log?.Write(LogType.Info, "Untrimming...");
+
+ var info = new FileInfo(Filename);
+ if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
+ {
+ try
+ {
+ Log?.Write(LogType.Info, "Attempting to remove ReadOnly attribute");
+ File.SetAttributes(Filename, info.Attributes & ~FileAttributes.ReadOnly);
+ }
+ catch (Exception e)
+ {
+ Log?.Write(LogType.Error, e.ToString());
+ return OperationOutcome.ReadOnlyFileCannotFix;
+ }
+ }
+
+ if (info.Length != FileSizeB)
+ {
+ Log?.Write(LogType.Error, "File size has changed, cannot safely untrim.");
+ return OperationOutcome.FileSizeChanged;
+ }
+
+ var outfileStream = new FileStream(_filename, FileMode.Append, FileAccess.Write, FileShare.Write);
+ long bytesToWriteB = UntrimmedFileSizeB - FileSizeB;
+
+ try
+ {
+ TimeSpan time = Performance.Measure(() =>
+ {
+ WritePadding(outfileStream, bytesToWriteB);
+ });
+
+ 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;
+ }
+ }
+
+ private void WritePadding(FileStream outfileStream, long bytesToWriteB)
+ {
+ long bytesLeftToWriteB = bytesToWriteB;
+ long writes = bytesLeftToWriteB / XCIFileTrimmer.BufferSize;
+ int write = 0;
+
+ try
+ {
+ var buffer = new byte[BufferSize];
+ Array.Fill(buffer, XCIFileTrimmer.PaddingByte);
+
+ while (bytesLeftToWriteB > 0)
+ {
+ long 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);
+ }
+ }
+
+ private void OpenReaders()
+ {
+ if (_binaryReader == null)
+ {
+ _fileStream = new FileStream(_filename, FileMode.Open, FileAccess.Read, FileShare.Read);
+ _binaryReader = new BinaryReader(_fileStream);
+ }
+ }
+
+ private void CloseReaders()
+ {
+ if (_binaryReader != null && _binaryReader.BaseStream != null)
+ _binaryReader.Close();
+ _binaryReader = null;
+ _fileStream = null;
+ GC.Collect();
+ }
+
+ private void ReadHeader()
+ {
+ try
+ {
+ OpenReaders();
+
+ try
+ {
+ // Attempt without key area
+ bool success = CheckAndReadHeader(false);
+
+ if (!success)
+ {
+ // Attempt with key area
+ success = CheckAndReadHeader(true);
+ }
+
+ _fileOK = success;
+ }
+ finally
+ {
+ CloseReaders();
+ }
+ }
+ catch (Exception ex)
+ {
+ Log?.Write(LogType.Error, ex.Message);
+ _fileOK = false;
+ _dataSizeB = 0;
+ _cartSizeB = 0;
+ _fileSizeB = 0;
+ _offsetB = 0;
+ }
+ }
+
+ private bool CheckAndReadHeader(bool assumeKeyArea)
+ {
+ // Read file size
+ _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
+ _offsetB = (long)(assumeKeyArea ? XCIFileTrimmer.CartKeyAreaSize : 0);
+
+ // Check header
+ Pos = _offsetB + XCIFileTrimmer.HeaderFilePos;
+ string 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
+ Pos = _offsetB + XCIFileTrimmer.CartSizeFilePos;
+ byte 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 (0x{cartSizeId:X2})");
+ return false;
+ }
+ _cartSizeB = cartSizeNGB * XCIFileTrimmer.CartSizeMBinFormattedGB * XCIFileTrimmer.BytesInAMegabyte;
+
+ // Read data size
+ Pos = _offsetB + XCIFileTrimmer.DataSizeFilePos;
+ long records = (long)BitConverter.ToUInt32(_binaryReader.ReadBytes(4), 0);
+ _dataSizeB = RecordsToByte(records);
+
+ return true;
+ }
+ }
+}
diff --git a/src/Ryujinx.Gtk3/UI/MainWindow.cs b/src/Ryujinx.Gtk3/UI/MainWindow.cs
index b10dfe3f..5f3d6925 100644
--- a/src/Ryujinx.Gtk3/UI/MainWindow.cs
+++ b/src/Ryujinx.Gtk3/UI/MainWindow.cs
@@ -134,6 +134,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;
@@ -727,6 +728,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.Gtk3/UI/MainWindow.glade b/src/Ryujinx.Gtk3/UI/MainWindow.glade
index d1b6872a..22d2b58b 100644
--- a/src/Ryujinx.Gtk3/UI/MainWindow.glade
+++ b/src/Ryujinx.Gtk3/UI/MainWindow.glade
@@ -667,6 +667,22 @@
1
+
+
+
+ False
+ True
+ 2
+
+
diff --git a/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.Designer.cs b/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.Designer.cs
index 8ee1cd2f..d082d989 100644
--- a/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.Designer.cs
+++ b/src/Ryujinx.Gtk3/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();
}
@@ -224,6 +234,8 @@ namespace Ryujinx.UI.Widgets
Add(_openTitleModDirMenuItem);
Add(_openTitleSdModDirMenuItem);
Add(new SeparatorMenuItem());
+ Add(_trimXCIMenuItem);
+ Add(new SeparatorMenuItem());
Add(_manageCacheMenuItem);
Add(_extractMenuItem);
diff --git a/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs b/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs
index a3e3d4c8..731a8f8f 100644
--- a/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs
+++ b/src/Ryujinx.Gtk3/UI/Widgets/GameTableContextMenu.cs
@@ -13,6 +13,7 @@ using LibHac.Tools.FsSystem.NcaUtils;
using Ryujinx.Common;
using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc;
@@ -75,6 +76,7 @@ namespace Ryujinx.UI.Widgets
_extractLogoMenuItem.Sensitive = hasNca;
_createShortcutMenuItem.Sensitive = !ReleaseInformation.IsFlatHubBuild;
+ _trimXCIMenuItem.Sensitive = _applicationData != null && Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(_applicationData.Path, new XCIFileTrimmerLog(_parent));
PopupAtPointer(null);
}
@@ -630,5 +632,91 @@ namespace Ryujinx.UI.Widgets
byte[] appIcon = new ApplicationLibrary(_virtualFileSystem, checkLevel).GetApplicationIcon(_applicationData.Path, ConfigurationState.Instance.System.Language, _applicationData.Id);
ShortcutHelper.CreateAppShortcut(_applicationData.Path, _applicationData.Name, _applicationData.IdString, 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;
+ case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.Successful:
+ _parent.UpdateGameTable();
+ break;
+ }
+
+ if (notifyUser != null)
+ {
+ GtkDialog.CreateWarningDialog("Trimming of the XCI file failed", notifyUser);
+ }
+ }
+
+ private void TrimXCI_Clicked(object sender, EventArgs args)
+ {
+ if (_applicationData?.Path == null)
+ {
+ return;
+ }
+
+ var trimmer = new XCIFileTrimmer(_applicationData.Path, 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 '{_applicationData.Path}");
+
+ try
+ {
+ XCIFileTrimmer.OperationOutcome operationOutcome = trimmer.Trim();
+
+ Gtk.Application.Invoke(delegate
+ {
+ ProcessTrimResult(_applicationData.Path, operationOutcome);
+ });
+ }
+ finally
+ {
+ _parent.EndProgress();
+ }
+ })
+ {
+ Name = "GUI.XCIFileTrimmerThread",
+ IsBackground = true,
+ };
+ xciFileTrimmerThread.Start();
+ }
+ }
+ }
}
}
diff --git a/src/Ryujinx.Gtk3/UI/XCIFileTrimmerLog.cs b/src/Ryujinx.Gtk3/UI/XCIFileTrimmerLog.cs
new file mode 100644
index 00000000..91ff1909
--- /dev/null
+++ b/src/Ryujinx.Gtk3/UI/XCIFileTrimmerLog.cs
@@ -0,0 +1,27 @@
+using Ryujinx.Common.Logging;
+using System;
+
+namespace Ryujinx.UI
+{
+ internal 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();
+ }
+ }
+ }
+}
diff --git a/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs b/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs
index 19fdbe19..1a2fa61c 100644
--- a/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs
+++ b/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs
@@ -13,6 +13,7 @@ namespace Ryujinx.HLE.Generators
var syntaxReceiver = (ServiceSyntaxReceiver)context.SyntaxReceiver;
CodeGenerator generator = new CodeGenerator();
+ generator.AppendLine("#nullable enable");
generator.AppendLine("using System;");
generator.EnterScope($"namespace Ryujinx.HLE.HOS.Services.Sm");
generator.EnterScope($"partial class IUserInterface");
@@ -58,6 +59,7 @@ namespace Ryujinx.HLE.Generators
generator.LeaveScope();
generator.LeaveScope();
+ generator.AppendLine("#nullable disable");
context.AddSource($"IUserInterface.g.cs", generator.ToString());
}
diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json
index b3cab7f5..34f67c22 100644
--- a/src/Ryujinx/Assets/Locales/en_US.json
+++ b/src/Ryujinx/Assets/Locales/en_US.json
@@ -82,8 +82,11 @@
"GameListContextMenuOpenModsDirectoryToolTip": "Opens the directory which contains Application's Mods",
"GameListContextMenuOpenSdModsDirectory": "Open Atmosphere Mods Directory",
"GameListContextMenuOpenSdModsDirectoryToolTip": "Opens the alternative SD card Atmosphere directory which contains Application's Mods. Useful for mods that are packaged for real hardware.",
+ "GameListContextMenuTrimXCI": "Check and Trim XCI File",
+ "GameListContextMenuTrimXCIToolTip": "Check and Trim XCI File to Save Disk Space",
"StatusBarGamesLoaded": "{0}/{1} Games Loaded",
"StatusBarSystemVersion": "System Version: {0}",
+ "StatusBarXCIFileTrimming": "Trimming XCI File '{0}'",
"LinuxVmMaxMapCountDialogTitle": "Low limit for memory mappings detected",
"LinuxVmMaxMapCountDialogTextPrimary": "Would you like to increase the value of vm.max_map_count to {0}",
"LinuxVmMaxMapCountDialogTextSecondary": "Some games might try to create more memory mappings than currently allowed. Ryujinx will crash as soon as this limit gets exceeded.",
@@ -704,6 +707,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})",
@@ -714,6 +727,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/Common/XCIFileTrimmerLog.cs b/src/Ryujinx/Common/XCIFileTrimmerLog.cs
new file mode 100644
index 00000000..aed7f89a
--- /dev/null
+++ b/src/Ryujinx/Common/XCIFileTrimmerLog.cs
@@ -0,0 +1,24 @@
+using Ryujinx.Ava.UI.ViewModels;
+
+namespace Ryujinx.Ava.Common
+{
+ internal 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/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml
index dd0926fc..572e14f4 100644
--- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml
+++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml
@@ -59,6 +59,12 @@
Click="OpenSdModsDirectory_Click"
Header="{locale:Locale GameListContextMenuOpenSdModsDirectory}"
ToolTip.Tip="{locale:Locale GameListContextMenuOpenSdModsDirectoryToolTip}" />
+
+