Add ability to trim XCI files from the application context menu (#33)

This commit is contained in:
TheToid 2024-10-23 01:25:40 +10:00 committed by GitHub
parent 7e9a293dab
commit 6253fe143a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 915 additions and 2 deletions

View file

@ -72,5 +72,6 @@ namespace Ryujinx.Common.Logging
TamperMachine, TamperMachine,
UI, UI,
Vic, Vic,
XCIFileTrimmer
} }
} }

View file

@ -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;
}
}
}
}

View file

@ -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";
/// <summary>
/// Cartridge Sizes (ByteIdentifier, SizeInGB)
/// </summary>
private static readonly Dictionary<byte, long> _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<byte>(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;
}
}
}

View file

@ -134,6 +134,7 @@ namespace Ryujinx.UI
[GUI] ScrolledWindow _gameTableWindow; [GUI] ScrolledWindow _gameTableWindow;
[GUI] Label _gpuName; [GUI] Label _gpuName;
[GUI] Label _progressLabel; [GUI] Label _progressLabel;
[GUI] Label _progressStatusLabel;
[GUI] Label _firmwareVersionLabel; [GUI] Label _firmwareVersionLabel;
[GUI] Gtk.ProgressBar _progressBar; [GUI] Gtk.ProgressBar _progressBar;
[GUI] Box _viewBox; [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() public void UpdateGameTable()
{ {
if (_updatingGameTable || _gameLoaded) if (_updatingGameTable || _gameLoaded)

View file

@ -667,6 +667,22 @@
<property name="position">1</property> <property name="position">1</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkLabel" id="_progressStatusLabel">
<property name="visible">False</property>
<property name="can-focus">False</property>
<property name="margin-left">10</property>
<property name="margin-right">5</property>
<property name="margin-top">2</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes"></property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child> <child>
<object class="GtkProgressBar" id="_progressBar"> <object class="GtkProgressBar" id="_progressBar">
<property name="width-request">200</property> <property name="width-request">200</property>
@ -680,7 +696,7 @@
<packing> <packing>
<property name="expand">True</property> <property name="expand">True</property>
<property name="fill">True</property> <property name="fill">True</property>
<property name="position">2</property> <property name="position">3</property>
</packing> </packing>
</child> </child>
</object> </object>

View file

@ -25,6 +25,7 @@ namespace Ryujinx.UI.Widgets
private MenuItem _openPtcDirMenuItem; private MenuItem _openPtcDirMenuItem;
private MenuItem _openShaderCacheDirMenuItem; private MenuItem _openShaderCacheDirMenuItem;
private MenuItem _createShortcutMenuItem; private MenuItem _createShortcutMenuItem;
private MenuItem _trimXCIMenuItem;
private void InitializeComponent() private void InitializeComponent()
{ {
@ -198,6 +199,15 @@ namespace Ryujinx.UI.Widgets
}; };
_createShortcutMenuItem.Activated += CreateShortcut_Clicked; _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(); ShowComponent();
} }
@ -224,6 +234,8 @@ namespace Ryujinx.UI.Widgets
Add(_openTitleModDirMenuItem); Add(_openTitleModDirMenuItem);
Add(_openTitleSdModDirMenuItem); Add(_openTitleSdModDirMenuItem);
Add(new SeparatorMenuItem()); Add(new SeparatorMenuItem());
Add(_trimXCIMenuItem);
Add(new SeparatorMenuItem());
Add(_manageCacheMenuItem); Add(_manageCacheMenuItem);
Add(_extractMenuItem); Add(_extractMenuItem);

View file

@ -13,6 +13,7 @@ using LibHac.Tools.FsSystem.NcaUtils;
using Ryujinx.Common; using Ryujinx.Common;
using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.HLE.HOS.Services.Account.Acc;
@ -75,6 +76,7 @@ namespace Ryujinx.UI.Widgets
_extractLogoMenuItem.Sensitive = hasNca; _extractLogoMenuItem.Sensitive = hasNca;
_createShortcutMenuItem.Sensitive = !ReleaseInformation.IsFlatHubBuild; _createShortcutMenuItem.Sensitive = !ReleaseInformation.IsFlatHubBuild;
_trimXCIMenuItem.Sensitive = _applicationData != null && Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(_applicationData.Path, new XCIFileTrimmerLog(_parent));
PopupAtPointer(null); 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); byte[] appIcon = new ApplicationLibrary(_virtualFileSystem, checkLevel).GetApplicationIcon(_applicationData.Path, ConfigurationState.Instance.System.Language, _applicationData.Id);
ShortcutHelper.CreateAppShortcut(_applicationData.Path, _applicationData.Name, _applicationData.IdString, appIcon); 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();
}
}
}
} }
} }

