From 5538227fceb4fcd9507a8aefaebfe04b17794456 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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

p6aHZ8We?OnIvoaWzaIaze|KQ3zsbz*|FZ!gV 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 C(O7-} literal 0 HcmV?d00001