From 1f81fe449ffd479416267887d55a1d6ce7152a2c Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Tue, 29 Sep 2026 10:44:06 -0400 Subject: [PATCH 1/2] [#151] Add --entry to dump and serialized-file to read a SerializedFile inside an archive --- Documentation/agent-guide.md | 5 +- Documentation/assetbundle-format.md | 10 +- Documentation/command-dump.md | 13 +- Documentation/command-serialized-file.md | 32 +-- SerializedFile/SerializedFileTool.cs | 125 +++++++----- TextDumper/TextDumperTool.cs | 104 +++++----- UnityBinaryFormat/ArchiveSerializedFile.cs | 93 +++++++++ UnityDataTool.Tests/DumpTests.cs | 62 +++++- .../SerializedFileCommandTests.cs | 190 +++++++++++------- UnityDataTool/Program.cs | 35 ++-- 10 files changed, 453 insertions(+), 216 deletions(-) create mode 100644 UnityBinaryFormat/ArchiveSerializedFile.cs diff --git a/Documentation/agent-guide.md b/Documentation/agent-guide.md index b7fd8af..1da6573 100644 --- a/Documentation/agent-guide.md +++ b/Documentation/agent-guide.md @@ -159,10 +159,11 @@ is only needed when scripts access the data on the CPU. `view_potential_duplicat objects across the whole build (this is why the full analysis matters) and is only expected to have results for AssetBundle builds: rows that span archives usually mean a shared dependency was not assigned to a common AssetBundle, so it was duplicated into each AssetBundle that needs it. To see -a suspicious object in full, dump it from the file the query reported: +a suspicious object in full, dump it from the file the query reported (the `archive`, +`serialized_file` and `object_id` columns of `object_view`): ``` -UnityDataTool dump /path/to/build/some.bundle -i --stdout +UnityDataTool dump /path/to/build/some.bundle -e -i --stdout ``` ## Worked example: why is this one AssetBundle so large? diff --git a/Documentation/assetbundle-format.md b/Documentation/assetbundle-format.md index df4d975..e783699 100644 --- a/Documentation/assetbundle-format.md +++ b/Documentation/assetbundle-format.md @@ -198,14 +198,12 @@ built-in shaders it needs and does not depend on the Player's Always Included Sh The [`archive`](command-archive.md) command lists or extracts the files inside a bundle, and [`dump`](command-dump.md) / [`serialized-file`](command-serialized-file.md) inspect the -SerializedFiles. A typical workflow is to extract the bundle into a folder and then dump specific -objects: +SerializedFiles directly inside the bundle, without extracting it: ``` -UnityDataTool archive extract mybundle.bundle -o extracted -cd extracted -UnityDataTool sf objectlist CAB- -UnityDataTool dump --stdout CAB- --type AssetBundle +UnityDataTool archive list mybundle.bundle +UnityDataTool sf objectlist mybundle.bundle -e CAB- +UnityDataTool dump --stdout mybundle.bundle -e CAB- --type AssetBundle ``` ## The AssetBundle object diff --git a/Documentation/command-dump.md b/Documentation/command-dump.md index 09b28c0..fa2ac85 100644 --- a/Documentation/command-dump.md +++ b/Documentation/command-dump.md @@ -10,7 +10,8 @@ UnityDataTool dump [options] | Option | Description | Default | |--------|-------------|---------| -| `` | Path to file to dump | *(required)* | +| `` | Path to the SerializedFile, or to an archive that contains SerializedFiles | *(required)* | +| `-e, --entry ` | Only dump this SerializedFile from inside the archive (see [Archive Support](#archive-support)) | All SerializedFiles | | `-o, --output-path ` | Output folder | Current folder | | `--stdout` | Write the dump to stdout (status and errors go to stderr). Mutually exclusive with `-o`. | `false` | | `-f, --output-format ` | Output format | `text` | @@ -84,7 +85,7 @@ UnityDataTool dump /path/to/file --stdout > my-dump.txt Restrictions: - `--stdout` and `-o` are mutually exclusive. -- For Unity archives that contain more than one SerializedFile, `--stdout` is refused — there is no unambiguous way to deliver multiple files on a single stream. Pass an individual SerializedFile, or omit `--stdout` to get one `.txt` per SerializedFile in the output folder. +- For Unity archives that contain more than one SerializedFile, choose the one to dump with `--entry`. Without it `--stdout` is refused, because there is no unambiguous way to deliver multiple files on a single stream. --- @@ -114,6 +115,14 @@ BuildPlayer-Scene2.sharedAssets.txt BuildPlayer-Scene2.txt ``` +To dump only one of the SerializedFiles, pass its name with `-e` / `--entry`. Nothing is extracted to disk. The names are listed by [`archive list`](command-archive.md), and they are also the `serialized_file` column of `object_view` in a database built by `analyze`: + +```bash +UnityDataTool dump scenes.bundle -e BuildPlayer-Scene2.sharedAssets --stdout +``` + +When the archive contains only one SerializedFile (the usual case for an AssetBundle that does not contain scenes), `--stdout` uses it without `--entry`. When there are several, the error lists their names. + --- ## TypeTree Requirement diff --git a/Documentation/command-serialized-file.md b/Documentation/command-serialized-file.md index f5e6e7f..5752839 100644 --- a/Documentation/command-serialized-file.md +++ b/Documentation/command-serialized-file.md @@ -38,7 +38,8 @@ UnityDataTool sf externalrefs [options] | Option | Description | Default | |--------|-------------|---------| -| `` | Path to the SerializedFile | *(required)* | +| `` | Path to the SerializedFile, or to an archive that contains it | *(required)* | +| `-e, --entry ` | Name of the SerializedFile inside the archive (see [SerializedFiles inside an archive](#serializedfiles-inside-an-archive)) | — | | `-f, --format ` | Output format: `Text` or `Json` | `Text` | ### Example - Text Output @@ -93,7 +94,8 @@ UnityDataTool sf objectlist [options] | Option | Description | Default | |--------|-------------|---------| -| `` | Path to the SerializedFile | *(required)* | +| `` | Path to the SerializedFile, or to an archive that contains it | *(required)* | +| `-e, --entry ` | Name of the SerializedFile inside the archive (see [SerializedFiles inside an archive](#serializedfiles-inside-an-archive)) | — | | `-f, --format ` | Output format: `Text` or `Json` | `Text` | ### Example - Text Output @@ -155,7 +157,8 @@ UnityDataTool sf header [options] | Option | Description | Default | |--------|-------------|---------| -| `` | Path to the SerializedFile | *(required)* | +| `` | Path to the SerializedFile, or to an archive that contains it | *(required)* | +| `-e, --entry ` | Name of the SerializedFile inside the archive (see [SerializedFiles inside an archive](#serializedfiles-inside-an-archive)) | — | | `-f, --format ` | Output format: `Text` or `Json` | `Text` | ### Example - Text Output @@ -223,7 +226,8 @@ UnityDataTool sf metadata [options] | Option | Description | Default | |--------|-------------|---------| -| `` | Path to the SerializedFile | *(required)* | +| `` | Path to the SerializedFile, or to an archive that contains it | *(required)* | +| `-e, --entry ` | Name of the SerializedFile inside the archive (see [SerializedFiles inside an archive](#serializedfiles-inside-an-archive)) | — | | `-f, --format ` | Output format: `Text` or `Json` | `Text` | ### Example - Text Output @@ -363,22 +367,26 @@ UnityDataTool sf objectlist sharedassets0.assets -f json | jq '.[] | select(.typ --- -## SerializedFile vs Archive +## SerializedFiles inside an archive -When working with AssetBundles (or a compressed Player build) you need to extract the contents first (with `archive extract`), then run the `serialized-file` command on individual files in the extracted output. +AssetBundles, compressed Player builds and Content Directory builds store their SerializedFiles inside a Unity Archive. Every subcommand can read a SerializedFile directly from the archive, without extracting it: pass the archive path and name the SerializedFile with `-e` / `--entry`. -**Example workflow:** ```bash -# 1. List contents of an archive +# 1. List the contents of the archive UnityDataTool archive list scenes.bundle -# 2. Extract the archive -UnityDataTool archive extract scenes.bundle -o extracted/ +# 2. Inspect one of its SerializedFiles +UnityDataTool sf objectlist scenes.bundle -e BuildPlayer-SampleScene.sharedAssets +``` + +When the archive contains only one SerializedFile (the usual case for an AssetBundle that does not contain scenes), `--entry` can be left out: -# 3. Inspect individual SerializedFiles -UnityDataTool sf objectlist extracted/CAB-5d40f7cad7c871cf2ad2af19ac542994 +```bash +UnityDataTool sf externalrefs mybundle.bundle ``` +When there are several, the error lists their names. The names are also the `serialized_file` column of `object_view` in a database built by `analyze`. + --- ## Notes diff --git a/SerializedFile/SerializedFileTool.cs b/SerializedFile/SerializedFileTool.cs index a2dd997..7e4d1da 100644 --- a/SerializedFile/SerializedFileTool.cs +++ b/SerializedFile/SerializedFileTool.cs @@ -15,7 +15,7 @@ public enum OutputFormat Json } - public static int ListExternalRefs(FileInfo filename, OutputFormat format) + public static int ListExternalRefs(FileInfo filename, string entry, OutputFormat format) { // External references are read directly from the parsed metadata rather than via UnityFileSystemApi. // @@ -26,19 +26,20 @@ public static int ListExternalRefs(FileInfo filename, OutputFormat format) // // These trade-offs are minor compared to the benefit of handling the common no-TypeTree case, // so there is no need to keep the UnityFileSystemApi code path. - if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) + using var file = OpenSerializedFile(filename.FullName, entry); + if (file == null) return 1; - if (!SerializedFileDetector.TryParseMetadata(filename.FullName, fileInfo, out var metadata, out var errorMessage)) + if (!SerializedFileDetector.TryParseMetadata(file.Stream, file.Info, out var metadata, out var errorMessage)) { - Console.Error.WriteLine($"Error: Failed to parse external references for: {filename.FullName}"); + Console.Error.WriteLine($"Error: Failed to parse external references for: {file.DisplayName}"); Console.Error.WriteLine(errorMessage); return 1; } if (metadata.ExternalReferences == null) { - Console.Error.WriteLine($"Error: External references could not be parsed for: {filename.FullName}"); + Console.Error.WriteLine($"Error: External references could not be parsed for: {file.DisplayName}"); return 1; } @@ -50,23 +51,24 @@ public static int ListExternalRefs(FileInfo filename, OutputFormat format) return 0; } - public static int ListObjects(FileInfo filename, OutputFormat format) + public static int ListObjects(FileInfo filename, string entry, OutputFormat format) { // The object list is read directly from the parsed metadata rather than via UnityFileSystemApi. // (See comment in ListExternalRefs() for the reasons for doing it that way) - if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) + using var file = OpenSerializedFile(filename.FullName, entry); + if (file == null) return 1; - if (!SerializedFileDetector.TryParseMetadata(filename.FullName, fileInfo, out var metadata, out var errorMessage)) + if (!SerializedFileDetector.TryParseMetadata(file.Stream, file.Info, out var metadata, out var errorMessage)) { - Console.Error.WriteLine($"Error: Failed to parse object list for: {filename.FullName}"); + Console.Error.WriteLine($"Error: Failed to parse object list for: {file.DisplayName}"); Console.Error.WriteLine(errorMessage); return 1; } if (metadata.ObjectList == null) { - Console.Error.WriteLine($"Error: Object list could not be parsed for: {filename.FullName}"); + Console.Error.WriteLine($"Error: Object list could not be parsed for: {file.DisplayName}"); return 1; } @@ -78,27 +80,29 @@ public static int ListObjects(FileInfo filename, OutputFormat format) return 0; } - public static int PrintHeader(FileInfo filename, OutputFormat format) + public static int PrintHeader(FileInfo filename, string entry, OutputFormat format) { - if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) + using var file = OpenSerializedFile(filename.FullName, entry); + if (file == null) return 1; if (format == OutputFormat.Json) - OutputHeaderJson(fileInfo); + OutputHeaderJson(file.Info); else - OutputHeaderText(fileInfo); + OutputHeaderText(file.Info); return 0; } - public static int PrintMetadata(FileInfo filename, OutputFormat format) + public static int PrintMetadata(FileInfo filename, string entry, OutputFormat format) { - if (!ValidateSerializedFile(filename.FullName, out var fileInfo)) + using var file = OpenSerializedFile(filename.FullName, entry); + if (file == null) return 1; - if (!SerializedFileDetector.TryParseMetadata(filename.FullName, fileInfo, out var metadata, out var errorMessage)) + if (!SerializedFileDetector.TryParseMetadata(file.Stream, file.Info, out var metadata, out var errorMessage)) { - Console.Error.WriteLine($"Error: Failed to parse metadata for: {filename.FullName}"); + Console.Error.WriteLine($"Error: Failed to parse metadata for: {file.DisplayName}"); Console.Error.WriteLine(errorMessage); return 1; } @@ -111,52 +115,79 @@ public static int PrintMetadata(FileInfo filename, OutputFormat format) return 0; } - /// - /// Validates that a file is a SerializedFile and provides helpful error messages if not. - /// - /// Path to the file to validate - /// SerializedFile header information if valid, null otherwise - /// True if valid SerializedFile, false otherwise - private static bool ValidateSerializedFile(string filePath, out SerializedFileInfo fileInfo) + // A validated SerializedFile open for reading: a file on disk, or an entry of a mounted archive. + private sealed class OpenedSerializedFile : IDisposable { - fileInfo = null; + public Stream Stream { get; init; } + public SerializedFileInfo Info { get; init; } + public string DisplayName { get; init; } + public ArchiveSerializedFile ArchiveFile { get; init; } + public void Dispose() + { + // The stream reads through the mount, so close it before unmounting. + Stream.Dispose(); + ArchiveFile?.Dispose(); + } + } + + // Opens the file as a SerializedFile, or the SerializedFile chosen by entry when the file is an + // archive. Prints a helpful error and returns null when that is not possible. + private static OpenedSerializedFile OpenSerializedFile(string filePath, string entry) + { if (!File.Exists(filePath)) { Console.Error.WriteLine($"Error: File not found: {filePath}"); - return false; + return null; } + Stream stream; + string displayName; + ArchiveSerializedFile archiveFile = null; + if (ArchiveDetector.IsUnityArchive(filePath)) { - Console.Error.WriteLine($"Error: The file is an AssetBundle or other Unity Archive, not a SerializedFile."); - Console.Error.WriteLine($"File: {filePath}"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Unity Archives contain SerializedFiles inside them."); - Console.Error.WriteLine("To access the SerializedFiles, first extract the archive using:"); - Console.Error.WriteLine($" UnityDataTool archive extract \"{filePath}\" -o "); - Console.Error.WriteLine(); - Console.Error.WriteLine("Then you can run serialized-file commands on the extracted files."); - return false; - } + if (!ArchiveSerializedFile.TryOpen(filePath, entry, out archiveFile, out var archiveError)) + { + Console.Error.WriteLine(archiveError); + return null; + } - if (YamlSerializedFileDetector.IsYamlSerializedFile(filePath)) + // The metadata parser reads one small value at a time, and every unbuffered read is a native call. + stream = new BufferedStream(new UnityFileStream(archiveFile.MountedPath), 64 * 1024); + displayName = $"{archiveFile.Name} in {filePath}"; + } + else { - Console.Error.WriteLine($"Error: The file is a YAML-format SerializedFile, which is not supported."); - Console.Error.WriteLine($"File: {filePath}"); - Console.Error.WriteLine(); - Console.Error.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); - return false; + if (entry != null) + { + Console.Error.WriteLine(ArchiveSerializedFile.EntryWithoutArchiveError(filePath)); + return null; + } + + if (YamlSerializedFileDetector.IsYamlSerializedFile(filePath)) + { + Console.Error.WriteLine($"Error: The file is a YAML-format SerializedFile, which is not supported."); + Console.Error.WriteLine($"File: {filePath}"); + Console.Error.WriteLine(); + Console.Error.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); + return null; + } + + stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + displayName = filePath; } - if (!SerializedFileDetector.TryDetectSerializedFile(filePath, out fileInfo)) + if (!SerializedFileDetector.TryDetectSerializedFile(stream, out var info)) { Console.Error.WriteLine($"Error: The file does not appear to be a valid Unity SerializedFile."); - Console.Error.WriteLine($"File: {filePath}"); - return false; + Console.Error.WriteLine($"File: {displayName}"); + stream.Dispose(); + archiveFile?.Dispose(); + return null; } - return true; + return new OpenedSerializedFile { Stream = stream, Info = info, DisplayName = displayName, ArchiveFile = archiveFile }; } private static void OutputExternalRefsText(ExternalReference[] refs) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index e886517..fb2f656 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -41,6 +41,8 @@ public class DumpOptions public long ObjectId { get; init; } public string TypeFilter { get; init; } public bool ToStdout { get; init; } + // Name of the SerializedFile to dump when Path is an archive + public string Entry { get; init; } } public int Dump(DumpOptions options) @@ -58,7 +60,13 @@ public int Dump(DumpOptions options) } if (ArchiveDetector.IsUnityArchive(m_Options.Path)) - return DumpArchive(); + return m_Options.Entry != null || m_Options.ToStdout ? DumpArchiveSerializedFile() : DumpArchive(); + + if (m_Options.Entry != null) + { + Console.Error.WriteLine(ArchiveSerializedFile.EntryWithoutArchiveError(m_Options.Path)); + return 1; + } if (YamlSerializedFileDetector.IsYamlSerializedFile(m_Options.Path)) { @@ -87,24 +95,48 @@ int DumpSerializedFile() if (ReportIfNotDumpable(m_Options.Path, m_Options.Path)) return 1; + return WriteDump(m_Options.Path, m_Options.Path); + } + + // Dumps one SerializedFile from an archive, chosen with --entry or because it is the only one. + int DumpArchiveSerializedFile() + { + if (!ArchiveSerializedFile.TryOpen(m_Options.Path, m_Options.Entry, out var archiveFile, out var errorMessage)) + { + Console.Error.WriteLine(errorMessage); + return 1; + } + + using (archiveFile) + { + if (ReportIfNotDumpable(archiveFile.MountedPath, archiveFile.Name)) + return 1; + + return WriteDump(archiveFile.MountedPath, archiveFile.Name); + } + } + + // Writes the dump of one SerializedFile to stdout or to ".txt" in the output folder. + int WriteDump(string path, string displayName) + { try { if (m_Options.ToStdout) { m_Writer = Console.Out; - OutputSerializedFile(m_Options.Path); + OutputSerializedFile(path); m_Writer.Flush(); } else { - using var writer = new StreamWriter(Path.Combine(m_Options.OutputPath, Path.GetFileName(m_Options.Path) + ".txt"), false); + using var writer = new StreamWriter(Path.Combine(m_Options.OutputPath, Path.GetFileName(displayName) + ".txt"), false); m_Writer = writer; - OutputSerializedFile(m_Options.Path); + OutputSerializedFile(path); } } catch (SerializedFileOpenException) { - Console.Error.WriteLine($"Error: Failed to open serialized file: {m_Options.Path}"); + Console.Error.WriteLine($"Error: Failed to open serialized file: {displayName}"); return 1; } @@ -134,69 +166,25 @@ bool ReportIfNotDumpable(string path, string displayName) return true; } - // For convenience we also support directly dumping serialized files that are inside an archive, - // so that it's not necessary to use `archive extract` if you only want to see values from the object serialization. + // Dumps every SerializedFile inside the archive, so that it's not necessary to use `archive extract` + // if you only want to see values from the object serialization. int DumpArchive() { using var archive = UnityFileSystem.MountArchive(m_Options.Path, "/"); - bool anyMissingTypeTrees = false; + bool anyFailed = false; - if (m_Options.ToStdout) + foreach (var node in archive.Nodes) { - ArchiveNode? singleSerializedFile = null; - int serializedFileCount = 0; - foreach (var node in archive.Nodes) - { - if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) - { - ++serializedFileCount; - singleSerializedFile ??= node; - } - } - - if (serializedFileCount == 0) - { - Console.Error.WriteLine("Error: Archive contains no SerializedFiles."); - return 1; - } - - if (serializedFileCount > 1) - { - Console.Error.WriteLine($"Error: --stdout cannot be used with an archive containing multiple SerializedFiles ({serializedFileCount} found)."); - Console.Error.WriteLine("Extract the archive first, or pass an individual SerializedFile as input."); - return 1; - } + Console.WriteLine($"Processing {node.Path} {node.Size} {node.Flags}"); - var node2 = singleSerializedFile.Value; - Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}"); - if (ReportIfNotDumpable("/" + node2.Path, node2.Path)) - return 1; - m_Writer = Console.Out; - OutputSerializedFile("/" + node2.Path); - m_Writer.Flush(); - } - else - { - foreach (var node in archive.Nodes) + if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) { - Console.WriteLine($"Processing {node.Path} {node.Size} {node.Flags}"); - - if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) - { - if (ReportIfNotDumpable("/" + node.Path, node.Path)) - { - anyMissingTypeTrees = true; - continue; - } - - using var writer = new StreamWriter(Path.Combine(m_Options.OutputPath, Path.GetFileName(node.Path) + ".txt"), false); - m_Writer = writer; - OutputSerializedFile("/" + node.Path); - } + if (ReportIfNotDumpable("/" + node.Path, node.Path) || WriteDump("/" + node.Path, node.Path) != 0) + anyFailed = true; } } - return anyMissingTypeTrees ? 1 : 0; + return anyFailed ? 1 : 0; } void OutputSerializedFile(string path) diff --git a/UnityBinaryFormat/ArchiveSerializedFile.cs b/UnityBinaryFormat/ArchiveSerializedFile.cs new file mode 100644 index 0000000..f6dd641 --- /dev/null +++ b/UnityBinaryFormat/ArchiveSerializedFile.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityDataTools.FileSystem; + +namespace UnityDataTools.BinaryFormat; + +// A SerializedFile inside a mounted Unity Archive, so that commands can read it without extracting +// the archive. The archive stays mounted until this object is disposed. +public sealed class ArchiveSerializedFile : IDisposable +{ + const string MountPoint = "/"; + + UnityArchive m_Archive; + + // Path of the SerializedFile inside the archive, as shown by `archive list`. + public string Name { get; } + + // Path to pass to UnityFileSystem / UnityFileStream while the archive is mounted. + public string MountedPath => MountPoint + Name; + + ArchiveSerializedFile(UnityArchive archive, string name) + { + m_Archive = archive; + Name = name; + } + + // Mounts the archive and selects the SerializedFile named by entry. When entry is null the archive + // must contain exactly one SerializedFile. On failure the archive is unmounted and errorMessage + // explains the problem, listing the SerializedFiles that can be chosen. + public static bool TryOpen(string archivePath, string entry, out ArchiveSerializedFile result, out string errorMessage) + { + result = null; + var archive = UnityFileSystem.MountArchive(archivePath, MountPoint); + + var nodes = archive.Nodes; + var serializedFiles = nodes + .Where(n => n.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) + .Select(n => n.Path) + .ToList(); + + errorMessage = null; + if (serializedFiles.Count == 0) + { + errorMessage = "Error: The archive contains no SerializedFiles."; + } + else if (entry != null) + { + if (serializedFiles.Contains(entry)) + result = new ArchiveSerializedFile(archive, entry); + else if (nodes.Any(n => n.Path == entry)) + errorMessage = FormatError($"\"{entry}\" in the archive is not a SerializedFile.", serializedFiles); + else + errorMessage = FormatError($"\"{entry}\" was not found in the archive.", serializedFiles); + } + else if (serializedFiles.Count == 1) + { + result = new ArchiveSerializedFile(archive, serializedFiles[0]); + } + else + { + errorMessage = FormatError( + $"The archive contains {serializedFiles.Count} SerializedFiles. Choose one with --entry, for example --entry \"{serializedFiles[0]}\".", + serializedFiles); + } + + if (result == null) + archive.Dispose(); + + return result != null; + } + + // Error for --entry given with a file that is not a Unity Archive. + public static string EntryWithoutArchiveError(string filePath) => + $"Error: --entry can only be used with a Unity Archive, and this file is not one.{Environment.NewLine}File: {filePath}"; + + static string FormatError(string message, List serializedFiles) + { + var sb = new StringBuilder(); + sb.Append("Error: ").AppendLine(message); + sb.Append("SerializedFiles in the archive:"); + foreach (var name in serializedFiles) + sb.AppendLine().Append(" ").Append(name); + return sb.ToString(); + } + + public void Dispose() + { + m_Archive?.Dispose(); + m_Archive = null; + } +} diff --git a/UnityDataTool.Tests/DumpTests.cs b/UnityDataTool.Tests/DumpTests.cs index 5a5ccf3..d077ab9 100644 --- a/UnityDataTool.Tests/DumpTests.cs +++ b/UnityDataTool.Tests/DumpTests.cs @@ -14,6 +14,7 @@ public class DumpTests private string m_SerializedFilePath; private string m_ResourceFilePath; private string m_MultiSerializedFileArchivePath; + private string m_SceneBundlePath; private string m_NoTypeTreeSerializedFilePath; private string m_NoTypeTreeArchivePath; private string m_SerializationDemoBundlePath; @@ -26,6 +27,7 @@ public void OneTimeSetup() m_SerializedFilePath = Path.Combine(m_TestDataFolder, "PlayerWithTypeTrees", "level0"); m_ResourceFilePath = Path.Combine(m_TestDataFolder, "PlayerWithTypeTrees", "sharedassets0.assets.resS"); m_MultiSerializedFileArchivePath = Path.Combine(m_TestDataFolder, "PlayerDataCompressed", "data.unity3d"); + m_SceneBundlePath = Path.Combine(m_TestDataFolder, "AssetBundles", "2022.1.20f1", "scenes"); m_NoTypeTreeSerializedFilePath = Path.Combine(m_TestDataFolder, "PlayerNoTypeTree", "level0"); m_NoTypeTreeArchivePath = Path.Combine(m_TestDataFolder, "AssetBundleTypeTreeVariations", "AssetBundle-NoTypeTree", "small.bundle"); m_SerializationDemoBundlePath = Path.Combine(m_TestDataFolder, "LeadingEdgeBuilds", "AssetBundles", "serializationdemo"); @@ -172,8 +174,64 @@ public async Task Dump_Stdout_MultipleSerializedFilesArchive_Refused() } var err = swErr.ToString(); - Assert.That(err, Does.Contain("--stdout cannot be used with an archive containing multiple SerializedFiles")); - Assert.That(err, Does.Contain("(5 found)")); + Assert.That(err, Does.Contain("The archive contains 5 SerializedFiles. Choose one with --entry")); + Assert.That(err, Does.Contain(" Resources/unity_builtin_extra")); + } + + [Test] + public async Task Dump_Stdout_Entry_DumpsChosenSerializedFile() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SceneBundlePath, "--stdout", "--entry", "BuildPlayer-SampleScene", "-t", "GameObject" })); + } + finally + { + Console.SetOut(currentOut); + } + + Assert.That(sw.ToString(), Does.Contain("(ClassID: 1) GameObject")); + } + + [Test] + public async Task Dump_Entry_WritesOnlyChosenSerializedFile() + { + var outputFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "dump_entry_output"); + Directory.CreateDirectory(outputFolder); + try + { + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SceneBundlePath, "-e", "BuildPlayer-OtherScene.sharedAssets", "-o", outputFolder })); + + var files = Directory.GetFiles(outputFolder); + Assert.AreEqual(1, files.Length); + Assert.AreEqual("BuildPlayer-OtherScene.sharedAssets.txt", Path.GetFileName(files[0])); + Assert.That(File.ReadAllText(files[0]), Does.Contain("External References")); + } + finally + { + Directory.Delete(outputFolder, true); + } + } + + [Test] + public async Task Dump_Entry_OnPlainSerializedFile_Fails() + { + using var swErr = new StringWriter(); + var currentErr = Console.Error; + try + { + Console.SetError(swErr); + Assert.AreNotEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-e", "level0" })); + } + finally + { + Console.SetError(currentErr); + } + + Assert.That(swErr.ToString(), Does.Contain("--entry can only be used with a Unity Archive")); } [Test] diff --git a/UnityDataTool.Tests/SerializedFileCommandTests.cs b/UnityDataTool.Tests/SerializedFileCommandTests.cs index 33b99f9..0b7821c 100644 --- a/UnityDataTool.Tests/SerializedFileCommandTests.cs +++ b/UnityDataTool.Tests/SerializedFileCommandTests.cs @@ -422,37 +422,6 @@ public async Task Header_InvalidFile_ReturnsError() Assert.AreNotEqual(0, result, "Should return error code for invalid file"); } - [Test] - public async Task Header_ArchiveFile_ReturnsError() - { - var legacyDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "LegacyFormats", "AssetBundles"); - var archivePath = Path.Combine(legacyDir, "alienprefab"); - - if (!File.Exists(archivePath)) - { - Assert.Ignore("alienprefab test file not found"); - return; - } - - using var sw = new StringWriter(); - var currentErr = Console.Error; - try - { - Console.SetError(sw); - - var result = await Program.Main(new string[] { "serialized-file", "header", archivePath }); - - Assert.AreNotEqual(0, result, "Should return error code for archive file"); - - var errorOutput = sw.ToString(); - StringAssert.Contains("Unity Archive", errorOutput, "Error message should mention Unity Archive"); - } - finally - { - Console.SetError(currentErr); - } - } - #endregion #region Metadata Tests @@ -798,48 +767,6 @@ public async Task ErrorHandling_NonExistentFile_ReturnsError() Assert.AreNotEqual(0, result, "Should return error code for non-existent file"); } - [Test] - public async Task ErrorHandling_ArchiveFile_ReturnsHelpfulError() - { - // Use an AssetBundle from test data - var assetBundlesDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "AssetBundles", "2022.1.20f1"); - - // Skip if the test data doesn't exist (CI environments might not have all test data) - if (!Directory.Exists(assetBundlesDir)) - { - Assert.Ignore("AssetBundle test data not found"); - return; - } - - var archiveFiles = Directory.GetFiles(assetBundlesDir, "*", SearchOption.TopDirectoryOnly); - if (archiveFiles.Length == 0) - { - Assert.Ignore("No AssetBundle test files found"); - return; - } - - var archivePath = archiveFiles[0]; // Use first archive file found - - using var sw = new StringWriter(); - var currentErr = Console.Error; - try - { - Console.SetError(sw); - - var result = await Program.Main(new string[] { "serialized-file", "objectlist", archivePath }); - - Assert.AreNotEqual(0, result, "Should return error code for archive file"); - - var errorOutput = sw.ToString(); - StringAssert.Contains("Unity Archive", errorOutput, "Error message should mention Unity Archive"); - StringAssert.Contains("archive extract", errorOutput, "Error message should suggest using archive extract command"); - } - finally - { - Console.SetError(currentErr); - } - } - [Test] public async Task ErrorHandling_InvalidFile_ShowsHelpfulMessage() { @@ -940,5 +867,120 @@ public async Task ErrorHandling_YamlFile_ObjectList_ReturnsHelpfulError() } #endregion -} + #region Archive Tests + + private static string AssetBundlesPath(params string[] parts) => + Path.Combine(new[] { TestContext.CurrentContext.TestDirectory, "Data", "AssetBundles", "2022.1.20f1" }.Concat(parts).ToArray()); + + private static async Task<(int ExitCode, string Out, string Err)> RunCaptured(params string[] args) + { + using var swOut = new StringWriter(); + using var swErr = new StringWriter(); + var currentOut = Console.Out; + var currentErr = Console.Error; + try + { + Console.SetOut(swOut); + Console.SetError(swErr); + var result = await Program.Main(args); + return (result, swOut.ToString(), swErr.ToString()); + } + finally + { + Console.SetOut(currentOut); + Console.SetError(currentErr); + } + } + + [Test] + public async Task Archive_SingleSerializedFile_UsedWithoutEntry() + { + var (exitCode, output, _) = await RunCaptured("sf", "externalrefs", AssetBundlesPath("assetbundle")); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains("Path: archive:/CAB-35fce856128a6714740898681ea54bbe/CAB-35fce856128a6714740898681ea54bbe", output); + } + + [Test] + public async Task Archive_SingleSerializedFile_LegacyHeader() + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "LegacyFormats", "AssetBundles", "alienprefab"); + + var (exitCode, output, _) = await RunCaptured("sf", "header", path); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains("Legacy (32-bit)", output); + } + + [Test] + public async Task Archive_NoTypeTreeBundle_ObjectListWorks() + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data", "AssetBundleTypeTreeVariations", "AssetBundle-NoTypeTree", "small.bundle"); + + var (exitCode, output, _) = await RunCaptured("sf", "objectlist", path, "-f", "Json"); + + Assert.AreEqual(0, exitCode); + using var doc = JsonDocument.Parse(output); + Assert.Greater(doc.RootElement.GetArrayLength(), 0); + } + + [Test] + public async Task Archive_Entry_SelectsSerializedFile() + { + var path = AssetBundlesPath("scenes"); + + var (sceneExit, sceneOutput, _) = await RunCaptured("sf", "objectlist", path, "--entry", "BuildPlayer-SampleScene", "-f", "Json"); + var (sharedExit, sharedOutput, _) = await RunCaptured("sf", "objectlist", path, "-e", "BuildPlayer-SampleScene.sharedAssets", "-f", "Json"); + + Assert.AreEqual(0, sceneExit); + Assert.AreEqual(0, sharedExit); + using var sceneDoc = JsonDocument.Parse(sceneOutput); + using var sharedDoc = JsonDocument.Parse(sharedOutput); + Assert.Greater(sceneDoc.RootElement.GetArrayLength(), 0); + Assert.Greater(sharedDoc.RootElement.GetArrayLength(), 0); + Assert.AreNotEqual(sceneOutput, sharedOutput); + } + + [Test] + public async Task Archive_MultipleSerializedFiles_WithoutEntry_ListsEntries() + { + var (exitCode, _, err) = await RunCaptured("sf", "metadata", AssetBundlesPath("scenes")); + + Assert.AreNotEqual(0, exitCode); + StringAssert.Contains("The archive contains 4 SerializedFiles. Choose one with --entry", err); + StringAssert.Contains(" BuildPlayer-SampleScene.sharedAssets", err); + StringAssert.Contains(" BuildPlayer-OtherScene", err); + } + + [Test] + public async Task Archive_EntryNotFound_ListsEntries() + { + var (exitCode, _, err) = await RunCaptured("sf", "header", AssetBundlesPath("scenes"), "-e", "NoSuchFile"); + + Assert.AreNotEqual(0, exitCode); + StringAssert.Contains("\"NoSuchFile\" was not found in the archive.", err); + StringAssert.Contains(" BuildPlayer-SampleScene", err); + } + + [Test] + public async Task Archive_EntryNotSerializedFile_ReturnsError() + { + var (exitCode, _, err) = await RunCaptured("sf", "header", AssetBundlesPath("assetbundle"), "-e", "CAB-5d40f7cad7c871cf2ad2af19ac542994.resS"); + + Assert.AreNotEqual(0, exitCode); + StringAssert.Contains("is not a SerializedFile", err); + StringAssert.Contains(" CAB-5d40f7cad7c871cf2ad2af19ac542994", err); + } + + [Test] + public async Task Archive_EntryOnPlainSerializedFile_ReturnsError() + { + var (exitCode, _, err) = await RunCaptured("sf", "header", Path.Combine(m_TestDataFolder, "level0"), "-e", "level0"); + + Assert.AreNotEqual(0, exitCode); + StringAssert.Contains("--entry can only be used with a Unity Archive", err); + } + + #endregion +} diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index 4df344e..126de18 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -16,6 +16,7 @@ namespace UnityDataTools.UnityDataTool; public static class Program { const string TypeTreeDataDescription = "Path to an external TypeTree data file to load before processing bundles"; + const string EntryDescription = "Name of the SerializedFile inside the archive, as shown by 'archive list'. Needed when the archive contains more than one SerializedFile"; public static async Task Main(string[] args) { @@ -180,11 +181,12 @@ static Command BuildDumpCommand() var oOpt = new Option(aliases: new[] { "--output-path", "-o" }, description: "Output folder", getDefaultValue: () => new DirectoryInfo(Environment.CurrentDirectory)); var objectIdOpt = new Option(aliases: new[] { "--objectid", "-i" }, () => 0, "Only dump the object with this signed 64-bit id (default: 0, dump all objects)"); var typeOpt = new Option(aliases: new[] { "--type", "-t" }, description: "Filter by object type (ClassID number or type name)"); - var stdoutOpt = new Option(aliases: new[] { "--stdout" }, description: "Write the dump to stdout instead of a file. Refused for archives that contain more than one SerializedFile."); + var stdoutOpt = new Option(aliases: new[] { "--stdout" }, description: "Write the dump to stdout instead of a file. For an archive with more than one SerializedFile, choose one with --entry."); + var entryOpt = new Option(aliases: new[] { "--entry", "-e" }, description: EntryDescription); var dOpt = new Option(aliases: new[] { "--typetree-data", "-d" }, description: TypeTreeDataDescription); var dumpCommand = new Command("dump", - "Dump serialized objects from a SerializedFile as text.\nFor an archive, dumps the objects from each SerializedFile inside;\nother archive content is ignored (use archive extract for that).") + "Dump serialized objects from a SerializedFile as text.\nFor an archive, dumps the objects from each SerializedFile inside,\nor only from the one chosen with --entry; other archive content is ignored (use archive extract for that).") { pathArg, fOpt, @@ -196,6 +198,7 @@ static Command BuildDumpCommand() typeOpt, dOpt, stdoutOpt, + entryOpt, }; dumpCommand.AddValidator(commandResult => { @@ -209,7 +212,7 @@ static Command BuildDumpCommand() } }); dumpCommand.SetHandler( - (FileInfo fi, TextDumperTool.DumpFormat f, bool a, bool x, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout) => + (FileInfo fi, TextDumperTool.DumpFormat f, bool a, bool x, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout, string entry) => { var ttResult = LoadTypeTreeDataFile(d); if (ttResult != 0) return Task.FromResult(ttResult); @@ -223,10 +226,11 @@ static Command BuildDumpCommand() ObjectId = objectId, TypeFilter = type, ToStdout = toStdout, + Entry = entry, }; return Task.FromResult(HandleDump(options)); }, - pathArg, fOpt, aOpt, xOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt); + pathArg, fOpt, aOpt, xOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt, entryOpt); return dumpCommand; } @@ -297,44 +301,49 @@ static Command BuildArchiveCommand() static Command BuildSerializedFileCommand() { - var pathArg = new Argument("filename", "The path of the SerializedFile").ExistingOnly(); + var pathArg = new Argument("filename", "The path of the SerializedFile, or of an archive that contains it").ExistingOnly(); var fOpt = new Option(aliases: new[] { "--format", "-f" }, description: "Output format", getDefaultValue: () => SerializedFileTool.OutputFormat.Text); + var entryOpt = new Option(aliases: new[] { "--entry", "-e" }, description: EntryDescription); var externalRefsCommand = new Command("externalrefs", "List external file references in a SerializedFile.") { pathArg, + entryOpt, fOpt, }; externalRefsCommand.SetHandler( - (FileInfo fi, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.ListExternalRefs(fi, f)), - pathArg, fOpt); + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.ListExternalRefs(fi, entry, f)), + pathArg, entryOpt, fOpt); var objectListCommand = new Command("objectlist", "List all objects in a SerializedFile.") { pathArg, + entryOpt, fOpt, }; objectListCommand.SetHandler( - (FileInfo fi, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.ListObjects(fi, f)), - pathArg, fOpt); + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.ListObjects(fi, entry, f)), + pathArg, entryOpt, fOpt); var headerCommand = new Command("header", "Show SerializedFile header information.") { pathArg, + entryOpt, fOpt, }; headerCommand.SetHandler( - (FileInfo fi, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.PrintHeader(fi, f)), - pathArg, fOpt); + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.PrintHeader(fi, entry, f)), + pathArg, entryOpt, fOpt); var metadataCommand = new Command("metadata", "Show information from the metadata section of the SerializedFile (use `-f Json` for detailed information).") { pathArg, + entryOpt, fOpt, }; metadataCommand.SetHandler( - (FileInfo fi, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.PrintMetadata(fi, f)), - pathArg, fOpt); + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.PrintMetadata(fi, entry, f)), + pathArg, entryOpt, fOpt); var serializedFileCommand = new Command("serialized-file", "Inspect a SerializedFile (scene, assets, etc.).") { From b0a18d82e473b58a4903f31f9c13bc6ec81ed38a Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Tue, 29 Sep 2026 11:28:29 -0400 Subject: [PATCH 2/2] [#151] Rename helper to MountedSerializedFile; report selection errors from the CLI layer --- SerializedFile/SerializedFileTool.cs | 29 ++---- TextDumper/TextDumperTool.cs | 24 ++--- UnityBinaryFormat/ArchiveSerializedFile.cs | 93 ------------------- UnityBinaryFormat/MountedSerializedFile.cs | 102 +++++++++++++++++++++ UnityDataTool/Program.cs | 43 ++++++++- 5 files changed, 156 insertions(+), 135 deletions(-) delete mode 100644 UnityBinaryFormat/ArchiveSerializedFile.cs create mode 100644 UnityBinaryFormat/MountedSerializedFile.cs diff --git a/SerializedFile/SerializedFileTool.cs b/SerializedFile/SerializedFileTool.cs index 7e4d1da..438fd73 100644 --- a/SerializedFile/SerializedFileTool.cs +++ b/SerializedFile/SerializedFileTool.cs @@ -121,18 +121,19 @@ private sealed class OpenedSerializedFile : IDisposable public Stream Stream { get; init; } public SerializedFileInfo Info { get; init; } public string DisplayName { get; init; } - public ArchiveSerializedFile ArchiveFile { get; init; } + public MountedSerializedFile MountedFile { get; init; } public void Dispose() { // The stream reads through the mount, so close it before unmounting. Stream.Dispose(); - ArchiveFile?.Dispose(); + MountedFile?.Dispose(); } } // Opens the file as a SerializedFile, or the SerializedFile chosen by entry when the file is an - // archive. Prints a helpful error and returns null when that is not possible. + // archive. Prints a helpful error and returns null when that is not possible, except that a failed + // selection inside the archive throws SerializedFileSelectionException. private static OpenedSerializedFile OpenSerializedFile(string filePath, string entry) { if (!File.Exists(filePath)) @@ -143,28 +144,18 @@ private static OpenedSerializedFile OpenSerializedFile(string filePath, string e Stream stream; string displayName; - ArchiveSerializedFile archiveFile = null; + MountedSerializedFile mountedFile = null; if (ArchiveDetector.IsUnityArchive(filePath)) { - if (!ArchiveSerializedFile.TryOpen(filePath, entry, out archiveFile, out var archiveError)) - { - Console.Error.WriteLine(archiveError); - return null; - } + mountedFile = MountedSerializedFile.Open(filePath, entry); // The metadata parser reads one small value at a time, and every unbuffered read is a native call. - stream = new BufferedStream(new UnityFileStream(archiveFile.MountedPath), 64 * 1024); - displayName = $"{archiveFile.Name} in {filePath}"; + stream = new BufferedStream(new UnityFileStream(mountedFile.MountedPath), 64 * 1024); + displayName = $"{mountedFile.PathInArchive} in {filePath}"; } else { - if (entry != null) - { - Console.Error.WriteLine(ArchiveSerializedFile.EntryWithoutArchiveError(filePath)); - return null; - } - if (YamlSerializedFileDetector.IsYamlSerializedFile(filePath)) { Console.Error.WriteLine($"Error: The file is a YAML-format SerializedFile, which is not supported."); @@ -183,11 +174,11 @@ private static OpenedSerializedFile OpenSerializedFile(string filePath, string e Console.Error.WriteLine($"Error: The file does not appear to be a valid Unity SerializedFile."); Console.Error.WriteLine($"File: {displayName}"); stream.Dispose(); - archiveFile?.Dispose(); + mountedFile?.Dispose(); return null; } - return new OpenedSerializedFile { Stream = stream, Info = info, DisplayName = displayName, ArchiveFile = archiveFile }; + return new OpenedSerializedFile { Stream = stream, Info = info, DisplayName = displayName, MountedFile = mountedFile }; } private static void OutputExternalRefsText(ExternalReference[] refs) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index fb2f656..cf4085f 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -62,12 +62,6 @@ public int Dump(DumpOptions options) if (ArchiveDetector.IsUnityArchive(m_Options.Path)) return m_Options.Entry != null || m_Options.ToStdout ? DumpArchiveSerializedFile() : DumpArchive(); - if (m_Options.Entry != null) - { - Console.Error.WriteLine(ArchiveSerializedFile.EntryWithoutArchiveError(m_Options.Path)); - return 1; - } - if (YamlSerializedFileDetector.IsYamlSerializedFile(m_Options.Path)) { Console.Error.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); @@ -82,7 +76,8 @@ public int Dump(DumpOptions options) Console.Error.WriteLine($"File: {m_Options.Path}"); return 1; } - catch (Exception e) + // The caller reports a failed selection in terms of its own options. + catch (Exception e) when (e is not SerializedFileSelectionException) { Console.Error.WriteLine($"Error: {e.GetType()}: {e.Message}"); Console.Error.WriteLine(e.StackTrace); @@ -101,19 +96,12 @@ int DumpSerializedFile() // Dumps one SerializedFile from an archive, chosen with --entry or because it is the only one. int DumpArchiveSerializedFile() { - if (!ArchiveSerializedFile.TryOpen(m_Options.Path, m_Options.Entry, out var archiveFile, out var errorMessage)) - { - Console.Error.WriteLine(errorMessage); - return 1; - } + using var serializedFile = MountedSerializedFile.Open(m_Options.Path, m_Options.Entry); - using (archiveFile) - { - if (ReportIfNotDumpable(archiveFile.MountedPath, archiveFile.Name)) - return 1; + if (ReportIfNotDumpable(serializedFile.MountedPath, serializedFile.PathInArchive)) + return 1; - return WriteDump(archiveFile.MountedPath, archiveFile.Name); - } + return WriteDump(serializedFile.MountedPath, serializedFile.PathInArchive); } // Writes the dump of one SerializedFile to stdout or to ".txt" in the output folder. diff --git a/UnityBinaryFormat/ArchiveSerializedFile.cs b/UnityBinaryFormat/ArchiveSerializedFile.cs deleted file mode 100644 index f6dd641..0000000 --- a/UnityBinaryFormat/ArchiveSerializedFile.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using UnityDataTools.FileSystem; - -namespace UnityDataTools.BinaryFormat; - -// A SerializedFile inside a mounted Unity Archive, so that commands can read it without extracting -// the archive. The archive stays mounted until this object is disposed. -public sealed class ArchiveSerializedFile : IDisposable -{ - const string MountPoint = "/"; - - UnityArchive m_Archive; - - // Path of the SerializedFile inside the archive, as shown by `archive list`. - public string Name { get; } - - // Path to pass to UnityFileSystem / UnityFileStream while the archive is mounted. - public string MountedPath => MountPoint + Name; - - ArchiveSerializedFile(UnityArchive archive, string name) - { - m_Archive = archive; - Name = name; - } - - // Mounts the archive and selects the SerializedFile named by entry. When entry is null the archive - // must contain exactly one SerializedFile. On failure the archive is unmounted and errorMessage - // explains the problem, listing the SerializedFiles that can be chosen. - public static bool TryOpen(string archivePath, string entry, out ArchiveSerializedFile result, out string errorMessage) - { - result = null; - var archive = UnityFileSystem.MountArchive(archivePath, MountPoint); - - var nodes = archive.Nodes; - var serializedFiles = nodes - .Where(n => n.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) - .Select(n => n.Path) - .ToList(); - - errorMessage = null; - if (serializedFiles.Count == 0) - { - errorMessage = "Error: The archive contains no SerializedFiles."; - } - else if (entry != null) - { - if (serializedFiles.Contains(entry)) - result = new ArchiveSerializedFile(archive, entry); - else if (nodes.Any(n => n.Path == entry)) - errorMessage = FormatError($"\"{entry}\" in the archive is not a SerializedFile.", serializedFiles); - else - errorMessage = FormatError($"\"{entry}\" was not found in the archive.", serializedFiles); - } - else if (serializedFiles.Count == 1) - { - result = new ArchiveSerializedFile(archive, serializedFiles[0]); - } - else - { - errorMessage = FormatError( - $"The archive contains {serializedFiles.Count} SerializedFiles. Choose one with --entry, for example --entry \"{serializedFiles[0]}\".", - serializedFiles); - } - - if (result == null) - archive.Dispose(); - - return result != null; - } - - // Error for --entry given with a file that is not a Unity Archive. - public static string EntryWithoutArchiveError(string filePath) => - $"Error: --entry can only be used with a Unity Archive, and this file is not one.{Environment.NewLine}File: {filePath}"; - - static string FormatError(string message, List serializedFiles) - { - var sb = new StringBuilder(); - sb.Append("Error: ").AppendLine(message); - sb.Append("SerializedFiles in the archive:"); - foreach (var name in serializedFiles) - sb.AppendLine().Append(" ").Append(name); - return sb.ToString(); - } - - public void Dispose() - { - m_Archive?.Dispose(); - m_Archive = null; - } -} diff --git a/UnityBinaryFormat/MountedSerializedFile.cs b/UnityBinaryFormat/MountedSerializedFile.cs new file mode 100644 index 0000000..e30bbcf --- /dev/null +++ b/UnityBinaryFormat/MountedSerializedFile.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityDataTools.FileSystem; + +namespace UnityDataTools.BinaryFormat; + +// Gives access to one SerializedFile inside a Unity Archive without extracting it, by keeping the +// archive mounted until this object is disposed. +public sealed class MountedSerializedFile : IDisposable +{ + const string MountPoint = "/"; + + UnityArchive m_Archive; + + // Path of the SerializedFile inside the archive, as shown by `archive list`, e.g. "CAB-". + public string PathInArchive { get; } + + // Path to pass to UnityFileSystem / UnityFileStream while the archive is mounted. + public string MountedPath => MountPoint + PathInArchive; + + MountedSerializedFile(UnityArchive archive, string pathInArchive) + { + m_Archive = archive; + PathInArchive = pathInArchive; + } + + // Mounts the archive and selects the SerializedFile at pathInArchive. When pathInArchive is null the + // archive must contain exactly one SerializedFile. Throws SerializedFileSelectionException when + // no SerializedFile can be selected, after unmounting the archive. + public static MountedSerializedFile Open(string archivePath, string pathInArchive) + { + var archive = UnityFileSystem.MountArchive(archivePath, MountPoint); + + var nodes = archive.Nodes; + var serializedFiles = nodes + .Where(n => n.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) + .Select(n => n.Path) + .ToList(); + + SerializedFileSelectionFailure failure; + if (serializedFiles.Count == 0) + failure = SerializedFileSelectionFailure.NoSerializedFiles; + else if (pathInArchive == null) + failure = serializedFiles.Count == 1 ? SerializedFileSelectionFailure.None : SerializedFileSelectionFailure.MultipleSerializedFiles; + else if (serializedFiles.Contains(pathInArchive)) + failure = SerializedFileSelectionFailure.None; + else if (nodes.Any(n => n.Path == pathInArchive)) + failure = SerializedFileSelectionFailure.NotSerializedFile; + else + failure = SerializedFileSelectionFailure.NotFound; + + if (failure != SerializedFileSelectionFailure.None) + { + archive.Dispose(); + throw new SerializedFileSelectionException(failure, archivePath, pathInArchive, serializedFiles); + } + + return new MountedSerializedFile(archive, pathInArchive ?? serializedFiles[0]); + } + + public void Dispose() + { + m_Archive?.Dispose(); + m_Archive = null; + } +} + +public enum SerializedFileSelectionFailure +{ + None, + NoSerializedFiles, + MultipleSerializedFiles, + NotFound, + NotSerializedFile, +} + +// Thrown by MountedSerializedFile.Open. Carries enough detail for the caller to explain how to +// select a SerializedFile in its own terms. +public class SerializedFileSelectionException : Exception +{ + public SerializedFileSelectionFailure Failure { get; } + public string ArchivePath { get; } + public string RequestedPath { get; } + public IReadOnlyList SerializedFiles { get; } + + public SerializedFileSelectionException(SerializedFileSelectionFailure failure, string archivePath, string requestedPath, IReadOnlyList serializedFiles) + : base(failure switch + { + SerializedFileSelectionFailure.NoSerializedFiles => "The archive contains no SerializedFiles.", + SerializedFileSelectionFailure.MultipleSerializedFiles => $"The archive contains {serializedFiles.Count} SerializedFiles.", + SerializedFileSelectionFailure.NotFound => $"\"{requestedPath}\" was not found in the archive.", + SerializedFileSelectionFailure.NotSerializedFile => $"\"{requestedPath}\" in the archive is not a SerializedFile.", + _ => "No SerializedFile could be selected in the archive.", + }) + { + Failure = failure; + ArchivePath = archivePath; + RequestedPath = requestedPath; + SerializedFiles = serializedFiles; + } +} diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index 126de18..a887973 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using UnityDataTools.Analyzer; using UnityDataTools.Archive; +using UnityDataTools.BinaryFormat; using UnityDataTools.FileSystem; using UnityDataTools.ReferenceFinder; using UnityDataTools.SerializedFile; @@ -228,7 +229,7 @@ static Command BuildDumpCommand() ToStdout = toStdout, Entry = entry, }; - return Task.FromResult(HandleDump(options)); + return Task.FromResult(RunWithEntry(fi, entry, () => HandleDump(options))); }, pathArg, fOpt, aOpt, xOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt, entryOpt); @@ -312,7 +313,7 @@ static Command BuildSerializedFileCommand() fOpt, }; externalRefsCommand.SetHandler( - (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.ListExternalRefs(fi, entry, f)), + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(RunWithEntry(fi, entry, () => SerializedFileTool.ListExternalRefs(fi, entry, f))), pathArg, entryOpt, fOpt); var objectListCommand = new Command("objectlist", "List all objects in a SerializedFile.") @@ -322,7 +323,7 @@ static Command BuildSerializedFileCommand() fOpt, }; objectListCommand.SetHandler( - (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.ListObjects(fi, entry, f)), + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(RunWithEntry(fi, entry, () => SerializedFileTool.ListObjects(fi, entry, f))), pathArg, entryOpt, fOpt); var headerCommand = new Command("header", "Show SerializedFile header information.") @@ -332,7 +333,7 @@ static Command BuildSerializedFileCommand() fOpt, }; headerCommand.SetHandler( - (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.PrintHeader(fi, entry, f)), + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(RunWithEntry(fi, entry, () => SerializedFileTool.PrintHeader(fi, entry, f))), pathArg, entryOpt, fOpt); var metadataCommand = new Command("metadata", "Show information from the metadata section of the SerializedFile (use `-f Json` for detailed information).") @@ -342,7 +343,7 @@ static Command BuildSerializedFileCommand() fOpt, }; metadataCommand.SetHandler( - (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(SerializedFileTool.PrintMetadata(fi, entry, f)), + (FileInfo fi, string entry, SerializedFileTool.OutputFormat f) => Task.FromResult(RunWithEntry(fi, entry, () => SerializedFileTool.PrintMetadata(fi, entry, f))), pathArg, entryOpt, fOpt); var serializedFileCommand = new Command("serialized-file", "Inspect a SerializedFile (scene, assets, etc.).") @@ -356,6 +357,38 @@ static Command BuildSerializedFileCommand() return serializedFileCommand; } + // Runs a command that accepts --entry, and explains in terms of --entry why no SerializedFile + // could be selected inside the archive. + static int RunWithEntry(FileInfo file, string entry, Func command) + { + if (entry != null && !ArchiveDetector.IsUnityArchive(file.FullName)) + { + Console.Error.WriteLine("Error: --entry can only be used with a Unity Archive, and this file is not one."); + Console.Error.WriteLine($"File: {file.FullName}"); + return 1; + } + + try + { + return command(); + } + catch (SerializedFileSelectionException e) + { + if (e.Failure == SerializedFileSelectionFailure.MultipleSerializedFiles) + Console.Error.WriteLine($"Error: {e.Message} Choose one with --entry, for example --entry \"{e.SerializedFiles[0]}\"."); + else + Console.Error.WriteLine($"Error: {e.Message}"); + + if (e.SerializedFiles.Count > 0) + { + Console.Error.WriteLine("SerializedFiles in the archive:"); + foreach (var path in e.SerializedFiles) + Console.Error.WriteLine($" {path}"); + } + return 1; + } + } + static int LoadTypeTreeDataFile(FileInfo typeTreeDataFile) { if (typeTreeDataFile == null)