diff --git a/src/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs b/src/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs index f47c663ed..651f92986 100644 --- a/src/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs +++ b/src/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs @@ -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(), + 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(), + 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(), - 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(), - 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) diff --git a/src/Ryujinx.HLE/HOS/Services/Caps/IScreenShotApplicationService.cs b/src/Ryujinx.HLE/HOS/Services/Caps/IScreenShotApplicationService.cs index 0723b57cc..2ccb7c598 100644 --- a/src/Ryujinx.HLE/HOS/Services/Caps/IScreenShotApplicationService.cs +++ b/src/Ryujinx.HLE/HOS/Services/Caps/IScreenShotApplicationService.cs @@ -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(); diff --git a/src/Ryujinx.Tests/HLE/CaptureManagerTests.cs b/src/Ryujinx.Tests/HLE/CaptureManagerTests.cs new file mode 100644 index 000000000..8dd2b070f --- /dev/null +++ b/src/Ryujinx.Tests/HLE/CaptureManagerTests.cs @@ -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); + } + } + } + } +}