View file

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

View file

@ -13,6 +13,7 @@ namespace Ryujinx.HLE.Generators
var syntaxReceiver = (ServiceSyntaxReceiver)context.SyntaxReceiver; var syntaxReceiver = (ServiceSyntaxReceiver)context.SyntaxReceiver;
CodeGenerator generator = new CodeGenerator(); CodeGenerator generator = new CodeGenerator();
generator.AppendLine("#nullable enable");
generator.AppendLine("using System;"); generator.AppendLine("using System;");
generator.EnterScope($"namespace Ryujinx.HLE.HOS.Services.Sm"); generator.EnterScope($"namespace Ryujinx.HLE.HOS.Services.Sm");
generator.EnterScope($"partial class IUserInterface"); generator.EnterScope($"partial class IUserInterface");
@ -58,6 +59,7 @@ namespace Ryujinx.HLE.Generators
generator.LeaveScope(); generator.LeaveScope();
generator.LeaveScope(); generator.LeaveScope();
generator.AppendLine("#nullable disable");
context.AddSource($"IUserInterface.g.cs", generator.ToString()); context.AddSource($"IUserInterface.g.cs", generator.ToString());
} }

View file

@ -82,8 +82,11 @@
"GameListContextMenuOpenModsDirectoryToolTip": "Opens the directory which contains Application's Mods", "GameListContextMenuOpenModsDirectoryToolTip": "Opens the directory which contains Application's Mods",
"GameListContextMenuOpenSdModsDirectory": "Open Atmosphere Mods Directory", "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.", "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", "StatusBarGamesLoaded": "{0}/{1} Games Loaded",
"StatusBarSystemVersion": "System Version: {0}", "StatusBarSystemVersion": "System Version: {0}",
"StatusBarXCIFileTrimming": "Trimming XCI File '{0}'",
"LinuxVmMaxMapCountDialogTitle": "Low limit for memory mappings detected", "LinuxVmMaxMapCountDialogTitle": "Low limit for memory mappings detected",
"LinuxVmMaxMapCountDialogTextPrimary": "Would you like to increase the value of vm.max_map_count to {0}", "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.", "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", "SelectDlcDialogTitle": "Select DLC files",
"SelectUpdateDialogTitle": "Select update files", "SelectUpdateDialogTitle": "Select update files",
"SelectModDialogTitle": "Select mod directory", "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", "UserProfileWindowTitle": "User Profiles Manager",
"CheatWindowTitle": "Cheats Manager", "CheatWindowTitle": "Cheats Manager",
"DlcWindowTitle": "Manage Downloadable Content for {0} ({1})", "DlcWindowTitle": "Manage Downloadable Content for {0} ({1})",
@ -714,6 +727,7 @@
"DlcWindowHeading": "{0} Downloadable Content(s)", "DlcWindowHeading": "{0} Downloadable Content(s)",
"ModWindowHeading": "{0} Mod(s)", "ModWindowHeading": "{0} Mod(s)",
"UserProfilesEditProfile": "Edit Selected", "UserProfilesEditProfile": "Edit Selected",
"Continue": "Continue",
"Cancel": "Cancel", "Cancel": "Cancel",
"Save": "Save", "Save": "Save",
"Discard": "Discard", "Discard": "Discard",

View file

@ -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);
});
}
}
}

View file

