hactoolnet: Improve XCI and PFS0 handling

- Automatically import title keys from tickets.
- Automatically apply updates when reading an XCI or NSP.
- Allow listing all contained NCAs, programs or applications.
- Allow specifying the title when an XCI or NSP contains more than one.
- Allow extracting ExeFS or RomFS directly from an NSP.
This commit is contained in:
Alex Barney 2022-11-06 14:44:44 -07:00
parent a002d92b13
commit 4362b7be53
8 changed files with 243 additions and 144 deletions

View file

@ -74,11 +74,16 @@ XCI options:
--securedir <dir> Specify secure XCI directory path. --securedir <dir> Specify secure XCI directory path.
--logodir <dir> Specify logo XCI directory path. --logodir <dir> Specify logo XCI directory path.
--outdir <dir> Specify XCI directory path. --outdir <dir> Specify XCI directory path.
--nspout <file> Specify file for the created NSP.
Partition FS and XCI options:
--exefs <file> Specify main ExeFS file path. --exefs <file> Specify main ExeFS file path.
--exefsdir <dir> Specify main ExeFS directory path. --exefsdir <dir> Specify main ExeFS directory path.
--romfs <file> Specify main RomFS file path. --romfs <file> Specify main RomFS file path.
--romfsdir <dir> Specify main RomFS directory path. --romfsdir <dir> Specify main RomFS directory path.
--nspout <file> Specify file for the created NSP. --listapps List application info.
--listtitles List title info for all titles.
--listncas List info for all NCAs.
--title <title id> Specify title ID to use.
Package1 options: Package1 options:
--outdir <dir> Specify Package1 directory path. --outdir <dir> Specify Package1 directory path.
Package2 options: Package2 options:

View file

@ -1,4 +1,6 @@
namespace LibHac.Ncm; using System;
namespace LibHac.Ncm;
public enum ContentType : byte public enum ContentType : byte
{ {
@ -24,11 +26,13 @@ public enum ContentMetaType : byte
Delta = 0x83 Delta = 0x83
} }
[Flags]
public enum ContentMetaAttribute : byte public enum ContentMetaAttribute : byte
{ {
None = 0, None = 0,
IncludesExFatDriver = 1, IncludesExFatDriver = 1 << 0,
Rebootless = 2 Rebootless = 1 << 1,
Compacted = 1 << 2,
} }
public enum UpdateType : byte public enum UpdateType : byte
@ -36,4 +40,4 @@ public enum UpdateType : byte
ApplyAsDelta = 0, ApplyAsDelta = 0,
Overwrite = 1, Overwrite = 1,
Create = 2 Create = 2
} }

View file

@ -15,6 +15,7 @@ public class Cnmt
public int TableOffset { get; } public int TableOffset { get; }
public int ContentEntryCount { get; } public int ContentEntryCount { get; }
public int MetaEntryCount { get; } public int MetaEntryCount { get; }
public ContentMetaAttribute ContentMetaAttributes { get; }
public CnmtContentEntry[] ContentEntries { get; } public CnmtContentEntry[] ContentEntries { get; }
public CnmtContentMetaEntry[] MetaEntries { get; } public CnmtContentMetaEntry[] MetaEntries { get; }
@ -42,11 +43,12 @@ public class Cnmt
TableOffset = reader.ReadUInt16(); TableOffset = reader.ReadUInt16();
ContentEntryCount = reader.ReadUInt16(); ContentEntryCount = reader.ReadUInt16();
MetaEntryCount = reader.ReadUInt16(); MetaEntryCount = reader.ReadUInt16();
ContentMetaAttributes = (ContentMetaAttribute)reader.ReadByte();
// Old, pre-release cnmt files don't have the "required system version" field. // Old, pre-release cnmt files don't have the "required system version" field.
// Try to detect this by reading the padding after that field. // Try to detect this by reading the padding after that field.
// The old format usually contains hashes there. // The old format usually contains hashes there.
file.Position += 8; file.Position += 7;
int padding = reader.ReadInt32(); int padding = reader.ReadInt32();
bool isOldCnmtFormat = padding != 0; bool isOldCnmtFormat = padding != 0;

View file

