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 + False + 10 + 5 + 2 + 2 + + + + False + True + 2 + + 200 @@ -680,7 +696,7 @@ True True - 2 + 3 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}" /> + + !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0; + public bool TrimXCIEnabled => Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(SelectedApplication.Path, new Common.XCIFileTrimmerLog(this)); + public bool OpenBcatSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0; public bool CreateShortcutEnabled => !ReleaseInformation.IsFlatHubBuild; @@ -480,6 +486,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; @@ -1747,6 +1775,114 @@ 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; + case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.Successful: + if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (desktop.MainWindow is MainWindow mainWindow) + mainWindow.LoadApplications(); + } + 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 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; + string 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 + { + XCIFileTrimmer.OperationOutcome 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/UI/Views/Main/MainStatusBarView.axaml b/src/Ryujinx/UI/Views/Main/MainStatusBarView.axaml index f9e192e6..373d1941 100644 --- a/src/Ryujinx/UI/Views/Main/MainStatusBarView.axaml +++ b/src/Ryujinx/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}" /> +