@ -59,6 +59,12 @@
Click="OpenSdModsDirectory_Click" Click="OpenSdModsDirectory_Click"
Header="{locale:Locale GameListContextMenuOpenSdModsDirectory}" Header="{locale:Locale GameListContextMenuOpenSdModsDirectory}"
ToolTip.Tip="{locale:Locale GameListContextMenuOpenSdModsDirectoryToolTip}" /> ToolTip.Tip="{locale:Locale GameListContextMenuOpenSdModsDirectoryToolTip}" />
<Separator />
<MenuItem
Click="TrimXCI_Click"
Header="{locale:Locale GameListContextMenuTrimXCI}"
IsEnabled="{Binding TrimXCIEnabled}"
ToolTip.Tip="{locale:Locale GameListContextMenuTrimXCIToolTip}" />
<Separator /> <Separator />
<MenuItem Header="{locale:Locale GameListContextMenuCacheManagement}"> <MenuItem Header="{locale:Locale GameListContextMenuCacheManagement}">
<MenuItem <MenuItem

View file

@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LibHac.Fs; using LibHac.Fs;
using LibHac.Tools.FsSystem.NcaUtils; using LibHac.Tools.FsSystem.NcaUtils;
using Ryujinx.Ava.Common; using Ryujinx.Ava.Common;
@ -15,6 +16,8 @@ using Ryujinx.UI.Common.Helper;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Path = System.IO.Path; using Path = System.IO.Path;
namespace Ryujinx.Ava.UI.Controls namespace Ryujinx.Ava.UI.Controls
@ -355,5 +358,15 @@ namespace Ryujinx.Ava.UI.Controls
await viewModel.LoadApplication(viewModel.SelectedApplication); await viewModel.LoadApplication(viewModel.SelectedApplication);
} }
} }
public async void TrimXCI_Click(object sender, RoutedEventArgs args)
{
var viewModel = (sender as MenuItem)?.DataContext as MainWindowViewModel;
if (viewModel?.SelectedApplication != null)
{
await viewModel.TrimXCIFile(viewModel.SelectedApplication.Path);
}
}
} }
} }

View file

@ -20,6 +20,7 @@ using Ryujinx.Ava.UI.Windows;
using Ryujinx.Common; using Ryujinx.Common;
using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
using Ryujinx.Cpu; using Ryujinx.Cpu;
using Ryujinx.HLE; using Ryujinx.HLE;
using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.FileSystem;
@ -36,6 +37,7 @@ using SkiaSharp;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
@ -78,6 +80,8 @@ namespace Ryujinx.Ava.UI.ViewModels
private bool _isAppletMenuActive; private bool _isAppletMenuActive;
private int _statusBarProgressMaximum; private int _statusBarProgressMaximum;
private int _statusBarProgressValue; private int _statusBarProgressValue;
private string _statusBarProgressStatusText;
private bool _statusBarProgressStatusVisible;
private bool _isPaused; private bool _isPaused;
private bool _showContent = true; private bool _showContent = true;
private bool _isLoadingIndeterminate = true; private bool _isLoadingIndeterminate = true;
@ -366,6 +370,8 @@ namespace Ryujinx.Ava.UI.ViewModels
public bool OpenDeviceSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0; public bool OpenDeviceSaveDirectoryEnabled => !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 OpenBcatSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;
public bool CreateShortcutEnabled => !ReleaseInformation.IsFlatHubBuild; 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 public string FifoStatusText
{ {
get => _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 #endregion
} }
} }

View file

@ -36,6 +36,7 @@
IsVisible="{Binding EnableNonGameRunningControls}"> IsVisible="{Binding EnableNonGameRunningControls}">
<Grid Margin="0"> <Grid Margin="0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition /> <ColumnDefinition />
@ -60,9 +61,16 @@
VerticalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding EnableNonGameRunningControls}" IsVisible="{Binding EnableNonGameRunningControls}"
Text="{locale:Locale StatusBarGamesLoaded}" /> Text="{locale:Locale StatusBarGamesLoaded}" />
<TextBlock
Name="StatusBarProgressStatus"
Grid.Column="2"
Margin="10,0,5,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusBarProgressStatusVisible}"
Text="{Binding StatusBarProgressStatusText}" />
<ProgressBar <ProgressBar
Name="LoadProgressBar" Name="LoadProgressBar"
Grid.Column="2" Grid.Column="3"
Height="6" Height="6"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource SystemAccentColorLight2}" Foreground="{DynamicResource SystemAccentColorLight2}"