android - add support for nro

This commit is contained in:
Emmanuel Hansen 2023-12-16 13:33:40 +00:00
parent 9680ecd820
commit 521403b0ea
9 changed files with 219 additions and 124 deletions

View File

@ -237,7 +237,7 @@ namespace LibRyujinx
} }
[UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceLoadDescriptor")] [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"); Logger.Trace?.Print(LogClass.Application, "Jni Function Call");
if (SwitchDevice?.EmulationContext == null) if (SwitchDevice?.EmulationContext == null)
@ -247,7 +247,7 @@ namespace LibRyujinx
var stream = OpenFile(descriptor); var stream = OpenFile(descriptor);
return LoadApplication(stream, isXci); return LoadApplication(stream, (FileType)(int)type);
} }
[UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_graphicsInitialize")] [UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_graphicsInitialize")]
@ -429,12 +429,12 @@ namespace LibRyujinx
} }
[UnmanagedCallersOnly(EntryPoint = "Java_org_ryujinx_android_RyujinxNative_deviceGetGameInfo")] [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"); Logger.Trace?.Print(LogClass.Application, "Jni Function Call");
using var stream = OpenFile(fileDescriptor); using var stream = OpenFile(fileDescriptor);
var ext = GetStoredString(extension);
var info = GetGameInfo(stream, isXci); var info = GetGameInfo(stream, ext.ToLower());
return GetInfo(jEnv, info); return GetInfo(jEnv, info);
} }

View File

@ -1,4 +1,4 @@
using ARMeilleure.Translation; using ARMeilleure.Translation;
using LibHac.Ncm; using LibHac.Ncm;
using LibHac.Tools.FsSystem.NcaUtils; using LibHac.Tools.FsSystem.NcaUtils;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
@ -66,10 +66,16 @@ namespace LibRyujinx
return LoadApplication(path); return LoadApplication(path);
} }
public static bool LoadApplication(Stream stream, bool isXci) public static bool LoadApplication(Stream stream, FileType type)
{ {
var emulationContext = SwitchDevice.EmulationContext; 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() public static bool LaunchMiiEditApplet()
@ -221,5 +227,13 @@ namespace LibRyujinx
Renderer = null; Renderer = null;
} }
} }
public enum FileType
{
None,
Nsp,
Xci,
Nro
}
} }
} }

View File