@ -9,70 +9,70 @@ internal static class CliParser
{ {
private static CliOption[] GetCliOptions() => new[] private static CliOption[] GetCliOptions() => new[]
{ {
new CliOption("custom", 0, (o, _) => o.RunCustom = true), new CliOption("custom", 0, (o, _) => o.RunCustom = true),
new CliOption("intype", 't', 1, (o, a) => o.InFileType = ParseFileType(a[0])), new CliOption("intype", 't', 1, (o, a) => o.InFileType = ParseFileType(a[0])),
new CliOption("raw", 'r', 0, (o, _) => o.Raw = true), new CliOption("raw", 'r', 0, (o, _) => o.Raw = true),
new CliOption("verify", 'y', 0, (o, _) => o.Validate = true), new CliOption("verify", 'y', 0, (o, _) => o.Validate = true),
new CliOption("dev", 'd', 0, (o, _) => o.UseDevKeys = true), new CliOption("dev", 'd', 0, (o, _) => o.UseDevKeys = true),
new CliOption("enablehash", 'h', 0, (o, _) => o.EnableHash = true), new CliOption("enablehash", 'h', 0, (o, _) => o.EnableHash = true),
new CliOption("disablekeywarns", 0, (o, _) => o.DisableKeyWarns = true), new CliOption("disablekeywarns", 0, (o, _) => o.DisableKeyWarns = true),
new CliOption("keyset", 'k', 1, (o, a) => o.Keyfile = a[0]), new CliOption("keyset", 'k', 1, (o, a) => o.Keyfile = a[0]),
new CliOption("titlekeys", 1, (o, a) => o.TitleKeyFile = a[0]), new CliOption("titlekeys", 1, (o, a) => o.TitleKeyFile = a[0]),
new CliOption("consolekeys", 1, (o, a) => o.ConsoleKeyFile = a[0]), new CliOption("consolekeys", 1, (o, a) => o.ConsoleKeyFile = a[0]),
new CliOption("accesslog", 1, (o, a) => o.AccessLog = a[0]), new CliOption("accesslog", 1, (o, a) => o.AccessLog = a[0]),
new CliOption("resultlog", 1, (o, a) => o.ResultLog = a[0]), new CliOption("resultlog", 1, (o, a) => o.ResultLog = a[0]),
new CliOption("section0", 1, (o, a) => o.SectionOut[0] = a[0]), new CliOption("section0", 1, (o, a) => o.SectionOut[0] = a[0]),
new CliOption("section1", 1, (o, a) => o.SectionOut[1] = a[0]), new CliOption("section1", 1, (o, a) => o.SectionOut[1] = a[0]),
new CliOption("section2", 1, (o, a) => o.SectionOut[2] = a[0]), new CliOption("section2", 1, (o, a) => o.SectionOut[2] = a[0]),
new CliOption("section3", 1, (o, a) => o.SectionOut[3] = a[0]), new CliOption("section3", 1, (o, a) => o.SectionOut[3] = a[0]),
new CliOption("section0dir", 1, (o, a) => o.SectionOutDir[0] = a[0]), new CliOption("section0dir", 1, (o, a) => o.SectionOutDir[0] = a[0]),
new CliOption("section1dir", 1, (o, a) => o.SectionOutDir[1] = a[0]), new CliOption("section1dir", 1, (o, a) => o.SectionOutDir[1] = a[0]),
new CliOption("section2dir", 1, (o, a) => o.SectionOutDir[2] = a[0]), new CliOption("section2dir", 1, (o, a) => o.SectionOutDir[2] = a[0]),
new CliOption("section3dir", 1, (o, a) => o.SectionOutDir[3] = a[0]), new CliOption("section3dir", 1, (o, a) => o.SectionOutDir[3] = a[0]),
new CliOption("header", 1, (o, a) => o.HeaderOut = a[0]), new CliOption("header", 1, (o, a) => o.HeaderOut = a[0]),
new CliOption("exefs", 1, (o, a) => o.ExefsOut = a[0]), new CliOption("exefs", 1, (o, a) => o.ExefsOut = a[0]),
new CliOption("exefsdir", 1, (o, a) => o.ExefsOutDir = a[0]), new CliOption("exefsdir", 1, (o, a) => o.ExefsOutDir = a[0]),
new CliOption("romfs", 1, (o, a) => o.RomfsOut = a[0]), new CliOption("romfs", 1, (o, a) => o.RomfsOut = a[0]),
new CliOption("romfsdir", 1, (o, a) => o.RomfsOutDir = a[0]), new CliOption("romfsdir", 1, (o, a) => o.RomfsOutDir = a[0]),
new CliOption("debugoutdir", 1, (o, a) => o.DebugOutDir = a[0]), new CliOption("debugoutdir", 1, (o, a) => o.DebugOutDir = a[0]),
new CliOption("savedir", 1, (o, a) => o.SaveOutDir = a[0]), new CliOption("savedir", 1, (o, a) => o.SaveOutDir = a[0]),
new CliOption("outdir", 1, (o, a) => o.OutDir = a[0]), new CliOption("outdir", 1, (o, a) => o.OutDir = a[0]),
new CliOption("ini1dir", 1, (o, a) => o.Ini1OutDir = a[0]), new CliOption("ini1dir", 1, (o, a) => o.Ini1OutDir = a[0]),
new CliOption("outfile", 1, (o, a) => o.OutFile = a[0]), new CliOption("outfile", 1, (o, a) => o.OutFile = a[0]),
new CliOption("plaintext", 1, (o, a) => o.PlaintextOut = a[0]), new CliOption("plaintext", 1, (o, a) => o.PlaintextOut = a[0]),
new CliOption("ciphertext", 1, (o, a) => o.CiphertextOut = a[0]), new CliOption("ciphertext", 1, (o, a) => o.CiphertextOut = a[0]),
new CliOption("uncompressed", 1, (o, a) => o.UncompressedOut = a[0]), new CliOption("uncompressed", 1, (o, a) => o.UncompressedOut = a[0]),
new CliOption("nspout", 1, (o, a) => o.NspOut = a[0]), new CliOption("nspout", 1, (o, a) => o.NspOut = a[0]),
new CliOption("sdseed", 1, (o, a) => o.SdSeed = a[0]), new CliOption("sdseed", 1, (o, a) => o.SdSeed = a[0]),
new CliOption("sdpath", 1, (o, a) => o.SdPath = a[0]), new CliOption("sdpath", 1, (o, a) => o.SdPath = a[0]),
new CliOption("basenca", 1, (o, a) => o.BaseNca = a[0]), new CliOption("basenca", 1, (o, a) => o.BaseNca = a[0]),
new CliOption("basefile", 1, (o, a) => o.BaseFile = a[0]), new CliOption("basefile", 1, (o, a) => o.BaseFile = a[0]),
new CliOption("rootdir", 1, (o, a) => o.RootDir = a[0]), new CliOption("rootdir", 1, (o, a) => o.RootDir = a[0]),
new CliOption("updatedir", 1, (o, a) => o.UpdateDir = a[0]), new CliOption("updatedir", 1, (o, a) => o.UpdateDir = a[0]),
new CliOption("normaldir", 1, (o, a) => o.NormalDir = a[0]), new CliOption("normaldir", 1, (o, a) => o.NormalDir = a[0]),
new CliOption("securedir", 1, (o, a) => o.SecureDir = a[0]), new CliOption("securedir", 1, (o, a) => o.SecureDir = a[0]),
new CliOption("logodir", 1, (o, a) => o.LogoDir = a[0]), new CliOption("logodir", 1, (o, a) => o.LogoDir = a[0]),
new CliOption("repack", 1, (o, a) => o.RepackSource = a[0]), new CliOption("repack", 1, (o, a) => o.RepackSource = a[0]),
new CliOption("listapps", 0, (o, _) => o.ListApps = true), new CliOption("listapps", 0, (o, _) => o.ListApps = true),
new CliOption("listtitles", 0, (o, _) => o.ListTitles = true), new CliOption("listtitles", 0, (o, _) => o.ListTitles = true),
new CliOption("listncas", 0, (o, _) => o.ListNcas = true), new CliOption("listncas", 0, (o, _) => o.ListNcas = true),
new CliOption("listromfs", 0, (o, _) => o.ListRomFs = true), new CliOption("listromfs", 0, (o, _) => o.ListRomFs = true),
new CliOption("listfiles", 0, (o, _) => o.ListFiles = true), new CliOption("listfiles", 0, (o, _) => o.ListFiles = true),
new CliOption("sign", 0, (o, _) => o.SignSave = true), new CliOption("sign", 0, (o, _) => o.SignSave = true),
new CliOption("trim", 0, (o, _) => o.TrimSave = true), new CliOption("trim", 0, (o, _) => o.TrimSave = true),
new CliOption("readbench", 0, (o, _) => o.ReadBench = true), new CliOption("readbench", 0, (o, _) => o.ReadBench = true),
new CliOption("hashedfs", 0, (o, _) => o.BuildHfs = true), new CliOption("hashedfs", 0, (o, _) => o.BuildHfs = true),
new CliOption("extractini1", 0, (o, _) => o.ExtractIni1 = true), new CliOption("extractini1", 0, (o, _) => o.ExtractIni1 = true),
new CliOption("title", 1, (o, a) => o.TitleId = ParseTitleId(a[0])), new CliOption("title", 1, (o, a) => o.TitleId = ParseTitleId(a[0])),
new CliOption("bench", 1, (o, a) => o.BenchType = a[0]), new CliOption("bench", 1, (o, a) => o.BenchType = a[0]),
new CliOption("cpufreq", 1, (o, a) => o.CpuFrequencyGhz = ParseDouble(a[0])), new CliOption("cpufreq", 1, (o, a) => o.CpuFrequencyGhz = ParseDouble(a[0])),
new CliOption("replacefile", 2, (o, a) => new CliOption("replacefile", 2, (o, a) =>
{ {
o.ReplaceFileDest = a[0]; o.ReplaceFileDest = a[0];
o.ReplaceFileSource = a[1]; o.ReplaceFileSource = a[1];
}) })
}; };
public static Options Parse(string[] args) public static Options Parse(string[] args)
{ {
@ -249,11 +249,16 @@ internal static class CliParser
sb.AppendLine(" --securedir <dir> Specify secure XCI directory path."); sb.AppendLine(" --securedir <dir> Specify secure XCI directory path.");
sb.AppendLine(" --logodir <dir> Specify logo XCI directory path."); sb.AppendLine(" --logodir <dir> Specify logo XCI directory path.");
sb.AppendLine(" --outdir <dir> Specify XCI directory path."); sb.AppendLine(" --outdir <dir> Specify XCI directory path.");
sb.AppendLine(" --nspout <file> Specify file for the created NSP.");
sb.AppendLine("Partition FS and XCI options:");
sb.AppendLine(" --exefs <file> Specify main ExeFS file path."); sb.AppendLine(" --exefs <file> Specify main ExeFS file path.");
sb.AppendLine(" --exefsdir <dir> Specify main ExeFS directory path."); sb.AppendLine(" --exefsdir <dir> Specify main ExeFS directory path.");
sb.AppendLine(" --romfs <file> Specify main RomFS file path."); sb.AppendLine(" --romfs <file> Specify main RomFS file path.");
sb.AppendLine(" --romfsdir <dir> Specify main RomFS directory path."); sb.AppendLine(" --romfsdir <dir> Specify main RomFS directory path.");
sb.AppendLine(" --nspout <file> Specify file for the created NSP."); sb.AppendLine(" --listapps List application info.");
sb.AppendLine(" --listtitles List title info for all titles.");
sb.AppendLine(" --listncas List info for all NCAs.");
sb.AppendLine(" --title <title id> Specify title ID to use.");
sb.AppendLine("Package1 options:"); sb.AppendLine("Package1 options:");
sb.AppendLine(" --outdir <dir> Specify Package1 directory path."); sb.AppendLine(" --outdir <dir> Specify Package1 directory path.");
sb.AppendLine("Package2 options:"); sb.AppendLine("Package2 options:");
@ -319,4 +324,4 @@ internal static class CliParser
public int ArgsNeeded { get; } public int ArgsNeeded { get; }
public Action<Options, string[]> Assigner { get; } public Action<Options, string[]> Assigner { get; }
} }
} }

