diff --git a/src/LibRyujinx/Android/JniExportedMethods.cs b/src/LibRyujinx/Android/JniExportedMethods.cs index b5876dd64..615d6d561 100644 --- a/src/LibRyujinx/Android/JniExportedMethods.cs +++ b/src/LibRyujinx/Android/JniExportedMethods.cs @@ -237,7 +237,7 @@ namespace LibRyujinx } [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceLoadDescriptor")] - public static JBoolean JniLoadApplicationNative(JEnvRef jEnv, JObjectLocalRef jObj, JInt descriptor, JBoolean isXci) + public static JBoolean JniLoadApplicationNative(JEnvRef jEnv, JObjectLocalRef jObj, JInt descriptor, JInt type) { Logger.Trace?.Print(LogClass.Application, "Jni Function Call"); if (SwitchDevice?.EmulationContext == null) @@ -247,7 +247,7 @@ namespace LibRyujinx var stream = OpenFile(descriptor); - return LoadApplication(stream, isXci); + return LoadApplication(stream, (FileType)(int)type); } [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_graphicsInitialize")] @@ -429,12 +429,12 @@ namespace LibRyujinx } [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceGetGameInfo")] - public static JObjectLocalRef JniGetGameInfo(JEnvRef jEnv, JObjectLocalRef jObj, JInt fileDescriptor, JBoolean isXci) + public static JObjectLocalRef JniGetGameInfo(JEnvRef jEnv, JObjectLocalRef jObj, JInt fileDescriptor, JLong extension) { Logger.Trace?.Print(LogClass.Application, "Jni Function Call"); using var stream = OpenFile(fileDescriptor); - - var info = GetGameInfo(stream, isXci); + var ext = GetStoredString(extension); + var info = GetGameInfo(stream, ext.ToLower()); return GetInfo(jEnv, info); } diff --git a/src/LibRyujinx/LibRyujinx.Device.cs b/src/LibRyujinx/LibRyujinx.Device.cs index f8dde580c..675206720 100644 --- a/src/LibRyujinx/LibRyujinx.Device.cs +++ b/src/LibRyujinx/LibRyujinx.Device.cs @@ -1,4 +1,4 @@ -using ARMeilleure.Translation; +using ARMeilleure.Translation; using LibHac.Ncm; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Common.Logging; @@ -66,10 +66,16 @@ namespace LibRyujinx return LoadApplication(path); } - public static bool LoadApplication(Stream stream, bool isXci) + public static bool LoadApplication(Stream stream, FileType type) { var emulationContext = SwitchDevice.EmulationContext; - return (isXci ? emulationContext?.LoadXci(stream) : emulationContext.LoadNsp(stream)) ?? false; + return type switch + { + FileType.None => false, + FileType.Nsp => emulationContext?.LoadNsp(stream) ?? false, + FileType.Xci => emulationContext?.LoadXci(stream) ?? false, + FileType.Nro => emulationContext?.LoadProgram(stream, true, "") ?? false, + }; } public static bool LaunchMiiEditApplet() @@ -221,5 +227,13 @@ namespace LibRyujinx Renderer = null; } } + + public enum FileType + { + None, + Nsp, + Xci, + Nro + } } } diff --git a/src/LibRyujinx/LibRyujinx.cs b/src/LibRyujinx/LibRyujinx.cs index f6d4b86b4..1e27a9230 100644 --- a/src/LibRyujinx/LibRyujinx.cs +++ b/src/LibRyujinx/LibRyujinx.cs @@ -34,6 +34,9 @@ using System.Globalization; using Ryujinx.Ui.Common.Configuration.System; using Ryujinx.Common.Logging.Targets; using System.Collections.Generic; +using LibHac.Bcat; +using Ryujinx.Ui.App.Common; +using System.Text; namespace LibRyujinx { @@ -126,10 +129,10 @@ namespace LibRyujinx using var stream = File.Open(file, FileMode.Open); - return GetGameInfo(stream, file.ToLower().EndsWith("xci")); + return GetGameInfo(stream, new FileInfo(file).Extension.Remove('.')); } - public static GameInfo? GetGameInfo(Stream gameStream, bool isXci) + public static GameInfo? GetGameInfo(Stream gameStream, string extension) { if (SwitchDevice == null) { @@ -142,7 +145,7 @@ namespace LibRyujinx FileSize = gameStream.Length * 0.000000000931, TitleName = "Unknown", TitleId = "0000000000000000", Developer = "Unknown", Version = "0", - Icon = null, + Icon = null }; const Language TitleLanguage = Language.AmericanEnglish; @@ -153,129 +156,169 @@ namespace LibRyujinx { try { - IFileSystem pfs; - - bool isExeFs = false; - - if (isXci) + if (extension == "nsp" || extension == "pfs0" || extension == "xci") { - Xci xci = new(SwitchDevice.VirtualFileSystem.KeySet, gameStream.AsStorage()); + IFileSystem pfs; - pfs = xci.OpenPartition(XciPartitionType.Secure); - } - else - { - var pfsTemp = new PartitionFileSystem(); - pfsTemp.Initialize(gameStream.AsStorage()).ThrowIfFailure(); - pfs = pfsTemp; + bool isExeFs = false; - // If the NSP doesn't have a main NCA, decrement the number of applications found and then continue to the next application. - bool hasMainNca = false; - - foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*")) + if (extension == "xci") { - if (Path.GetExtension(fileEntry.FullPath).ToLower() == ".nca") + Xci xci = new(SwitchDevice.VirtualFileSystem.KeySet, gameStream.AsStorage()); + + pfs = xci.OpenPartition(XciPartitionType.Secure); + } + else + { + var pfsTemp = new PartitionFileSystem(); + pfsTemp.Initialize(gameStream.AsStorage()).ThrowIfFailure(); + pfs = pfsTemp; + + // If the NSP doesn't have a main NCA, decrement the number of applications found and then continue to the next application. + bool hasMainNca = false; + + foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*")) { - using UniqueRef ncaFile = new(); - - pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - Nca nca = new(SwitchDevice.VirtualFileSystem.KeySet, ncaFile.Get.AsStorage()); - int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); - - // Some main NCAs don't have a data partition, so check if the partition exists before opening it - if (nca.Header.ContentType == NcaContentType.Program && !(nca.SectionExists(NcaSectionType.Data) && nca.Header.GetFsHeader(dataIndex).IsPatchSection())) + if (Path.GetExtension(fileEntry.FullPath).ToLower() == ".nca") { - hasMainNca = true; + using UniqueRef ncaFile = new(); - break; + pfs.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = new(SwitchDevice.VirtualFileSystem.KeySet, ncaFile.Get.AsStorage()); + int dataIndex = Nca.GetSectionIndexFromType(NcaSectionType.Data, NcaContentType.Program); + + // Some main NCAs don't have a data partition, so check if the partition exists before opening it + if (nca.Header.ContentType == NcaContentType.Program && !(nca.SectionExists(NcaSectionType.Data) && nca.Header.GetFsHeader(dataIndex).IsPatchSection())) + { + hasMainNca = true; + + break; + } + } + else if (Path.GetFileNameWithoutExtension(fileEntry.FullPath) == "main") + { + isExeFs = true; } } - else if (Path.GetFileNameWithoutExtension(fileEntry.FullPath) == "main") + + if (!hasMainNca && !isExeFs) { - isExeFs = true; + return null; } } - if (!hasMainNca && !isExeFs) + if (isExeFs) { - return null; - } - } + using UniqueRef npdmFile = new(); - if (isExeFs) - { - using UniqueRef npdmFile = new(); + Result result = pfs.OpenFile(ref npdmFile.Ref, "/main.npdm".ToU8Span(), OpenMode.Read); - Result result = pfs.OpenFile(ref npdmFile.Ref, "/main.npdm".ToU8Span(), OpenMode.Read); - - if (ResultFs.PathNotFound.Includes(result)) - { - Npdm npdm = new(npdmFile.Get.AsStream()); - - gameInfo.TitleName = npdm.TitleName; - gameInfo.TitleId = npdm.Aci0.TitleId.ToString("x16"); - } - } - else - { - GetControlFsAndTitleId(pfs, out IFileSystem? controlFs, out string? id); - - gameInfo.TitleId = id; - - if (controlFs == null) - { - Logger.Error?.Print(LogClass.Application, $"No control FS was returned. Unable to process game any further: {gameInfo.TitleName}"); - return null; - } - - // Check if there is an update available. - if (IsUpdateApplied(gameInfo.TitleId, out IFileSystem? updatedControlFs)) - { - // Replace the original ControlFs by the updated one. - controlFs = updatedControlFs; - } - - ReadControlData(controlFs, controlHolder.ByteSpan); - - GetGameInformation(ref controlHolder.Value, out gameInfo.TitleName, out _, out gameInfo.Developer, out gameInfo.Version); - - // Read the icon from the ControlFS and store it as a byte array - try - { - using UniqueRef icon = new(); - - controlFs?.OpenFile(ref icon.Ref, $"/icon_{TitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure(); - - using MemoryStream stream = new(); - - icon.Get.AsStream().CopyTo(stream); - gameInfo.Icon = stream.ToArray(); - } - catch (HorizonResultException) - { - foreach (DirectoryEntryEx entry in controlFs.EnumerateEntries("/", "*")) + if (ResultFs.PathNotFound.Includes(result)) { - if (entry.Name == "control.nacp") - { - continue; - } + Npdm npdm = new(npdmFile.Get.AsStream()); - using var icon = new UniqueRef(); + gameInfo.TitleName = npdm.TitleName; + gameInfo.TitleId = npdm.Aci0.TitleId.ToString("x16"); + } + } + else + { + GetControlFsAndTitleId(pfs, out IFileSystem? controlFs, out string? id); - controlFs?.OpenFile(ref icon.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + gameInfo.TitleId = id; + + if (controlFs == null) + { + Logger.Error?.Print(LogClass.Application, $"No control FS was returned. Unable to process game any further: {gameInfo.TitleName}"); + return null; + } + + // Check if there is an update available. + if (IsUpdateApplied(gameInfo.TitleId, out IFileSystem? updatedControlFs)) + { + // Replace the original ControlFs by the updated one. + controlFs = updatedControlFs; + } + + ReadControlData(controlFs, controlHolder.ByteSpan); + + GetGameInformation(ref controlHolder.Value, out gameInfo.TitleName, out _, out gameInfo.Developer, out gameInfo.Version); + + // Read the icon from the ControlFS and store it as a byte array + try + { + using UniqueRef icon = new(); + + controlFs?.OpenFile(ref icon.Ref, $"/icon_{TitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure(); using MemoryStream stream = new(); icon.Get.AsStream().CopyTo(stream); gameInfo.Icon = stream.ToArray(); - - if (gameInfo.Icon != null) + } + catch (HorizonResultException) + { + foreach (DirectoryEntryEx entry in controlFs.EnumerateEntries("/", "*")) { - break; + if (entry.Name == "control.nacp") + { + continue; + } + + using var icon = new UniqueRef(); + + controlFs?.OpenFile(ref icon.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + using MemoryStream stream = new(); + + icon.Get.AsStream().CopyTo(stream); + gameInfo.Icon = stream.ToArray(); + + if (gameInfo.Icon != null) + { + break; + } } + + } + } + } + else if (extension == "nro") + { + BinaryReader reader = new(gameStream); + + byte[] Read(long position, int size) + { + gameStream.Seek(position, SeekOrigin.Begin); + + return reader.ReadBytes(size); + } + + gameStream.Seek(24, SeekOrigin.Begin); + + int assetOffset = reader.ReadInt32(); + + if (Encoding.ASCII.GetString(Read(assetOffset, 4)) == "ASET") + { + byte[] iconSectionInfo = Read(assetOffset + 8, 0x10); + + long iconOffset = BitConverter.ToInt64(iconSectionInfo, 0); + long iconSize = BitConverter.ToInt64(iconSectionInfo, 8); + + ulong nacpOffset = reader.ReadUInt64(); + ulong nacpSize = reader.ReadUInt64(); + + // Reads and stores game icon as byte array + if (iconSize > 0) + { + gameInfo.Icon = Read(assetOffset + iconOffset, (int)iconSize); } + // Read the NACP data + Read(assetOffset + (int)nacpOffset, (int)nacpSize).AsSpan().CopyTo(controlHolder.ByteSpan); + + GetGameInformation(ref controlHolder.Value, out gameInfo.TitleName, out _, out gameInfo.Developer, out gameInfo.Version); } } } diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt index 3ec7d70b2..5836372f4 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/RyujinxNative.kt @@ -33,9 +33,9 @@ class RyujinxNative { external fun deviceGetGameFrameRate(): Double external fun deviceGetGameFrameTime(): Double external fun deviceGetGameFifo(): Double - external fun deviceGetGameInfo(fileDescriptor: Int, isXci:Boolean): GameInfo + external fun deviceGetGameInfo(fileDescriptor: Int, extension: Long): GameInfo external fun deviceGetGameInfoFromPath(path: String): GameInfo - external fun deviceLoadDescriptor(fileDescriptor: Int, isXci:Boolean): Boolean + external fun deviceLoadDescriptor(fileDescriptor: Int, gameType: Int): Boolean external fun graphicsRendererSetSize(width: Int, height: Int) external fun graphicsRendererSetVsync(enabled: Boolean) external fun graphicsRendererRunLoop() diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt index 376ae33a8..47aaa7f34 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/GameModel.kt @@ -4,10 +4,12 @@ import android.content.Context import android.os.ParcelFileDescriptor import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.extension +import org.ryujinx.android.NativeHelpers import org.ryujinx.android.RyujinxNative class GameModel(var file: DocumentFile, val context: Context) { + var type: FileType var descriptor: ParcelFileDescriptor? = null var fileName: String? var fileSize = 0.0 @@ -19,8 +21,9 @@ class GameModel(var file: DocumentFile, val context: Context) { init { fileName = file.name - var pid = open() - val gameInfo = RyujinxNative.instance.deviceGetGameInfo(pid, file.extension.contains("xci")) + val pid = open() + val ext = NativeHelpers.instance.storeStringJava(file.extension) + val gameInfo = RyujinxNative.instance.deviceGetGameInfo(pid, ext) close() fileSize = gameInfo.FileSize @@ -29,6 +32,16 @@ class GameModel(var file: DocumentFile, val context: Context) { developer = gameInfo.Developer version = gameInfo.Version icon = gameInfo.Icon + type = when { + (file.extension == "xci") -> FileType.Xci + (file.extension == "nsp") -> FileType.Nsp + (file.extension == "nro") -> FileType.Nro + else -> FileType.None + } + + if (type == FileType.Nro && (titleName.isNullOrEmpty() || titleName == "Unknown")) { + titleName = file.name + } } fun open() : Int { @@ -41,10 +54,6 @@ class GameModel(var file: DocumentFile, val context: Context) { descriptor?.close() descriptor = null } - - fun isXci() : Boolean { - return file.extension == "xci" - } } class GameInfo { @@ -55,3 +64,10 @@ class GameInfo { var Version: String? = null var Icon: String? = null } + +enum class FileType{ + None, + Nsp, + Xci, + Nro +} diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt index d1182611f..f98b66fb4 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/HomeViewModel.kt @@ -72,7 +72,7 @@ class HomeViewModel( loadedCache.clear() val files = mutableListOf() for (file in folder.search(false, DocumentFileType.FILE)) { - if (file.extension == "xci" || file.extension == "nsp") + if (file.extension == "xci" || file.extension == "nsp" || file.extension == "nro") activity.let { val item = GameModel(file, it) diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt index c5ac4a081..6bf1fca72 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/viewmodels/MainViewModel.kt @@ -165,7 +165,7 @@ class MainViewModel(val activity: MainActivity) { if (!success) return false - success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.isXci()) + success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.type.ordinal) if (!success) return false diff --git a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt index dd6993b3b..9ca2ff55a 100644 --- a/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt +++ b/src/RyujinxAndroid/app/src/main/java/org/ryujinx/android/views/HomeViews.kt @@ -57,11 +57,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import com.anggrayudi.storage.extension.launchOnUiThread +import org.ryujinx.android.R +import org.ryujinx.android.viewmodels.FileType import org.ryujinx.android.viewmodels.GameModel import org.ryujinx.android.viewmodels.HomeViewModel import org.ryujinx.android.viewmodels.QuickSettings @@ -398,7 +401,7 @@ class HomeViews { selected = null } selectedModel.value = null - } else if (gameModel.titleId.isNullOrEmpty() || gameModel.titleId != "0000000000000000") { + } else if (gameModel.titleId.isNullOrEmpty() || gameModel.titleId != "0000000000000000" || gameModel.type == FileType.Nro) { thread { showLoading.value = true val success = @@ -427,7 +430,7 @@ class HomeViews { horizontalArrangement = Arrangement.SpaceBetween ) { Row { - if (!gameModel.titleId.isNullOrEmpty() && gameModel.titleId != "0000000000000000") { + if (!gameModel.titleId.isNullOrEmpty() && (gameModel.titleId != "0000000000000000" || gameModel.type == FileType.Nro)) { if (gameModel.icon?.isNotEmpty() == true) { val pic = decoder.decode(gameModel.icon) val size = @@ -441,7 +444,9 @@ class HomeViews { .width(size.roundToInt().dp) .height(size.roundToInt().dp) ) - } else NotAvailableIcon() + } else if (gameModel.type == FileType.Nro) + NROIcon() + else NotAvailableIcon() } else NotAvailableIcon() Column { Text(text = gameModel.titleName ?: "") @@ -487,7 +492,7 @@ class HomeViews { selected = null } selectedModel.value = null - } else if (gameModel.titleId.isNullOrEmpty() || gameModel.titleId != "0000000000000000") { + } else if (gameModel.titleId.isNullOrEmpty() || gameModel.titleId != "0000000000000000" || gameModel.type == FileType.Nro) { thread { showLoading.value = true val success = @@ -510,7 +515,7 @@ class HomeViews { }) ) { Column(modifier = Modifier.padding(4.dp)) { - if (!gameModel.titleId.isNullOrEmpty() && gameModel.titleId != "0000000000000000") { + if (!gameModel.titleId.isNullOrEmpty() && (gameModel.titleId != "0000000000000000" || gameModel.type == FileType.Nro)) { if (gameModel.icon?.isNotEmpty() == true) { val pic = decoder.decode(gameModel.icon) val size = GridImageSize / Resources.getSystem().displayMetrics.density @@ -523,20 +528,24 @@ class HomeViews { .clip(RoundedCornerShape(16.dp)) .align(Alignment.CenterHorizontally) ) - } else NotAvailableIcon() + } else if (gameModel.type == FileType.Nro) + NROIcon() + else NotAvailableIcon() } else NotAvailableIcon() Text( text = gameModel.titleName ?: "N/A", maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(vertical = 4.dp) + modifier = Modifier + .padding(vertical = 4.dp) .basicMarquee() ) Text( text = gameModel.developer ?: "N/A", maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(vertical = 4.dp) + modifier = Modifier + .padding(vertical = 4.dp) .basicMarquee() ) } @@ -556,6 +565,19 @@ class HomeViews { ) } + @Composable + fun NROIcon() { + val size = ListImageSize / Resources.getSystem().displayMetrics.density + Image( + painter = painterResource(id = R.drawable.icon_nro), + contentDescription = "NRO", + modifier = Modifier + .padding(end = 8.dp) + .width(size.roundToInt().dp) + .height(size.roundToInt().dp) + ) + } + } @Preview diff --git a/src/RyujinxAndroid/app/src/main/res/drawable/icon_nro.png b/src/RyujinxAndroid/app/src/main/res/drawable/icon_nro.png new file mode 100644 index 000000000..3a9da6218 Binary files /dev/null and b/src/RyujinxAndroid/app/src/main/res/drawable/icon_nro.png differ