From 521403b0ead894cfe6f3fdf4a101f3572393c000 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Sat, 16 Dec 2023 13:33:40 +0000 Subject: [PATCH] android - add support for nro --- src/LibRyujinx/Android/JniExportedMethods.cs | 10 +- src/LibRyujinx/LibRyujinx.Device.cs | 20 +- src/LibRyujinx/LibRyujinx.cs | 239 +++++++++++------- .../java/org/ryujinx/android/RyujinxNative.kt | 4 +- .../ryujinx/android/viewmodels/GameModel.kt | 28 +- .../android/viewmodels/HomeViewModel.kt | 2 +- .../android/viewmodels/MainViewModel.kt | 2 +- .../org/ryujinx/android/views/HomeViews.kt | 38 ++- .../app/src/main/res/drawable/icon_nro.png | Bin 0 -> 10254 bytes 9 files changed, 219 insertions(+), 124 deletions(-) create mode 100644 src/RyujinxAndroid/app/src/main/res/drawable/icon_nro.png 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 0000000000000000000000000000000000000000..3a9da621834bc55b58978ffbc7b37cc50372bb1b GIT binary patch literal 10254 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 Utxt6oQ~_w*)mAB0vVQ(Q0RPcgegFUf literal 0 HcmV?d00001