diff --git a/src/Ryujinx.Common/Logging/LogClass.cs b/src/Ryujinx.Common/Logging/LogClass.cs
index 1b404a06a..a4117580e 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 000000000..fb11432b0
--- /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 000000000..050e78d1e
--- /dev/null
+++ b/src/Ryujinx.Common/Utilities/XCIFileTrimmer.cs
@@ -0,0 +1,524 @@
+// Uncomment the line below to ensure XCIFileTrimmer does not modify files
+//#define XCI_TRIMMER_READ_ONLY_MODE
+
+using Gommon;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+
+namespace Ryujinx.Common.Utilities
+{
+ 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
+ {
+ Undetermined,
+ InvalidXCIFile,
+ NoTrimNecessary,
+ NoUntrimPossible,
+ FreeSpaceCheckFailed,
+ FileIOWriteError,
+ ReadOnlyFileCannotFix,
+ FileSizeChanged,
+ Successful,
+ Cancelled
+ }
+
+ 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(CancellationToken? cancelToken = null)
+ {
+ if (FreeSpaceChecked)
+ return;
+
+ try
+ {
+ if (CanBeTrimmed)
+ {
+ _freeSpaceValid = false;
+
+ OpenReaders();
+
+ try
+ {
+ Pos = TrimmedFileSizeB;
+ bool freeSpaceValid = true;
+ long readSizeB = FileSizeB - TrimmedFileSizeB;
+
+ Stopwatch timedSw = Lambda.Timed(() =>
+ {
+ freeSpaceValid = CheckPadding(readSizeB, cancelToken);
+ });
+
+ if (timedSw.Elapsed.TotalSeconds > 0)
+ {
+ Log?.Write(LogType.Info, $"Checked at {readSizeB / (double)XCIFileTrimmer.BytesInAMegabyte / timedSw.Elapsed.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, CancellationToken? cancelToken = null)
+ {
+ long maxReads = readSizeB / XCIFileTrimmer.BufferSize;
+ long read = 0;
+ var buffer = new byte[BufferSize];
+
+ while (true)
+ {
+ if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+ {
+ return false;
+ }
+
+ 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(CancellationToken? cancelToken = null)
+ {
+ if (!FileOK)
+ {
+ return OperationOutcome.InvalidXCIFile;
+ }
+
+ if (!CanBeTrimmed)
+ {
+ return OperationOutcome.NoTrimNecessary;
+ }
+
+ if (!FreeSpaceChecked)
+ {
+ CheckFreeSpace(cancelToken);
+ }
+
+ if (!FreeSpaceValid)
+ {
+ if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+ {
+ return OperationOutcome.Cancelled;
+ }
+ else
+ {
+ 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
+ {
+
+#if !XCI_TRIMMER_READ_ONLY_MODE
+ outfileStream.SetLength(TrimmedFileSizeB);
+#endif
+ return OperationOutcome.Successful;
+ }
+ finally
+ {
+ outfileStream.Close();
+ Reset();
+ }
+ }
+ catch (Exception e)
+ {
+ Log?.Write(LogType.Error, e.ToString());
+ return OperationOutcome.FileIOWriteError;
+ }
+ }
+
+ public OperationOutcome Untrim(CancellationToken? cancelToken = null)
+ {
+ 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
+ {
+ Stopwatch timedSw = Lambda.Timed(() =>
+ {
+ WritePadding(outfileStream, bytesToWriteB, cancelToken);
+ });
+
+ if (timedSw.Elapsed.TotalSeconds > 0)
+ {
+ Log?.Write(LogType.Info, $"Wrote at {bytesToWriteB / (double)XCIFileTrimmer.BytesInAMegabyte / timedSw.Elapsed.TotalSeconds:N} Mb/sec");
+ }
+
+ if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+ {
+ return OperationOutcome.Cancelled;
+ }
+ else
+ {
+ 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, CancellationToken? cancelToken = null)
+ {
+ 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)
+ {
+ if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
+ {
+ return;
+ }
+
+ long bytesToWrite = Math.Min(XCIFileTrimmer.BufferSize, bytesLeftToWriteB);
+
+#if !XCI_TRIMMER_READ_ONLY_MODE
+ outfileStream.Write(buffer, 0, (int)bytesToWrite);
+#endif
+
+ 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.HLE.Generators/IpcServiceGenerator.cs b/src/Ryujinx.HLE.Generators/IpcServiceGenerator.cs
index 5dcd49af5..5cac4d13a 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.UI.Common/Models/XCITrimmerFileModel.cs b/src/Ryujinx.UI.Common/Models/XCITrimmerFileModel.cs
new file mode 100644
index 000000000..05fa82920
--- /dev/null
+++ b/src/Ryujinx.UI.Common/Models/XCITrimmerFileModel.cs
@@ -0,0 +1,55 @@
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.UI.App.Common;
+
+namespace Ryujinx.UI.Common.Models
+{
+ public record XCITrimmerFileModel(
+ string Name,
+ string Path,
+ bool Trimmable,
+ bool Untrimmable,
+ long PotentialSavingsB,
+ long CurrentSavingsB,
+ int? PercentageProgress,
+ XCIFileTrimmer.OperationOutcome ProcessingOutcome)
+ {
+ public static XCITrimmerFileModel FromApplicationData(ApplicationData applicationData, XCIFileTrimmerLog logger)
+ {
+ var trimmer = new XCIFileTrimmer(applicationData.Path, logger);
+
+ return new XCITrimmerFileModel(
+ applicationData.Name,
+ applicationData.Path,
+ trimmer.CanBeTrimmed,
+ trimmer.CanBeUntrimmed,
+ trimmer.DiskSpaceSavingsB,
+ trimmer.DiskSpaceSavedB,
+ null,
+ XCIFileTrimmer.OperationOutcome.Undetermined
+ );
+ }
+
+ public bool IsFailed
+ {
+ get
+ {
+ return ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Undetermined &&
+ ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Successful;
+ }
+ }
+
+ public virtual bool Equals(XCITrimmerFileModel obj)
+ {
+ if (obj == null)
+ return false;
+ else
+ return this.Path == obj.Path;
+ }
+
+ public override int GetHashCode()
+ {
+ return this.Path.GetHashCode();
+ }
+ }
+}
diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json
index 8c2e7db71..c2ea29b9a 100644
--- a/src/Ryujinx/Assets/Locales/en_US.json
+++ b/src/Ryujinx/Assets/Locales/en_US.json
@@ -33,6 +33,7 @@
"MenuBarToolsManageFileTypes": "Manage file types",
"MenuBarToolsInstallFileTypes": "Install file types",
"MenuBarToolsUninstallFileTypes": "Uninstall file types",
+ "MenuBarToolsXCITrimmer": "Trim XCI Files",
"MenuBarView": "_View",
"MenuBarViewWindow": "Window Size",
"MenuBarViewWindow720": "720p",
@@ -84,8 +85,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.",
@@ -400,6 +404,8 @@
"InputDialogTitle": "Input Dialog",
"InputDialogOk": "OK",
"InputDialogCancel": "Cancel",
+ "InputDialogCancelling": "Cancelling",
+ "InputDialogClose": "Close",
"InputDialogAddNewProfileTitle": "Choose the Profile Name",
"InputDialogAddNewProfileHeader": "Please Enter a Profile Name",
"InputDialogAddNewProfileSubtext": "(Max Length: {0})",
@@ -468,6 +474,7 @@
"DialogUninstallFileTypesSuccessMessage": "Successfully uninstalled file types!",
"DialogUninstallFileTypesErrorMessage": "Failed to uninstall file types.",
"DialogOpenSettingsWindowLabel": "Open Settings Window",
+ "DialogOpenXCITrimmerWindowLabel": "XCI Trimmer Window",
"DialogControllerAppletTitle": "Controller Applet",
"DialogMessageDialogErrorExceptionMessage": "Error displaying Message Dialog: {0}",
"DialogSoftwareKeyboardErrorExceptionMessage": "Error displaying Software Keyboard: {0}",
@@ -670,6 +677,12 @@
"TitleUpdateVersionLabel": "Version {0}",
"TitleBundledUpdateVersionLabel": "Bundled: Version {0}",
"TitleBundledDlcLabel": "Bundled:",
+ "TitleXCIStatusPartialLabel": "Partial",
+ "TitleXCIStatusTrimmableLabel": "Untrimmed",
+ "TitleXCIStatusUntrimmableLabel": "Trimmed",
+ "TitleXCIStatusFailedLabel": "(Failed)",
+ "TitleXCICanSaveLabel": "Save {0:n0} Mb",
+ "TitleXCISavingLabel": "Saved {0:n0} Mb",
"RyujinxInfo": "Ryujinx - Info",
"RyujinxConfirm": "Ryujinx - Confirmation",
"FileDialogAllTypes": "All types",
@@ -722,11 +735,37 @@
"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",
+ "TrimXCIFileNoUntrimPossible": "XCI File cannot be untrimmed. 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",
+ "TrimXCIFileCancelled": "The operation was cancelled",
+ "TrimXCIFileFileUndertermined": "No operation was performed",
"UserProfileWindowTitle": "User Profiles Manager",
"CheatWindowTitle": "Cheats Manager",
"DlcWindowTitle": "Manage Downloadable Content for {0} ({1})",
"ModWindowTitle": "Manage Mods for {0} ({1})",
"UpdateWindowTitle": "Title Update Manager",
+ "XCITrimmerWindowTitle": "XCI File Trimmer",
+ "XCITrimmerTitleStatusCount": "{0} of {1} Title(s) Selected",
+ "XCITrimmerTitleStatusCountWithFilter": "{0} of {1} Title(s) Selected ({2} displayed)",
+ "XCITrimmerTitleStatusTrimming": "Trimming {0} Title(s)...",
+ "XCITrimmerTitleStatusUntrimming": "Untrimming {0} Title(s)...",
+ "XCITrimmerTitleStatusFailed": "Failed",
+ "XCITrimmerPotentialSavings": "Potential Savings",
+ "XCITrimmerActualSavings": "Actual Savings",
+ "XCITrimmerSavingsMb": "{0:n0} Mb",
+ "XCITrimmerSelectDisplayed": "Select Shown",
+ "XCITrimmerDeselectDisplayed": "Deselect Shown",
+ "XCITrimmerSortName": "Title",
+ "XCITrimmerSortSaved": "Space Savings",
"UpdateWindowUpdateAddedMessage": "{0} new update(s) added",
"UpdateWindowBundledContentNotice": "Bundled updates cannot be removed, only disabled.",
"CheatWindowHeading": "Cheats Available for {0} [{1}]",
@@ -740,6 +779,7 @@
"AutoloadUpdateRemovedMessage": "{0} missing update(s) removed",
"ModWindowHeading": "{0} Mod(s)",
"UserProfilesEditProfile": "Edit Selected",
+ "Continue": "Continue",
"Cancel": "Cancel",
"Save": "Save",
"Discard": "Discard",
diff --git a/src/Ryujinx/Assets/Styles/Styles.xaml b/src/Ryujinx/Assets/Styles/Styles.xaml
index b3a6f59c8..05212a7dd 100644
--- a/src/Ryujinx/Assets/Styles/Styles.xaml
+++ b/src/Ryujinx/Assets/Styles/Styles.xaml
@@ -43,6 +43,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs b/src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs
new file mode 100644
index 000000000..580ebc9da
--- /dev/null
+++ b/src/Ryujinx/UI/Windows/XCITrimmerWindow.axaml.cs
@@ -0,0 +1,101 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.UI.Common.Models;
+using System;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class XCITrimmerWindow : UserControl
+ {
+ public XCITrimmerViewModel ViewModel;
+
+ public XCITrimmerWindow()
+ {
+ DataContext = this;
+
+ InitializeComponent();
+ }
+
+ public XCITrimmerWindow(MainWindowViewModel mainWindowViewModel)
+ {
+ DataContext = ViewModel = new XCITrimmerViewModel(mainWindowViewModel);
+
+ InitializeComponent();
+ }
+
+ public static async Task Show(MainWindowViewModel mainWindowViewModel)
+ {
+ ContentDialog contentDialog = new()
+ {
+ PrimaryButtonText = "",
+ SecondaryButtonText = "",
+ CloseButtonText = "",
+ Content = new XCITrimmerWindow(mainWindowViewModel),
+ Title = string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerWindowTitle]),
+ };
+
+ Style bottomBorder = new(x => x.OfType().Name("DialogSpace").Child().OfType());
+ bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false));
+
+ contentDialog.Styles.Add(bottomBorder);
+
+ await contentDialog.ShowAsync();
+ }
+
+ private void Trim(object sender, RoutedEventArgs e)
+ {
+ ViewModel.TrimSelected();
+ }
+
+ private void Untrim(object sender, RoutedEventArgs e)
+ {
+ ViewModel.UntrimSelected();
+ }
+
+ private void Close(object sender, RoutedEventArgs e)
+ {
+ ((ContentDialog)Parent).Hide();
+ }
+
+ private void Cancel(Object sender, RoutedEventArgs e)
+ {
+ ViewModel.Cancel = true;
+ }
+
+ public void Sort_Checked(object sender, RoutedEventArgs args)
+ {
+ if (sender is RadioButton { Tag: string sortField })
+ ViewModel.SortingField = Enum.Parse(sortField);
+ }
+
+ public void Order_Checked(object sender, RoutedEventArgs args)
+ {
+ if (sender is RadioButton { Tag: string sortOrder })
+ ViewModel.SortingAscending = sortOrder is "Ascending";
+ }
+
+ private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ foreach (var content in e.AddedItems)
+ {
+ if (content is XCITrimmerFileModel applicationData)
+ {
+ ViewModel.Select(applicationData);
+ }
+ }
+
+ foreach (var content in e.RemovedItems)
+ {
+ if (content is XCITrimmerFileModel applicationData)
+ {
+ ViewModel.Deselect(applicationData);
+ }
+ }
+ }
+ }
+}