@ -34,6 +34,9 @@ using System.Globalization;
using Ryujinx.Ui.Common.Configuration.System; using Ryujinx.Ui.Common.Configuration.System;
using Ryujinx.Common.Logging.Targets; using Ryujinx.Common.Logging.Targets;
using System.Collections.Generic; using System.Collections.Generic;
using LibHac.Bcat;
using Ryujinx.Ui.App.Common;
using System.Text;
namespace LibRyujinx namespace LibRyujinx
{ {
@ -126,10 +129,10 @@ namespace LibRyujinx
using var stream = File.Open(file, FileMode.Open); 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) if (SwitchDevice == null)
{ {
@ -142,7 +145,7 @@ namespace LibRyujinx
FileSize = gameStream.Length * 0.000000000931, TitleName = "Unknown", TitleId = "0000000000000000", FileSize = gameStream.Length * 0.000000000931, TitleName = "Unknown", TitleId = "0000000000000000",
Developer = "Unknown", Developer = "Unknown",
Version = "0", Version = "0",
Icon = null, Icon = null
}; };
const Language TitleLanguage = Language.AmericanEnglish; const Language TitleLanguage = Language.AmericanEnglish;
@ -153,129 +156,169 @@ namespace LibRyujinx
{ {
try try
{ {
IFileSystem pfs; if (extension == "nsp" || extension == "pfs0" || extension == "xci")
bool isExeFs = false;
if (isXci)
{ {
Xci xci = new(SwitchDevice.VirtualFileSystem.KeySet, gameStream.AsStorage()); IFileSystem pfs;
pfs = xci.OpenPartition(XciPartitionType.Secure); bool isExeFs = false;
}
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. if (extension == "xci")
bool hasMainNca = false;
foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*"))
{ {
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<IFile> ncaFile = new(); if (Path.GetExtension(fileEntry.FullPath).ToLower() == ".nca")
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; using UniqueRef<IFile> 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<IFile> npdmFile = new();
}
}
if (isExeFs) Result result = pfs.OpenFile(ref npdmFile.Ref, "/main.npdm".ToU8Span(), OpenMode.Read);
{
using UniqueRef<IFile> npdmFile = new();
Result result = pfs.OpenFile(ref npdmFile.Ref, "/main.npdm".ToU8Span(), OpenMode.Read); if (ResultFs.PathNotFound.Includes(result))
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<IFile> 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 (entry.Name == "control.nacp") Npdm npdm = new(npdmFile.Get.AsStream());
{
continue;
}
using var icon = new UniqueRef<IFile>(); 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<IFile> icon = new();
controlFs?.OpenFile(ref icon.Ref, $"/icon_{TitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure();
using MemoryStream stream = new(); using MemoryStream stream = new();
icon.Get.AsStream().CopyTo(stream); icon.Get.AsStream().CopyTo(stream);
gameInfo.Icon = stream.ToArray(); 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<IFile>();
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);
} }
} }
} }

View File

@ -33,9 +33,9 @@ class RyujinxNative {
external fun deviceGetGameFrameRate(): Double external fun deviceGetGameFrameRate(): Double
external fun deviceGetGameFrameTime(): Double external fun deviceGetGameFrameTime(): Double
external fun deviceGetGameFifo(): 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 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 graphicsRendererSetSize(width: Int, height: Int)
external fun graphicsRendererSetVsync(enabled: Boolean) external fun graphicsRendererSetVsync(enabled: Boolean)
external fun graphicsRendererRunLoop() external fun graphicsRendererRunLoop()

View File

@ -4,10 +4,12 @@ import android.content.Context
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.extension import com.anggrayudi.storage.file.extension
import org.ryujinx.android.NativeHelpers
import org.ryujinx.android.RyujinxNative import org.ryujinx.android.RyujinxNative
class GameModel(var file: DocumentFile, val context: Context) { class GameModel(var file: DocumentFile, val context: Context) {
var type: FileType
var descriptor: ParcelFileDescriptor? = null var descriptor: ParcelFileDescriptor? = null
var fileName: String? var fileName: String?
var fileSize = 0.0 var fileSize = 0.0
@ -19,8 +21,9 @@ class GameModel(var file: DocumentFile, val context: Context) {
init { init {
fileName = file.name fileName = file.name
var pid = open() val pid = open()
val gameInfo = RyujinxNative.instance.deviceGetGameInfo(pid, file.extension.contains("xci")) val ext = NativeHelpers.instance.storeStringJava(file.extension)
val gameInfo = RyujinxNative.instance.deviceGetGameInfo(pid, ext)
close() close()
fileSize = gameInfo.FileSize fileSize = gameInfo.FileSize
@ -29,6 +32,16 @@ class GameModel(var file: DocumentFile, val context: Context) {
developer = gameInfo.Developer developer = gameInfo.Developer
version = gameInfo.Version version = gameInfo.Version
icon = gameInfo.Icon 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 { fun open() : Int {
@ -41,10 +54,6 @@ class GameModel(var file: DocumentFile, val context: Context) {
descriptor?.close() descriptor?.close()
descriptor = null descriptor = null
} }
fun isXci() : Boolean {
return file.extension == "xci"
}
} }
class GameInfo { class GameInfo {
@ -55,3 +64,10 @@ class GameInfo {
var Version: String? = null var Version: String? = null
var Icon: String? = null var Icon: String? = null
} }
enum class FileType{
None,
Nsp,
Xci,
Nro
}

View File

@ -72,7 +72,7 @@ class HomeViewModel(
loadedCache.clear() loadedCache.clear()
val files = mutableListOf<GameModel>() val files = mutableListOf<GameModel>()
for (file in folder.search(false, DocumentFileType.FILE)) { 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 { activity.let {
val item = GameModel(file, it) val item = GameModel(file, it)

View File

@ -165,7 +165,7 @@ class MainViewModel(val activity: MainActivity) {
if (!success) if (!success)
return false return false
success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.isXci()) success = nativeRyujinx.deviceLoadDescriptor(descriptor, game.type.ordinal)
if (!success) if (!success)
return false return false

View File

@ -57,11 +57,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.anggrayudi.storage.extension.launchOnUiThread 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.GameModel
import org.ryujinx.android.viewmodels.HomeViewModel import org.ryujinx.android.viewmodels.HomeViewModel
import org.ryujinx.android.viewmodels.QuickSettings import org.ryujinx.android.viewmodels.QuickSettings
@ -398,7 +401,7 @@ class HomeViews {
selected = null selected = null
} }
selectedModel.value = 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 { thread {
showLoading.value = true showLoading.value = true
val success = val success =
@ -427,7 +430,7 @@ class HomeViews {
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row { Row {
if (!gameModel.titleId.isNullOrEmpty() && gameModel.titleId != "0000000000000000") { if (!gameModel.titleId.isNullOrEmpty() && (gameModel.titleId != "0000000000000000" || gameModel.type == FileType.Nro)) {
if (gameModel.icon?.isNotEmpty() == true) { if (gameModel.icon?.isNotEmpty() == true) {
val pic = decoder.decode(gameModel.icon) val pic = decoder.decode(gameModel.icon)
val size = val size =
@ -441,7 +444,9 @@ class HomeViews {
.width(size.roundToInt().dp) .width(size.roundToInt().dp)
.height(size.roundToInt().dp) .height(size.roundToInt().dp)
) )
} else NotAvailableIcon() } else if (gameModel.type == FileType.Nro)
NROIcon()
else NotAvailableIcon()
} else NotAvailableIcon() } else NotAvailableIcon()
Column { Column {
Text(text = gameModel.titleName ?: "") Text(text = gameModel.titleName ?: "")
@ -487,7 +492,7 @@ class HomeViews {
selected = null selected = null
} }
selectedModel.value = 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 { thread {
showLoading.value = true showLoading.value = true
val success = val success =
@ -510,7 +515,7 @@ class HomeViews {
}) })
) { ) {
Column(modifier = Modifier.padding(4.dp)) { 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) { if (gameModel.icon?.isNotEmpty() == true) {
val pic = decoder.decode(gameModel.icon) val pic = decoder.decode(gameModel.icon)
val size = GridImageSize / Resources.getSystem().displayMetrics.density val size = GridImageSize / Resources.getSystem().displayMetrics.density
@ -523,20 +528,24 @@ class HomeViews {
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)
) )
} else NotAvailableIcon() } else if (gameModel.type == FileType.Nro)
NROIcon()
else NotAvailableIcon()
} else NotAvailableIcon() } else NotAvailableIcon()
Text( Text(
text = gameModel.titleName ?: "N/A", text = gameModel.titleName ?: "N/A",
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(vertical = 4.dp) modifier = Modifier
.padding(vertical = 4.dp)
.basicMarquee() .basicMarquee()
) )
Text( Text(
text = gameModel.developer ?: "N/A", text = gameModel.developer ?: "N/A",
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(vertical = 4.dp) modifier = Modifier
.padding(vertical = 4.dp)
.basicMarquee() .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 @Preview

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB