mirror of
https://git.ryujinx.app/ryubing/ryujinx.git
synced 2026-05-31 01:19:16 +00:00
Compare commits
5 Commits
8065dec744
...
Canary-1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49891ba7af | ||
|
|
89ea41ef84 | ||
|
|
f8167eb625 | ||
|
|
bab160d650 | ||
|
|
e8cc252d9a |
@@ -3,11 +3,11 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="11.3.14" />
|
||||
<PackageVersion Include="Avalonia" Version="11.3.15" />
|
||||
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.14" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.14" />
|
||||
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.14" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.15" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.15" />
|
||||
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.15" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.48.0" />
|
||||
<PackageVersion Include="Svg.Controls.Avalonia" Version="11.3.9.5" />
|
||||
<PackageVersion Include="Svg.Controls.Skia.Avalonia" Version="11.3.9.5" />
|
||||
|
||||
@@ -176,9 +176,7 @@ namespace Ryujinx.Graphics.Vulkan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This can somehow become -1.
|
||||
// Logger.Info?.PrintMsg(LogClass.Gpu, $"_referenceCount: {_referenceCount}");
|
||||
|
||||
Debug.Assert(_referenceCount >= 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Common.Memory;
|
||||
using Ryujinx.HLE.HOS.Services.Caps.Types;
|
||||
using SkiaSharp;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
@@ -9,16 +11,20 @@ using System.Security.Cryptography;
|
||||
|
||||
namespace Ryujinx.HLE.HOS.Services.Caps
|
||||
{
|
||||
class CaptureManager
|
||||
internal class CaptureManager
|
||||
{
|
||||
private readonly string _sdCardPath;
|
||||
public CaptureManager(Switch device)
|
||||
{
|
||||
_ = device;
|
||||
}
|
||||
private readonly string _sdCardPath = FileSystem.VirtualFileSystem.GetSdCardPath();
|
||||
|
||||
private uint _shimLibraryVersion;
|
||||
|
||||
public CaptureManager(Switch device)
|
||||
{
|
||||
_sdCardPath = FileSystem.VirtualFileSystem.GetSdCardPath();
|
||||
}
|
||||
private const int ScreenshotWidth = 1280;
|
||||
private const int ScreenshotHeight = 720;
|
||||
private const int ScreenshotBytesPerPixel = 4;
|
||||
private const int ScreenshotDataSize = ScreenshotWidth * ScreenshotHeight * ScreenshotBytesPerPixel; // 0x384000
|
||||
|
||||
public ResultCode SetShimLibraryVersion(ServiceCtx context)
|
||||
{
|
||||
@@ -53,84 +59,94 @@ namespace Ryujinx.HLE.HOS.Services.Caps
|
||||
return resultCode;
|
||||
}
|
||||
|
||||
public ResultCode SaveScreenShot(byte[] screenshotData, ulong appletResourceUserId, ulong titleId, out ApplicationAlbumEntry applicationAlbumEntry)
|
||||
public ResultCode SaveScreenShot(
|
||||
byte[] screenshotData,
|
||||
ulong appletResourceUserId,
|
||||
ulong titleId,
|
||||
out ApplicationAlbumEntry applicationAlbumEntry)
|
||||
{
|
||||
Logger.Stub?.PrintStub(LogClass.ServiceCaps, new
|
||||
{
|
||||
appletResourceUserId,
|
||||
titleId,
|
||||
screenshotDataLength = screenshotData?.Length ?? 0,
|
||||
});
|
||||
|
||||
applicationAlbumEntry = default;
|
||||
|
||||
if (screenshotData.Length == 0)
|
||||
if (screenshotData == null || screenshotData.Length == 0)
|
||||
{
|
||||
return ResultCode.NullInputBuffer;
|
||||
}
|
||||
|
||||
/*
|
||||
// NOTE: On our current implementation, appletResourceUserId starts at 0, disable it for now.
|
||||
if (appletResourceUserId == 0)
|
||||
if (screenshotData.Length < ScreenshotDataSize)
|
||||
{
|
||||
Logger.Warning?.PrintMsg(
|
||||
LogClass.ServiceCaps,
|
||||
$"Invalid screenshot buffer size 0x{screenshotData.Length:X}; expected at least 0x{ScreenshotDataSize:X}.");
|
||||
|
||||
return ResultCode.NullInputBuffer;
|
||||
}
|
||||
|
||||
DateTime currentDateTime = DateTime.Now;
|
||||
|
||||
applicationAlbumEntry = new ApplicationAlbumEntry()
|
||||
{
|
||||
Size = (ulong)Unsafe.SizeOf<ApplicationAlbumEntry>(),
|
||||
TitleId = titleId,
|
||||
AlbumFileDateTime = new AlbumFileDateTime()
|
||||
{
|
||||
Year = (ushort)currentDateTime.Year,
|
||||
Month = (byte)currentDateTime.Month,
|
||||
Day = (byte)currentDateTime.Day,
|
||||
Hour = (byte)currentDateTime.Hour,
|
||||
Minute = (byte)currentDateTime.Minute,
|
||||
Second = (byte)currentDateTime.Second,
|
||||
UniqueId = 0,
|
||||
},
|
||||
AlbumStorage = AlbumStorage.Sd,
|
||||
ContentType = ContentType.Screenshot,
|
||||
Padding = new Array5<byte>(),
|
||||
Unknown0x1f = 1,
|
||||
};
|
||||
|
||||
// NOTE: The hex hash is a HMAC-SHA256 (first 32 bytes) using a hardcoded secret key over the titleId, we can simulate it by hashing the titleId instead.
|
||||
string hash = Convert.ToHexString(SHA256.HashData(BitConverter.GetBytes(titleId)))[..0x20];
|
||||
|
||||
string folderPath = Path.Combine(
|
||||
_sdCardPath,
|
||||
"Nintendo",
|
||||
"Album",
|
||||
currentDateTime.Year.ToString("0000", CultureInfo.InvariantCulture),
|
||||
currentDateTime.Month.ToString("00", CultureInfo.InvariantCulture),
|
||||
currentDateTime.Day.ToString("00", CultureInfo.InvariantCulture));
|
||||
|
||||
string filePath = GenerateFilePath(folderPath, applicationAlbumEntry, currentDateTime, hash);
|
||||
|
||||
_ = Directory.CreateDirectory(folderPath);
|
||||
|
||||
while (File.Exists(filePath))
|
||||
{
|
||||
applicationAlbumEntry.AlbumFileDateTime.UniqueId++;
|
||||
filePath = GenerateFilePath(folderPath, applicationAlbumEntry, currentDateTime, hash);
|
||||
}
|
||||
|
||||
using SKBitmap bitmap = new(new SKImageInfo(ScreenshotWidth, ScreenshotHeight, SKColorType.Rgba8888));
|
||||
|
||||
IntPtr pixels = bitmap.GetPixels();
|
||||
|
||||
if (pixels == IntPtr.Zero)
|
||||
{
|
||||
return ResultCode.InvalidArgument;
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
// Doesn't occur in our case.
|
||||
if (applicationAlbumEntry == null)
|
||||
{
|
||||
return ResultCode.NullOutputBuffer;
|
||||
}
|
||||
*/
|
||||
Marshal.Copy(screenshotData, 0, pixels, ScreenshotDataSize);
|
||||
|
||||
if (screenshotData.Length >= 0x384000)
|
||||
{
|
||||
DateTime currentDateTime = DateTime.Now;
|
||||
using SKData data = bitmap.Encode(SKEncodedImageFormat.Jpeg, 80);
|
||||
using FileStream file = File.OpenWrite(filePath);
|
||||
data.SaveTo(file);
|
||||
|
||||
applicationAlbumEntry = new ApplicationAlbumEntry()
|
||||
{
|
||||
Size = (ulong)Unsafe.SizeOf<ApplicationAlbumEntry>(),
|
||||
TitleId = titleId,
|
||||
AlbumFileDateTime = new AlbumFileDateTime()
|
||||
{
|
||||
Year = (ushort)currentDateTime.Year,
|
||||
Month = (byte)currentDateTime.Month,
|
||||
Day = (byte)currentDateTime.Day,
|
||||
Hour = (byte)currentDateTime.Hour,
|
||||
Minute = (byte)currentDateTime.Minute,
|
||||
Second = (byte)currentDateTime.Second,
|
||||
UniqueId = 0,
|
||||
},
|
||||
AlbumStorage = AlbumStorage.Sd,
|
||||
ContentType = ContentType.Screenshot,
|
||||
Padding = new Array5<byte>(),
|
||||
Unknown0x1f = 1,
|
||||
};
|
||||
|
||||
// NOTE: The hex hash is a HMAC-SHA256 (first 32 bytes) using a hardcoded secret key over the titleId, we can simulate it by hashing the titleId instead.
|
||||
string hash = Convert.ToHexString(SHA256.HashData(BitConverter.GetBytes(titleId)))[..0x20];
|
||||
string folderPath = Path.Combine(_sdCardPath, "Nintendo", "Album", currentDateTime.Year.ToString("00"), currentDateTime.Month.ToString("00"), currentDateTime.Day.ToString("00"));
|
||||
string filePath = GenerateFilePath(folderPath, applicationAlbumEntry, currentDateTime, hash);
|
||||
|
||||
// TODO: Handle that using the FS service implementation and return the right error code instead of throwing exceptions.
|
||||
Directory.CreateDirectory(folderPath);
|
||||
|
||||
while (File.Exists(filePath))
|
||||
{
|
||||
applicationAlbumEntry.AlbumFileDateTime.UniqueId++;
|
||||
|
||||
filePath = GenerateFilePath(folderPath, applicationAlbumEntry, currentDateTime, hash);
|
||||
}
|
||||
|
||||
// NOTE: The saved JPEG file doesn't have the limitation in the extra EXIF data.
|
||||
using SKBitmap bitmap = new(new SKImageInfo(1280, 720, SKColorType.Rgba8888, SKAlphaType.Premul));
|
||||
int dataLen = screenshotData.Length > bitmap.ByteCount ? bitmap.ByteCount : screenshotData.Length;
|
||||
|
||||
Marshal.Copy(screenshotData, 0, bitmap.GetPixels(), dataLen);
|
||||
|
||||
using SKData data = bitmap.Encode(SKEncodedImageFormat.Jpeg, 80);
|
||||
using FileStream file = File.OpenWrite(filePath);
|
||||
data.SaveTo(file);
|
||||
|
||||
return ResultCode.Success;
|
||||
}
|
||||
|
||||
return ResultCode.NullInputBuffer;
|
||||
return ResultCode.Success;
|
||||
}
|
||||
|
||||
private string GenerateFilePath(string folderPath, ApplicationAlbumEntry applicationAlbumEntry, DateTime currentDateTime, string hash)
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Ryujinx.Common;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.HLE.HOS.Services.Caps.Types;
|
||||
|
||||
namespace Ryujinx.HLE.HOS.Services.Caps
|
||||
{
|
||||
[Service("caps:su")] // 6.0.0+
|
||||
class IScreenShotApplicationService : IpcService
|
||||
internal class IScreenShotApplicationService : IpcService
|
||||
{
|
||||
public IScreenShotApplicationService(ServiceCtx context) { }
|
||||
private const ulong ScreenshotDataSize = 0x384000;
|
||||
private const ulong ApplicationDataSize = 0x404;
|
||||
|
||||
public IScreenShotApplicationService(ServiceCtx context)
|
||||
{
|
||||
_ = context;
|
||||
}
|
||||
[CommandCmif(32)] // 7.0.0+
|
||||
// SetShimLibraryVersion(pid, u64, nn::applet::AppletResourceUserId)
|
||||
public ResultCode SetShimLibraryVersion(ServiceCtx context)
|
||||
@@ -33,6 +39,15 @@ namespace Ryujinx.HLE.HOS.Services.Caps
|
||||
ulong screenshotDataPosition = context.Request.SendBuff[0].Position;
|
||||
ulong screenshotDataSize = context.Request.SendBuff[0].Size;
|
||||
|
||||
if (screenshotDataSize < ScreenshotDataSize)
|
||||
{
|
||||
Logger.Warning?.PrintMsg(
|
||||
LogClass.ServiceCaps,
|
||||
$"Invalid screenshot buffer size 0x{screenshotDataSize:X}; expected at least 0x{ScreenshotDataSize:X}.");
|
||||
|
||||
return ResultCode.NullInputBuffer;
|
||||
}
|
||||
|
||||
byte[] screenshotData = context.Memory.GetSpan(screenshotDataPosition, (int)screenshotDataSize, true).ToArray();
|
||||
|
||||
ResultCode resultCode = context.Device.System.CaptureManager.SaveScreenShot(screenshotData, appletResourceUserId, context.Device.Processes.ActiveApplication.ProgramId, out ApplicationAlbumEntry applicationAlbumEntry);
|
||||
@@ -60,6 +75,24 @@ namespace Ryujinx.HLE.HOS.Services.Caps
|
||||
ulong screenshotDataPosition = context.Request.SendBuff[1].Position;
|
||||
ulong screenshotDataSize = context.Request.SendBuff[1].Size;
|
||||
|
||||
if (applicationDataSize != ApplicationDataSize)
|
||||
{
|
||||
Logger.Warning?.PrintMsg(
|
||||
LogClass.ServiceCaps,
|
||||
$"Invalid ApplicationData size 0x{applicationDataSize:X}; expected 0x{ApplicationDataSize:X}.");
|
||||
|
||||
return ResultCode.InvalidArgument;
|
||||
}
|
||||
|
||||
if (screenshotDataSize < ScreenshotDataSize)
|
||||
{
|
||||
Logger.Warning?.PrintMsg(
|
||||
LogClass.ServiceCaps,
|
||||
$"Invalid screenshot buffer size 0x{screenshotDataSize:X}; expected at least 0x{ScreenshotDataSize:X}.");
|
||||
|
||||
return ResultCode.NullInputBuffer;
|
||||
}
|
||||
|
||||
// TODO: Parse the application data: At 0x00 it's UserData (Size of 0x400), at 0x404 it's a uint UserDataSize (Always empty for now).
|
||||
_ = context.Memory.GetSpan(applicationDataPosition, (int)applicationDataSize).ToArray();
|
||||
|
||||
@@ -88,6 +121,23 @@ namespace Ryujinx.HLE.HOS.Services.Caps
|
||||
ulong screenshotDataPosition = context.Request.SendBuff[1].Position;
|
||||
ulong screenshotDataSize = context.Request.SendBuff[1].Size;
|
||||
|
||||
if (userIdListSize != 0x88)
|
||||
{
|
||||
Logger.Warning?.PrintMsg(
|
||||
LogClass.ServiceCaps,
|
||||
$"Invalid UserIdList size 0x{userIdListSize:X}; expected 0x88.");
|
||||
return ResultCode.InvalidArgument;
|
||||
}
|
||||
|
||||
if (screenshotDataSize < ScreenshotDataSize)
|
||||
{
|
||||
Logger.Warning?.PrintMsg(
|
||||
LogClass.ServiceCaps,
|
||||
$"Invalid screenshot buffer size 0x{screenshotDataSize:X}; expected at least 0x{ScreenshotDataSize:X}.");
|
||||
|
||||
return ResultCode.NullInputBuffer;
|
||||
}
|
||||
|
||||
// TODO: Parse the UserIdList.
|
||||
_ = context.Memory.GetSpan(userIdListPosition, (int)userIdListSize).ToArray();
|
||||
|
||||
|
||||
@@ -81,8 +81,10 @@ namespace Ryujinx.HLE.HOS.Services.Mii
|
||||
return ResultCode.Success;
|
||||
}
|
||||
|
||||
public ResultCode UpdateLatest<T>(DatabaseSessionMetadata metadata, IStoredData<T> oldMiiData, SourceFlag flag, IStoredData<T> newMiiData) where T : unmanaged
|
||||
public ResultCode UpdateLatest<T>(DatabaseSessionMetadata metadata, T oldMiiData, SourceFlag flag, out T newMiiData) where T : unmanaged, IStoredData<T>
|
||||
{
|
||||
newMiiData = default;
|
||||
|
||||
if (!flag.HasFlag(SourceFlag.Database))
|
||||
{
|
||||
return ResultCode.NotFound;
|
||||
@@ -106,7 +108,7 @@ namespace Ryujinx.HLE.HOS.Services.Mii
|
||||
|
||||
newMiiData.SetFromStoreData(storeData);
|
||||
|
||||
if (oldMiiData == newMiiData)
|
||||
if (oldMiiData.Equals(newMiiData))
|
||||
{
|
||||
return ResultCode.NotUpdated;
|
||||
}
|
||||
@@ -286,6 +288,18 @@ namespace Ryujinx.HLE.HOS.Services.Mii
|
||||
return result;
|
||||
}
|
||||
|
||||
public ResultCode Append(DatabaseSessionMetadata metadata, CharInfo charInfo)
|
||||
{
|
||||
ResultCode result = _miiDatabase.Append(metadata, _utilityImpl, charInfo);
|
||||
|
||||
if (result == ResultCode.Success)
|
||||
{
|
||||
result = _miiDatabase.SaveDatabase();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ResultCode ConvertCharInfoToCoreData(CharInfo charInfo, out CoreData coreData)
|
||||
{
|
||||
coreData = new CoreData();
|
||||
|
||||
@@ -449,6 +449,32 @@ namespace Ryujinx.HLE.HOS.Services.Mii
|
||||
return ResultCode.Success;
|
||||
}
|
||||
|
||||
public ResultCode Append(DatabaseSessionMetadata metadata, UtilityImpl utilityImpl, CharInfo charInfo)
|
||||
{
|
||||
if (!charInfo.IsValid())
|
||||
{
|
||||
return ResultCode.InvalidCharInfo;
|
||||
}
|
||||
|
||||
if (charInfo.Type == 1)
|
||||
{
|
||||
return ResultCode.InvalidOperationOnSpecialMii;
|
||||
}
|
||||
|
||||
CoreData coreData = new();
|
||||
coreData.SetFromCharInfo(charInfo);
|
||||
|
||||
StoreData storeData;
|
||||
|
||||
do
|
||||
{
|
||||
storeData = StoreData.BuildFromCoreData(utilityImpl, coreData);
|
||||
}
|
||||
while (_database.GetIndexByCreatorId(out _, storeData.CreateId));
|
||||
|
||||
return AddOrReplace(metadata, storeData);
|
||||
}
|
||||
|
||||
public ResultCode Delete(DatabaseSessionMetadata metadata, CreateId createId)
|
||||
{
|
||||
if (!_database.GetIndexByCreatorId(out int index, createId))
|
||||
|
||||
@@ -54,9 +54,7 @@ namespace Ryujinx.HLE.HOS.Services.Mii.StaticService
|
||||
|
||||
protected override ResultCode UpdateLatest(CharInfo oldCharInfo, SourceFlag flag, out CharInfo newCharInfo)
|
||||
{
|
||||
newCharInfo = default;
|
||||
|
||||
return _database.UpdateLatest(_metadata, oldCharInfo, flag, newCharInfo);
|
||||
return _database.UpdateLatest(_metadata, oldCharInfo, flag, out newCharInfo);
|
||||
}
|
||||
|
||||
protected override ResultCode BuildRandom(Age age, Gender gender, Race race, out CharInfo charInfo)
|
||||
@@ -113,14 +111,14 @@ namespace Ryujinx.HLE.HOS.Services.Mii.StaticService
|
||||
|
||||
protected override ResultCode UpdateLatest1(StoreData oldStoreData, SourceFlag flag, out StoreData newStoreData)
|
||||
{
|
||||
newStoreData = default;
|
||||
|
||||
if (!_isSystem)
|
||||
{
|
||||
newStoreData = default;
|
||||
|
||||
return ResultCode.PermissionDenied;
|
||||
}
|
||||
|
||||
return _database.UpdateLatest(_metadata, oldStoreData, flag, newStoreData);
|
||||
return _database.UpdateLatest(_metadata, oldStoreData, flag, out newStoreData);
|
||||
}
|
||||
|
||||
protected override ResultCode FindIndex(CreateId createId, bool isSpecial, out int index)
|
||||
@@ -262,5 +260,10 @@ namespace Ryujinx.HLE.HOS.Services.Mii.StaticService
|
||||
{
|
||||
return _database.ConvertCharInfoToCoreData(charInfo, out coreData);
|
||||
}
|
||||
|
||||
protected override ResultCode Append(CharInfo charInfo)
|
||||
{
|
||||
return _database.Append(_metadata, charInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +340,15 @@ namespace Ryujinx.HLE.HOS.Services.Mii.StaticService
|
||||
return result;
|
||||
}
|
||||
|
||||
[CommandCmif(26)] // 10.2.0+
|
||||
// Append(nn::mii::CharInfo char_info)
|
||||
public ResultCode Append(ServiceCtx context)
|
||||
{
|
||||
CharInfo charInfo = context.RequestData.ReadStruct<CharInfo>();
|
||||
|
||||
return Append(charInfo);
|
||||
}
|
||||
|
||||
private Span<byte> CreateByteSpanFromBuffer(ServiceCtx context, IpcBuffDesc ipcBuff, bool isOutput)
|
||||
{
|
||||
byte[] rawData;
|
||||
@@ -421,5 +430,7 @@ namespace Ryujinx.HLE.HOS.Services.Mii.StaticService
|
||||
protected abstract ResultCode ConvertCoreDataToCharInfo(CoreData coreData, out CharInfo charInfo);
|
||||
|
||||
protected abstract ResultCode ConvertCharInfoToCoreData(CharInfo charInfo, out CoreData coreData);
|
||||
|
||||
protected abstract ResultCode Append(CharInfo charInfo);
|
||||
}
|
||||
}
|
||||
|
||||
187
src/Ryujinx.Tests/HLE/CaptureManagerTests.cs
Normal file
187
src/Ryujinx.Tests/HLE/CaptureManagerTests.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using NUnit.Framework;
|
||||
using Ryujinx.HLE.HOS.Services.Caps;
|
||||
using Ryujinx.HLE.HOS.Services.Caps.Types;
|
||||
using SkiaSharp;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Ryujinx.Tests.HLE
|
||||
{
|
||||
public class CaptureManagerTests
|
||||
{
|
||||
private const int ScreenshotWidth = 1280;
|
||||
private const int ScreenshotHeight = 720;
|
||||
private const int BytesPerPixel = 4;
|
||||
|
||||
private const int ScreenshotDataSize = ScreenshotWidth * ScreenshotHeight * BytesPerPixel; // 0x384000
|
||||
private const int PaddedScreenshotDataSize = ScreenshotWidth * 768 * BytesPerPixel; // 0x3C0000
|
||||
|
||||
[Test]
|
||||
public void SaveScreenShotRejectsBufferSmallerThan720p()
|
||||
{
|
||||
using TempSdCard tempSdCard = new();
|
||||
|
||||
CaptureManager captureManager = CreateCaptureManager(tempSdCard.Path);
|
||||
byte[] screenshotData = new byte[ScreenshotDataSize - 1];
|
||||
|
||||
ResultCode result = captureManager.SaveScreenShot(
|
||||
screenshotData,
|
||||
appletResourceUserId: 0,
|
||||
titleId: 0x0100000000001000,
|
||||
out _);
|
||||
|
||||
Assert.That(result, Is.EqualTo(ResultCode.NullInputBuffer));
|
||||
Assert.That(Directory.Exists(Path.Combine(tempSdCard.Path, "Nintendo", "Album")), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveScreenShotAcceptsExact720pBuffer()
|
||||
{
|
||||
using TempSdCard tempSdCard = new();
|
||||
|
||||
CaptureManager captureManager = CreateCaptureManager(tempSdCard.Path);
|
||||
byte[] screenshotData = CreateTestPattern(ScreenshotDataSize);
|
||||
|
||||
ResultCode result = captureManager.SaveScreenShot(
|
||||
screenshotData,
|
||||
appletResourceUserId: 0,
|
||||
titleId: 0x0100000000001000,
|
||||
out ApplicationAlbumEntry applicationAlbumEntry);
|
||||
|
||||
string filePath = GetSingleAlbumFile(tempSdCard.Path);
|
||||
|
||||
using SKBitmap bitmap = SKBitmap.Decode(filePath);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result, Is.EqualTo(ResultCode.Success));
|
||||
Assert.That(bitmap.Width, Is.EqualTo(ScreenshotWidth));
|
||||
Assert.That(bitmap.Height, Is.EqualTo(ScreenshotHeight));
|
||||
Assert.That(applicationAlbumEntry.TitleId, Is.EqualTo(0x0100000000001000));
|
||||
Assert.That(applicationAlbumEntry.AlbumStorage, Is.EqualTo(AlbumStorage.Sd));
|
||||
Assert.That(applicationAlbumEntry.ContentType, Is.EqualTo(ContentType.Screenshot));
|
||||
Assert.That(applicationAlbumEntry.Unknown0x1f, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveScreenShotAcceptsBufferLargerThan720p()
|
||||
{
|
||||
using TempSdCard tempSdCard = new();
|
||||
|
||||
CaptureManager captureManager = CreateCaptureManager(tempSdCard.Path);
|
||||
byte[] screenshotData = CreateTestPattern(PaddedScreenshotDataSize);
|
||||
|
||||
ResultCode result = captureManager.SaveScreenShot(
|
||||
screenshotData,
|
||||
appletResourceUserId: 0,
|
||||
titleId: 0x0100000000001000,
|
||||
out ApplicationAlbumEntry applicationAlbumEntry);
|
||||
|
||||
string filePath = GetSingleAlbumFile(tempSdCard.Path);
|
||||
|
||||
using SKBitmap bitmap = SKBitmap.Decode(filePath);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result, Is.EqualTo(ResultCode.Success));
|
||||
Assert.That(bitmap.Width, Is.EqualTo(ScreenshotWidth));
|
||||
Assert.That(bitmap.Height, Is.EqualTo(ScreenshotHeight));
|
||||
Assert.That(applicationAlbumEntry.TitleId, Is.EqualTo(0x0100000000001000));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveScreenShotCreatesUniqueFileNamesForRepeatedSaves()
|
||||
{
|
||||
using TempSdCard tempSdCard = new();
|
||||
|
||||
CaptureManager captureManager = CreateCaptureManager(tempSdCard.Path);
|
||||
byte[] screenshotData = CreateTestPattern(ScreenshotDataSize);
|
||||
|
||||
ResultCode firstResult = captureManager.SaveScreenShot(
|
||||
screenshotData,
|
||||
appletResourceUserId: 0,
|
||||
titleId: 0x0100000000001000,
|
||||
out _);
|
||||
|
||||
ResultCode secondResult = captureManager.SaveScreenShot(
|
||||
screenshotData,
|
||||
appletResourceUserId: 0,
|
||||
titleId: 0x0100000000001000,
|
||||
out _);
|
||||
|
||||
string[] files = Directory.GetFiles(
|
||||
Path.Combine(tempSdCard.Path, "Nintendo", "Album"),
|
||||
"*.jpg",
|
||||
SearchOption.AllDirectories);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(firstResult, Is.EqualTo(ResultCode.Success));
|
||||
Assert.That(secondResult, Is.EqualTo(ResultCode.Success));
|
||||
Assert.That(files, Has.Length.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
private static CaptureManager CreateCaptureManager(string sdCardPath)
|
||||
{
|
||||
CaptureManager captureManager = (CaptureManager)RuntimeHelpers.GetUninitializedObject(typeof(CaptureManager));
|
||||
|
||||
typeof(CaptureManager)
|
||||
.GetField("_sdCardPath", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(captureManager, sdCardPath);
|
||||
|
||||
return captureManager;
|
||||
}
|
||||
|
||||
private static string GetSingleAlbumFile(string sdCardPath)
|
||||
{
|
||||
string albumPath = Path.Combine(sdCardPath, "Nintendo", "Album");
|
||||
|
||||
string[] files = Directory.GetFiles(albumPath, "*.jpg", SearchOption.AllDirectories);
|
||||
|
||||
Assert.That(files, Has.Length.EqualTo(1));
|
||||
|
||||
return files.Single();
|
||||
}
|
||||
|
||||
private static byte[] CreateTestPattern(int size)
|
||||
{
|
||||
byte[] data = new byte[size];
|
||||
|
||||
int pixelCount = size / BytesPerPixel;
|
||||
|
||||
for (int i = 0; i < pixelCount; i++)
|
||||
{
|
||||
int x = i % ScreenshotWidth;
|
||||
int y = i / ScreenshotWidth;
|
||||
|
||||
data[(i * 4) + 0] = (byte)(x & 0xff);
|
||||
data[(i * 4) + 1] = (byte)(y & 0xff);
|
||||
data[(i * 4) + 2] = 0x80;
|
||||
data[(i * 4) + 3] = 0xff;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private sealed class TempSdCard : IDisposable
|
||||
{
|
||||
public string Path { get; } = System.IO.Path.Combine(
|
||||
TestContext.CurrentContext.WorkDirectory,
|
||||
"sdcard-" + Guid.NewGuid());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/Ryujinx.Tests/HLE/MiiDatabaseTests.cs
Normal file
122
src/Ryujinx.Tests/HLE/MiiDatabaseTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Reflection;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
using Ryujinx.Cpu;
|
||||
using Ryujinx.HLE.HOS.Services.Mii;
|
||||
using Ryujinx.HLE.HOS.Services.Mii.StaticService;
|
||||
using Ryujinx.HLE.HOS.Services.Mii.Types;
|
||||
|
||||
namespace Ryujinx.Tests.HLE
|
||||
{
|
||||
public class MiiDatabaseTests
|
||||
{
|
||||
[Test]
|
||||
public void UpdateLatestReturnsStoredCharInfo()
|
||||
{
|
||||
DatabaseImpl database = new();
|
||||
StoreData storedData = StoreData.BuildDefault(new UtilityImpl(new TickSource(19200000)), 0);
|
||||
MiiDatabaseManager databaseManager = GetDatabaseManager(database);
|
||||
|
||||
NintendoFigurineDatabase figurineDatabase = new();
|
||||
figurineDatabase.Format();
|
||||
figurineDatabase.Add(storedData);
|
||||
SetFigurineDatabase(databaseManager, figurineDatabase);
|
||||
|
||||
TestDatabaseService service = new(database);
|
||||
|
||||
CharInfo oldCharInfo = new();
|
||||
oldCharInfo.SetFromStoreData(storedData);
|
||||
oldCharInfo.Height--;
|
||||
|
||||
ResultCode result = service.UpdateLatestForTest(oldCharInfo, SourceFlag.Database, out CharInfo newCharInfo);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result, Is.EqualTo(ResultCode.Success));
|
||||
Assert.That(newCharInfo.CreateId, Is.EqualTo(oldCharInfo.CreateId));
|
||||
Assert.That(newCharInfo.Height, Is.EqualTo(storedData.CoreData.Height));
|
||||
Assert.That(newCharInfo.IsValid(), Is.True);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AppendAddsRegularCharInfoToDatabase()
|
||||
{
|
||||
DatabaseImpl database = new();
|
||||
UtilityImpl utilityImpl = new(new TickSource(19200000));
|
||||
SetUtilityImpl(database, utilityImpl);
|
||||
MiiDatabaseManager databaseManager = GetDatabaseManager(database);
|
||||
SetFigurineDatabase(databaseManager, CreateFormattedDatabase());
|
||||
|
||||
StoreData defaultStoreData = StoreData.BuildDefault(utilityImpl, 0);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(defaultStoreData.CoreData.IsValid(), Is.True);
|
||||
Assert.That(defaultStoreData.IsValidDataCrc(), Is.True);
|
||||
Assert.That(defaultStoreData.IsValidDeviceCrc(), Is.True);
|
||||
Assert.That(defaultStoreData.IsValid(), Is.True);
|
||||
});
|
||||
|
||||
CharInfo charInfo = new();
|
||||
charInfo.SetFromStoreData(defaultStoreData);
|
||||
|
||||
DatabaseSessionMetadata metadata = database.CreateSessionMetadata(new SpecialMiiKeyCode());
|
||||
|
||||
ResultCode result = databaseManager.Append(metadata, utilityImpl, charInfo);
|
||||
|
||||
int count = databaseManager.GetCount(metadata);
|
||||
databaseManager.Get(metadata, 0, out StoreData storedData);
|
||||
|
||||
CoreData expectedCoreData = new();
|
||||
expectedCoreData.SetFromCharInfo(charInfo);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result, Is.EqualTo(ResultCode.Success));
|
||||
Assert.That(count, Is.EqualTo(1));
|
||||
Assert.That(storedData.IsValid(), Is.True);
|
||||
Assert.That(storedData.CreateId, Is.Not.EqualTo(charInfo.CreateId));
|
||||
Assert.That(storedData.CoreData, Is.EqualTo(expectedCoreData));
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestDatabaseService(DatabaseImpl database) : DatabaseServiceImpl(database, true, new SpecialMiiKeyCode())
|
||||
{
|
||||
public ResultCode UpdateLatestForTest(CharInfo oldCharInfo, SourceFlag flag, out CharInfo newCharInfo)
|
||||
{
|
||||
return UpdateLatest(oldCharInfo, flag, out newCharInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private static MiiDatabaseManager GetDatabaseManager(DatabaseImpl database)
|
||||
{
|
||||
return (MiiDatabaseManager)typeof(DatabaseImpl)
|
||||
.GetField("_miiDatabase", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(database);
|
||||
}
|
||||
|
||||
private static void SetFigurineDatabase(MiiDatabaseManager databaseManager, NintendoFigurineDatabase figurineDatabase)
|
||||
{
|
||||
typeof(MiiDatabaseManager)
|
||||
.GetField("_database", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(databaseManager, figurineDatabase);
|
||||
}
|
||||
|
||||
private static NintendoFigurineDatabase CreateFormattedDatabase()
|
||||
{
|
||||
NintendoFigurineDatabase figurineDatabase = new();
|
||||
figurineDatabase.Format();
|
||||
|
||||
return figurineDatabase;
|
||||
}
|
||||
|
||||
private static void SetUtilityImpl(DatabaseImpl database, UtilityImpl utilityImpl)
|
||||
{
|
||||
typeof(DatabaseImpl)
|
||||
.GetField("_utilityImpl", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(database, utilityImpl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -622,15 +622,15 @@ namespace Ryujinx.Ava.Systems
|
||||
// If the GPU has no work and is cancelled, we need to handle that as well.
|
||||
|
||||
WaitHandle.WaitAny(new[] { _gpuDoneEvent, _gpuCancellationTokenSource.Token.WaitHandle });
|
||||
_gpuCancellationTokenSource.Dispose();
|
||||
|
||||
// Waiting for work to be finished before we dispose.
|
||||
|
||||
if (_renderingStarted)
|
||||
{
|
||||
// Waiting for work to be finished before we dispose.
|
||||
Device.Gpu.WaitUntilGpuReady();
|
||||
}
|
||||
|
||||
_gpuDoneEvent.Dispose();
|
||||
_gpuCancellationTokenSource.Dispose();
|
||||
|
||||
DisposeGpu();
|
||||
AppExit?.Invoke(this, EventArgs.Empty);
|
||||
@@ -1095,51 +1095,56 @@ namespace Ryujinx.Ava.Systems
|
||||
|
||||
Device.Gpu.Renderer.RunLoop(() =>
|
||||
{
|
||||
Device.Gpu.SetGpuThread();
|
||||
Device.Gpu.InitializeShaderCache(_gpuCancellationTokenSource.Token);
|
||||
|
||||
_renderer.Window.ChangeVSyncMode(Device.VSyncMode);
|
||||
|
||||
while (_isActive)
|
||||
try
|
||||
{
|
||||
_ticks += _chrono.ElapsedTicks;
|
||||
Device.Gpu.SetGpuThread();
|
||||
Device.Gpu.InitializeShaderCache(_gpuCancellationTokenSource.Token);
|
||||
|
||||
_chrono.Restart();
|
||||
_renderer.Window.ChangeVSyncMode(Device.VSyncMode);
|
||||
|
||||
if (Device.WaitFifo())
|
||||
while (_isActive)
|
||||
{
|
||||
Device.Statistics.RecordFifoStart();
|
||||
Device.ProcessFrame();
|
||||
Device.Statistics.RecordFifoEnd();
|
||||
}
|
||||
_ticks += _chrono.ElapsedTicks;
|
||||
|
||||
while (Device.ConsumeFrameAvailable())
|
||||
{
|
||||
if (!_renderingStarted)
|
||||
_chrono.Restart();
|
||||
|
||||
if (Device.WaitFifo())
|
||||
{
|
||||
_renderingStarted = true;
|
||||
_viewModel.SwitchToRenderer(false);
|
||||
InitStatus();
|
||||
Device.Statistics.RecordFifoStart();
|
||||
Device.ProcessFrame();
|
||||
Device.Statistics.RecordFifoEnd();
|
||||
}
|
||||
|
||||
Device.PresentFrame(() => (RendererHost.EmbeddedWindow as EmbeddedWindowOpenGL)?.SwapBuffers());
|
||||
}
|
||||
while (Device.ConsumeFrameAvailable())
|
||||
{
|
||||
if (!_renderingStarted)
|
||||
{
|
||||
_renderingStarted = true;
|
||||
_viewModel.SwitchToRenderer(false);
|
||||
InitStatus();
|
||||
}
|
||||
|
||||
if (_ticks >= _ticksPerFrame)
|
||||
{
|
||||
UpdateStatus();
|
||||
Device.PresentFrame(() =>
|
||||
(RendererHost.EmbeddedWindow as EmbeddedWindowOpenGL)?.SwapBuffers());
|
||||
}
|
||||
|
||||
if (_ticks >= _ticksPerFrame)
|
||||
{
|
||||
UpdateStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure all commands in the run loop are fully executed before leaving the loop.
|
||||
if (Device.Gpu.Renderer is ThreadedRenderer threaded)
|
||||
finally
|
||||
{
|
||||
Logger.Info?.PrintMsg(LogClass.Gpu, "Flushing threaded commands...");
|
||||
threaded.FlushThreadedCommands();
|
||||
Logger.Info?.PrintMsg(LogClass.Gpu, "Flushed!");
|
||||
// Make sure all commands in the run loop are fully executed before leaving the loop.
|
||||
if (Device.Gpu.Renderer is ThreadedRenderer threaded)
|
||||
{
|
||||
Logger.Info?.PrintMsg(LogClass.Gpu, "Flushing threaded commands...");
|
||||
threaded.FlushThreadedCommands();
|
||||
Logger.Info?.PrintMsg(LogClass.Gpu, "Flushed!");
|
||||
}
|
||||
_gpuDoneEvent.Set();
|
||||
}
|
||||
|
||||
_gpuDoneEvent.Set();
|
||||
});
|
||||
|
||||
(RendererHost.EmbeddedWindow as EmbeddedWindowOpenGL)?.MakeCurrent(true);
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
internal partial class Win32NativeInterop
|
||||
{
|
||||
internal const int GWLP_WNDPROC = -4;
|
||||
internal const int GWL_STYLE = -16;
|
||||
internal const int GWL_EXSTYLE = -20;
|
||||
|
||||
internal const uint WS_OVERLAPPEDWINDOW = 0x00CF0000;
|
||||
internal const uint WS_POPUP = 0x80000000;
|
||||
internal const uint WS_VISIBLE = 0x10000000;
|
||||
|
||||
[Flags]
|
||||
public enum ClassStyles : uint
|
||||
@@ -107,9 +113,29 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
nint hInstance,
|
||||
nint lpParam);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true, EntryPoint = "GetWindowLongPtrW")]
|
||||
public static partial nint GetWindowLongPtrW(nint hWnd, int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
public static partial nint SetWindowLongPtrW(nint hWnd, int nIndex, nint value);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool SetWindowPos(
|
||||
nint hWnd,
|
||||
nint hWndInsertAfter,
|
||||
int x,
|
||||
int y,
|
||||
int cx,
|
||||
int cy,
|
||||
uint uFlags);
|
||||
|
||||
internal const uint SWP_NOZORDER = 0x0004;
|
||||
internal const uint SWP_NOACTIVATE = 0x0010;
|
||||
internal const uint SWP_FRAMECHANGED = 0x0020;
|
||||
internal const uint SWP_NOMOVE = 0x0002;
|
||||
internal const uint SWP_NOSIZE = 0x0001;
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
public static partial ushort GetAsyncKeyState(int nVirtKey);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using System.Runtime.Versioning;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DynamicData;
|
||||
@@ -2014,7 +2015,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
|
||||
LastFullscreenToggle = Environment.TickCount64;
|
||||
|
||||
if (WindowState is not WindowState.Normal)
|
||||
if (WindowState is WindowState.FullScreen)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
Window.TitleBar.ExtendsContentIntoTitleBar = !ConfigurationState.Instance.ShowOldUI;
|
||||
@@ -2023,21 +2024,74 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
ShowMenuAndStatusBar = true;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
RestoreWindowFromFullscreen();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
Window.TitleBar.ExtendsContentIntoTitleBar = true;
|
||||
|
||||
if (IsGameRunning)
|
||||
{
|
||||
ShowMenuAndStatusBar = false;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
MakeWindowFullscreen();
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
}
|
||||
}
|
||||
|
||||
IsFullScreen = WindowState is WindowState.FullScreen;
|
||||
}
|
||||
|
||||
private nint _savedWindowStyle;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void MakeWindowFullscreen()
|
||||
{
|
||||
nint hwnd = Window.TryGetPlatformHandle()?.Handle ?? nint.Zero;
|
||||
if (hwnd == nint.Zero) return;
|
||||
|
||||
// Save current style and placement
|
||||
_savedWindowStyle = Win32NativeInterop.GetWindowLongPtrW(hwnd, Win32NativeInterop.GWL_STYLE);
|
||||
|
||||
// Remove window chrome: WS_OVERLAPPEDWINDOW -> WS_POPUP | WS_VISIBLE
|
||||
Win32NativeInterop.SetWindowLongPtrW(hwnd, Win32NativeInterop.GWL_STYLE,
|
||||
unchecked((nint)(Win32NativeInterop.WS_POPUP | Win32NativeInterop.WS_VISIBLE)));
|
||||
|
||||
Avalonia.Platform.Screen? screen = Window.Screens.ScreenFromVisual(Window);
|
||||
int w = screen?.Bounds.Width ?? 0;
|
||||
int h = screen?.Bounds.Height ?? 0;
|
||||
|
||||
Win32NativeInterop.SetWindowPos(hwnd, nint.Zero, 0, 0, w, h,
|
||||
Win32NativeInterop.SWP_NOZORDER | Win32NativeInterop.SWP_NOACTIVATE | Win32NativeInterop.SWP_FRAMECHANGED);
|
||||
|
||||
WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void RestoreWindowFromFullscreen()
|
||||
{
|
||||
nint hwnd = Window.TryGetPlatformHandle()?.Handle ?? nint.Zero;
|
||||
if (hwnd == nint.Zero) return;
|
||||
|
||||
// Restore original window style
|
||||
Win32NativeInterop.SetWindowLongPtrW(hwnd, Win32NativeInterop.GWL_STYLE, _savedWindowStyle);
|
||||
|
||||
Win32NativeInterop.SetWindowPos(hwnd, nint.Zero, 0, 0, 0, 0,
|
||||
Win32NativeInterop.SWP_NOZORDER | Win32NativeInterop.SWP_NOACTIVATE |
|
||||
Win32NativeInterop.SWP_FRAMECHANGED | Win32NativeInterop.SWP_NOMOVE | Win32NativeInterop.SWP_NOSIZE);
|
||||
|
||||
}
|
||||
|
||||
public static void SaveConfig()
|
||||
{
|
||||
ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
|
||||
|
||||
Reference in New Issue
Block a user