View file

@ -0,0 +1,145 @@
using System.Linq;
using LibHac.Common;
using LibHac.Fs;
using LibHac.Fs.Fsa;
using LibHac.FsSystem;
using LibHac.Ncm;
using LibHac.Spl;
using LibHac.Tools.Es;
using LibHac.Tools.Fs;
using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils;
namespace hactoolnet;
internal static class ProcessAppFs
{
private static void ImportTickets(Context ctx, IFileSystem fileSystem)
{
foreach (DirectoryEntryEx entry in fileSystem.EnumerateEntries("*.tik", SearchOptions.Default))
{
using var tikFile = new UniqueRef<IFile>();
fileSystem.OpenFile(ref tikFile.Ref(), entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
var ticket = new Ticket(tikFile.Get.AsStream());
if (ticket.TitleKeyType != TitleKeyType.Common)
continue;
if (ticket.RightsId.IsZeros())
continue;
var rightsId = SpanHelpers.AsStruct<RightsId>(ticket.RightsId);
var accessKey = SpanHelpers.AsStruct<AccessKey>(ticket.TitleKeyBlock);
ctx.KeySet.ExternalKeySet.Add(rightsId, accessKey).ThrowIfFailure();
}
}
public static void Process(Context ctx, IFileSystem fileSystem)
{
ImportTickets(ctx, fileSystem);
SwitchFs switchFs = SwitchFs.OpenNcaDirectory(ctx.KeySet, fileSystem);
if (ctx.Options.ListNcas)
{
ctx.Logger.LogMessage(ProcessSwitchFs.ListNcas(switchFs));
}
if (ctx.Options.ListTitles)
{
ctx.Logger.LogMessage(ProcessSwitchFs.ListTitles(switchFs));
}
if (ctx.Options.ListApps)
{
ctx.Logger.LogMessage(ProcessSwitchFs.ListApplications(switchFs));
}
ulong id = GetTargetProgramId(ctx, switchFs);
if (id == ulong.MaxValue)
{
ctx.Logger.LogMessage("Title ID must be specified to dump ExeFS or RomFS");
return;
}
if (!switchFs.Titles.TryGetValue(id, out Title title))
{
ctx.Logger.LogMessage($"Could not find title {id:X16}");
return;
}
if (title.MainNca == null)
{
ctx.Logger.LogMessage($"Could not find main data for title {id:X16}");
return;
}
if (title.Metadata?.ContentMetaAttributes.HasFlag(ContentMetaAttribute.Compacted) == true)
{
ctx.Logger.LogMessage($"Cannot extract compacted NCAs");
return;
}
if (ctx.Options.ExefsOutDir != null || ctx.Options.ExefsOut != null)
{
if (!title.MainNca.Nca.SectionExists(NcaSectionType.Code))
{
ctx.Logger.LogMessage($"Main NCA for title {id:X16} has no ExeFS section");
return;
}
if (ctx.Options.ExefsOutDir != null)
{
IFileSystem fs = title.MainNca.OpenFileSystem(NcaSectionType.Code, ctx.Options.IntegrityLevel);
fs.Extract(ctx.Options.ExefsOutDir, ctx.Logger);
}
if (ctx.Options.ExefsOut != null)
{
title.MainNca.Nca.ExportSection(NcaSectionType.Code, ctx.Options.ExefsOut, ctx.Options.Raw, ctx.Options.IntegrityLevel, ctx.Logger);
}
}
if (ctx.Options.RomfsOutDir != null || ctx.Options.RomfsOut != null || ctx.Options.ListRomFs)
{
if (!title.MainNca.Nca.SectionExists(NcaSectionType.Data))
{
ctx.Logger.LogMessage($"Main NCA for title {id:X16} has no RomFS section");
return;
}
ProcessRomfs.Process(ctx, title.MainNca.OpenStorage(NcaSectionType.Data, ctx.Options.IntegrityLevel));
}
if (ctx.Options.NspOut != null)
{
ProcessPfs.CreateNsp(ctx, switchFs);
}
}
private static ulong GetTargetProgramId(Context ctx, SwitchFs switchFs)
{
ulong id = ctx.Options.TitleId;
if (id != 0)
{
return id;
}
if (switchFs.Applications.Count != 1)
{
return ulong.MaxValue;
}
ulong applicationId = switchFs.Applications.Values.First().TitleId;
ulong updateId = applicationId | 0x800ul;
if (switchFs.Titles.ContainsKey(updateId))
{
return updateId;
}
return applicationId;
}
}

View file

@ -1,4 +1,5 @@
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using LibHac.Fs; using LibHac.Fs;
@ -24,6 +25,11 @@ internal static class ProcessPfs
{ {
pfs.Extract(ctx.Options.OutDir, ctx.Logger); pfs.Extract(ctx.Options.OutDir, ctx.Logger);
} }
if (pfs.EnumerateEntries("*.nca", SearchOptions.Default).Any())
{
ProcessAppFs.Process(ctx, pfs);
}
} }
} }

View file

@ -234,7 +234,7 @@ internal static class ProcessSwitchFs
} }
} }
static string ListTitles(SwitchFs sdfs) public static string ListTitles(SwitchFs sdfs)
{ {
var table = new TableBuilder("Title ID", "Version", "", "Type", "Size", "Display Version", "Name"); var table = new TableBuilder("Title ID", "Version", "", "Type", "Size", "Display Version", "Name");
@ -252,7 +252,7 @@ internal static class ProcessSwitchFs
return table.Print(); return table.Print();
} }
static string ListNcas(SwitchFs sdfs) public static string ListNcas(SwitchFs sdfs)
{ {
var table = new TableBuilder("NCA ID", "Type", "Title ID"); var table = new TableBuilder("NCA ID", "Type", "Title ID");
@ -264,7 +264,7 @@ internal static class ProcessSwitchFs
return table.Print(); return table.Print();
} }
static string ListApplications(SwitchFs sdfs) public static string ListApplications(SwitchFs sdfs)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();

View file

@ -8,7 +8,6 @@ using LibHac.FsSystem;
using LibHac.Gc.Impl; using LibHac.Gc.Impl;
using LibHac.Tools.Fs; using LibHac.Tools.Fs;
using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils;
namespace hactoolnet; namespace hactoolnet;
@ -65,80 +64,13 @@ internal static class ProcessXci
} }
} }
if (ctx.Options.ExefsOutDir != null || ctx.Options.ExefsOut != null) if (xci.HasPartition(XciPartitionType.Secure))
{ {
Nca mainNca = GetXciMainNca(xci, ctx); ProcessAppFs.Process(ctx, xci.OpenPartition(XciPartitionType.Secure));
if (mainNca == null)
{
ctx.Logger.LogMessage("Could not find Program NCA");
return;
}
if (!mainNca.SectionExists(NcaSectionType.Code))
{
ctx.Logger.LogMessage("NCA has no ExeFS section");
return;
}
if (ctx.Options.ExefsOutDir != null)
{
mainNca.ExtractSection(NcaSectionType.Code, ctx.Options.ExefsOutDir, ctx.Options.IntegrityLevel, ctx.Logger);
}
if (ctx.Options.ExefsOut != null)
{
mainNca.ExportSection(NcaSectionType.Code, ctx.Options.ExefsOut, ctx.Options.Raw, ctx.Options.IntegrityLevel, ctx.Logger);
}
}
if (ctx.Options.RomfsOutDir != null || ctx.Options.RomfsOut != null || ctx.Options.ListRomFs)
{
Nca mainNca = GetXciMainNca(xci, ctx);
if (mainNca == null)
{
ctx.Logger.LogMessage("Could not find Program NCA");
return;
}
if (!mainNca.SectionExists(NcaSectionType.Data))
{
ctx.Logger.LogMessage("NCA has no RomFS section");
return;
}
ProcessRomfs.Process(ctx, mainNca.OpenStorage(NcaSectionType.Data, ctx.Options.IntegrityLevel, false));
} }
} }
} }
private static Nca GetXciMainNca(Xci xci, Context ctx)
{
XciPartition partition = xci.OpenPartition(XciPartitionType.Secure);
if (partition == null)
{
ctx.Logger.LogMessage("Could not find secure partition");
return null;
}
Nca mainNca = null;
foreach (PartitionFileEntry fileEntry in partition.Files.Where(x => x.Name.EndsWith(".nca")))
{
IStorage ncaStorage = partition.OpenFile(fileEntry, OpenMode.Read).AsStorage();
var nca = new Nca(ctx.KeySet, ncaStorage);
if (nca.Header.ContentType == NcaContentType.Program)
{
mainNca = nca;
}
}
return mainNca;
}
private static string Print(this Xci xci) private static string Print(this Xci xci)
{ {
const int colLen = 52; const int colLen = 52;