From cc67b4e48b9652737ca3b14b268a29fb5d3e1af9 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Thu, 14 May 2026 16:23:52 -0400 Subject: [PATCH 01/10] Add --stdout argument to UnityDataTool dump Support easier piping. --- Documentation/command-dump.md | 21 ++++++++++ TextDumper/TextDumperTool.cs | 79 +++++++++++++++++++++++++++-------- UnityDataTool/Program.cs | 26 +++++++++--- 3 files changed, 102 insertions(+), 24 deletions(-) diff --git a/Documentation/command-dump.md b/Documentation/command-dump.md index 6f1d0d3..de0f41d 100644 --- a/Documentation/command-dump.md +++ b/Documentation/command-dump.md @@ -12,6 +12,7 @@ UnityDataTool dump [options] |--------|-------------|---------| | `` | Path to file to dump | *(required)* | | `-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` | | `-s, --skip-large-arrays` | Skip dumping large arrays | `false` | | `-i, --objectid ` | Only dump object with this ID | All objects | @@ -55,6 +56,26 @@ Dump the AssetBundle manifest object: UnityDataTool dump mybundle -t AssetBundle ``` +Write the dump to stdout and pipe it through another tool: +```bash +UnityDataTool dump /path/to/file --stdout | grep "ClassID: 114" +``` + +--- + +## Writing to stdout + +Use `--stdout` to send the dump to standard output instead of writing a `.txt` file. Status messages (the `Processing ...` line, errors, and stack traces) are routed to stderr so the dump on stdout is clean for piping or redirecting. + +```bash +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. + --- ## Filtering by Type diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 4a8f7a2..6f08de8 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -12,9 +12,9 @@ public class TextDumperTool bool m_SkipLargeArrays; UnityFileReader m_Reader; SerializedFile m_SerializedFile; - StreamWriter m_Writer; + TextWriter m_Writer; - public int Dump(string path, string outputPath, bool skipLargeArrays, long objectId = 0, string typeFilter = null) + public int Dump(string path, string outputPath, bool skipLargeArrays, long objectId = 0, string typeFilter = null, bool toStdout = false) { if (string.IsNullOrWhiteSpace(typeFilter)) typeFilter = null; @@ -25,7 +25,7 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec { if (!File.Exists(path)) { - Console.WriteLine($"Error: File not found: {path}"); + Console.Error.WriteLine($"Error: File not found: {path}"); return 1; } @@ -33,14 +33,49 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec { // The input is a Unity archive (e.g. AssetBundle); dump each serialized file inside it. using var archive = UnityFileSystem.MountArchive(path, "/"); - foreach (var node in archive.Nodes) + + if (toStdout) { - Console.WriteLine($"Processing {node.Path} {node.Size} {node.Flags}"); + ArchiveNode? singleSerializedFile = null; + int serializedFileCount = 0; + foreach (var node in archive.Nodes) + { + if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) + { + ++serializedFileCount; + singleSerializedFile ??= node; + } + } - if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) + if (serializedFileCount == 0) { - using (m_Writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(node.Path) + ".txt"), false)) + 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; + } + + var node2 = singleSerializedFile.Value; + Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}"); + m_Writer = Console.Out; + OutputSerializedFile("/" + node2.Path, objectId, typeFilter); + m_Writer.Flush(); + } + else + { + foreach (var node in archive.Nodes) + { + Console.WriteLine($"Processing {node.Path} {node.Size} {node.Flags}"); + + if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) { + using var writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(node.Path) + ".txt"), false); + m_Writer = writer; OutputSerializedFile("/" + node.Path, objectId, typeFilter); } } @@ -48,8 +83,8 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec } else if (YamlSerializedFileDetector.IsYamlSerializedFile(path)) { - Console.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); - Console.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); + Console.Error.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); + Console.Error.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); return 1; } else if (SerializedFileDetector.TryDetectSerializedFile(path, out _)) @@ -57,8 +92,16 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec // The input is a binary SerializedFile; dump it directly. try { - using (m_Writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false)) + if (toStdout) + { + m_Writer = Console.Out; + OutputSerializedFile(path, objectId, typeFilter); + m_Writer.Flush(); + } + else { + using var writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false); + m_Writer = writer; OutputSerializedFile(path, objectId, typeFilter); } } @@ -67,29 +110,29 @@ public int Dump(string path, string outputPath, bool skipLargeArrays, long objec var hint = SerializedFileDetector.GetOpenFailureHint(path); if (hint != null) { - Console.WriteLine(); - Console.WriteLine(hint); + Console.Error.WriteLine(); + Console.Error.WriteLine(hint); } return 1; } catch (Exception e) { - Console.WriteLine($"Error: {e.GetType()}: {e.Message}"); - Console.WriteLine(e.StackTrace); + Console.Error.WriteLine($"Error: {e.GetType()}: {e.Message}"); + Console.Error.WriteLine(e.StackTrace); return 1; } } else { - Console.WriteLine("Error: The file does not appear to be a valid Unity SerializedFile or Unity Archive."); - Console.WriteLine($"File: {path}"); + Console.Error.WriteLine("Error: The file does not appear to be a valid Unity SerializedFile or Unity Archive."); + Console.Error.WriteLine($"File: {path}"); return 1; } } catch (Exception e) { - Console.WriteLine($"Error: {e.GetType()}: {e.Message}"); - Console.WriteLine(e.StackTrace); + Console.Error.WriteLine($"Error: {e.GetType()}: {e.Message}"); + Console.Error.WriteLine(e.StackTrace); return 1; } diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index fd5be86..bd4c1cf 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -93,10 +93,12 @@ public static async Task Main(string[] args) 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 dOpt = new Option(aliases: new[] { "--typetree-data", "-d" }, description: typeTreeDataDescription); - var dumpCommand = new Command("dump", "Dump the contents of an AssetBundle or SerializedFile.") + var dumpCommand = new Command("dump", + "Dump serialized objects from a SerializedFile as text. For an archive, dumps the objects from each SerializedFile\n ▎ inside; other archive content is ignored (use archive extract for that).") { pathArg, fOpt, @@ -105,15 +107,27 @@ public static async Task Main(string[] args) objectIdOpt, typeOpt, dOpt, + stdoutOpt, }; + dumpCommand.AddValidator(commandResult => + { + var stdoutResult = commandResult.FindResultFor(stdoutOpt); + var oResult = commandResult.FindResultFor(oOpt); + bool stdoutSet = stdoutResult is { IsImplicit: false }; + bool oExplicit = oResult is { IsImplicit: false }; + if (stdoutSet && oExplicit) + { + commandResult.ErrorMessage = "--stdout and -o/--output-path are mutually exclusive."; + } + }); dumpCommand.SetHandler( - (FileInfo fi, DumpFormat f, bool s, DirectoryInfo o, long objectId, string type, FileInfo d) => + (FileInfo fi, DumpFormat f, bool s, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout) => { var ttResult = LoadTypeTreeDataFile(d); if (ttResult != 0) return Task.FromResult(ttResult); - return Task.FromResult(HandleDump(fi, f, s, o, objectId, type)); + return Task.FromResult(HandleDump(fi, f, s, o, objectId, type, toStdout)); }, - pathArg, fOpt, sOpt, oOpt, objectIdOpt, typeOpt, dOpt); + pathArg, fOpt, sOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt); rootCommand.AddCommand(dumpCommand); } @@ -315,14 +329,14 @@ static int HandleFindReferences(FileInfo databasePath, string outputFile, long? } } - static int HandleDump(FileInfo filename, DumpFormat format, bool skipLargeArrays, DirectoryInfo outputFolder, long objectId = 0, string typeFilter = null) + static int HandleDump(FileInfo filename, DumpFormat format, bool skipLargeArrays, DirectoryInfo outputFolder, long objectId = 0, string typeFilter = null, bool toStdout = false) { switch (format) { case DumpFormat.Text: { var textDumper = new TextDumperTool(); - return textDumper.Dump(filename.FullName, outputFolder.FullName, skipLargeArrays, objectId, typeFilter); + return textDumper.Dump(filename.FullName, outputFolder.FullName, skipLargeArrays, objectId, typeFilter, toStdout); } } From 6788ecd26cf5fb6ee17c3d67dd5b5898989b0fc3 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Thu, 14 May 2026 17:05:03 -0400 Subject: [PATCH 02/10] Refactor Main() and arguments to Dump Split Main() into individual methods for each command. Use a structure to pass options to TextDumperTool --- Documentation/textdumper.md | 14 +- TextDumper/TextDumperTool.cs | 26 +- UnityDataTool/Program.cs | 464 +++++++++++++++++------------------ 3 files changed, 259 insertions(+), 245 deletions(-) diff --git a/Documentation/textdumper.md b/Documentation/textdumper.md index 3ef490c..8c13a6c 100644 --- a/Documentation/textdumper.md +++ b/Documentation/textdumper.md @@ -5,12 +5,14 @@ file (AssetBundle or SerializedFile) into human-readable yaml-style text file. ## How to use -The library consists of a single class called [TextDumperTool](../TextDumper/TextDumperTool.cs). It has a method named Dump and takes five parameters: -* path (string): path of the data file. -* outputPath (string): path where the output files will be created. -* skipLargeArrays (bool): if true, the content of arrays larger than 1KB won't be dumped. -* objectId (long, optional): if specified and not 0, only the object with this signed 64-bit id will be dumped. If 0 (default), all objects are dumped. -* typeFilter (string, optional): if specified, only objects matching this type are dumped. Accepts a numeric ClassID (e.g. 114) or a type name (e.g. MonoBehaviour, case-insensitive). +The library consists of a single class called [TextDumperTool](../TextDumper/TextDumperTool.cs). Call its `Dump` method, passing a `TextDumperTool.DumpOptions` object with the following properties: +* `Format` (DumpFormat, optional): output format. Defaults to `Text`. +* `Path` (string): path of the data file. +* `OutputPath` (string): path where the output files will be created. Ignored when `ToStdout` is true. +* `SkipLargeArrays` (bool): if true, the content of arrays larger than 1KB won't be dumped. +* `ObjectId` (long, optional): if specified and not 0, only the object with this signed 64-bit id will be dumped. If 0 (default), all objects are dumped. +* `TypeFilter` (string, optional): if specified, only objects matching this type are dumped. Accepts a numeric ClassID (e.g. 114) or a type name (e.g. MonoBehaviour, case-insensitive). +* `ToStdout` (bool, optional): if true, the dump is written to standard output instead of a file. Refused for archives that contain more than one SerializedFile. ## How to interpret the output files diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 6f08de8..6fab2a4 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -14,12 +14,34 @@ public class TextDumperTool SerializedFile m_SerializedFile; TextWriter m_Writer; - public int Dump(string path, string outputPath, bool skipLargeArrays, long objectId = 0, string typeFilter = null, bool toStdout = false) + public enum DumpFormat { + Text, + } + + public class DumpOptions + { + public DumpFormat Format { get; init; } = DumpFormat.Text; + public string Path { get; init; } + public string OutputPath { get; init; } + public bool SkipLargeArrays { get; init; } + public long ObjectId { get; init; } + public string TypeFilter { get; init; } + public bool ToStdout { get; init; } + } + + public int Dump(DumpOptions options) + { + var path = options.Path; + var outputPath = options.OutputPath; + var objectId = options.ObjectId; + var typeFilter = options.TypeFilter; + var toStdout = options.ToStdout; + if (string.IsNullOrWhiteSpace(typeFilter)) typeFilter = null; - m_SkipLargeArrays = skipLargeArrays; + m_SkipLargeArrays = options.SkipLargeArrays; try { diff --git a/UnityDataTool/Program.cs b/UnityDataTool/Program.cs index bd4c1cf..2d5d591 100644 --- a/UnityDataTool/Program.cs +++ b/UnityDataTool/Program.cs @@ -17,259 +17,258 @@ public enum OutputFormat public static class Program { + const string TypeTreeDataDescription = "Path to an external TypeTree data file to load before processing bundles"; + public static async Task Main(string[] args) { UnityFileSystem.Init(); var rootCommand = new RootCommand(); + rootCommand.AddCommand(BuildAnalyzeCommand()); + rootCommand.AddCommand(BuildFindRefsCommand()); + rootCommand.AddCommand(BuildDumpCommand()); + rootCommand.AddCommand(BuildArchiveCommand()); + rootCommand.AddCommand(BuildSerializedFileCommand()); - var typeTreeDataDescription = "Path to an external TypeTree data file to load before processing bundles"; + var r = await rootCommand.InvokeAsync(args); - { - var pathArg = new Argument("path", "The path to the directory containing the files to analyze").ExistingOnly(); - var oOpt = new Option(aliases: new[] { "--output-file", "-o" }, description: "Filename of the output database", getDefaultValue: () => "database.db"); - var sOpt = new Option(aliases: new[] { "--skip-references", "-s" }, description: "Skip CRC and do not extract references"); - var rOpt = new Option(aliases: new[] { "--extract-references", "-r" }) { IsHidden = true }; - var pOpt = new Option(aliases: new[] { "--search-pattern", "-p" }, description: "File search pattern", getDefaultValue: () => "*"); - var vOpt = new Option(aliases: new[] { "--verbose", "-v" }, description: "Verbose output"); - var recurseOpt = new Option(aliases: new[] { "--no-recurse" }, description: "Do not analyze contents of subdirectories inside path"); + UnityFileSystem.Cleanup(); - var dOpt = new Option(aliases: new[] { "--typetree-data", "-d" }, description: typeTreeDataDescription); + return r; + } - var analyzeCommand = new Command("analyze", "Analyze AssetBundles or SerializedFiles.") + static Command BuildAnalyzeCommand() + { + var pathArg = new Argument("path", "The path to the directory containing the files to analyze").ExistingOnly(); + var oOpt = new Option(aliases: new[] { "--output-file", "-o" }, description: "Filename of the output database", getDefaultValue: () => "database.db"); + var sOpt = new Option(aliases: new[] { "--skip-references", "-s" }, description: "Skip CRC and do not extract references"); + var rOpt = new Option(aliases: new[] { "--extract-references", "-r" }) { IsHidden = true }; + var pOpt = new Option(aliases: new[] { "--search-pattern", "-p" }, description: "File search pattern", getDefaultValue: () => "*"); + var vOpt = new Option(aliases: new[] { "--verbose", "-v" }, description: "Verbose output"); + var recurseOpt = new Option(aliases: new[] { "--no-recurse" }, description: "Do not analyze contents of subdirectories inside path"); + var dOpt = new Option(aliases: new[] { "--typetree-data", "-d" }, description: TypeTreeDataDescription); + + var analyzeCommand = new Command("analyze", "Analyze AssetBundles or SerializedFiles.") + { + pathArg, + oOpt, + sOpt, + rOpt, + pOpt, + vOpt, + recurseOpt, + dOpt + }; + + analyzeCommand.AddAlias("analyse"); + analyzeCommand.SetHandler( + (DirectoryInfo di, string o, bool s, bool r, string p, bool v, bool noRecurse, FileInfo d) => { - pathArg, - oOpt, - sOpt, - rOpt, - pOpt, - vOpt, - recurseOpt, - dOpt - }; - - analyzeCommand.AddAlias("analyse"); - analyzeCommand.SetHandler( - (DirectoryInfo di, string o, bool s, bool r, string p, bool v, bool noRecurse, FileInfo d) => - { - var ttResult = LoadTypeTreeDataFile(d); - if (ttResult != 0) return Task.FromResult(ttResult); - return Task.FromResult(HandleAnalyze(di, o, s, r, p, v, noRecurse)); - }, - pathArg, oOpt, sOpt, rOpt, pOpt, vOpt, recurseOpt, dOpt); + var ttResult = LoadTypeTreeDataFile(d); + if (ttResult != 0) return Task.FromResult(ttResult); + return Task.FromResult(HandleAnalyze(di, o, s, r, p, v, noRecurse)); + }, + pathArg, oOpt, sOpt, rOpt, pOpt, vOpt, recurseOpt, dOpt); - rootCommand.AddCommand(analyzeCommand); - } + return analyzeCommand; + } + static Command BuildFindRefsCommand() + { + var pathArg = new Argument("databasePath", "The path to the database generated by the 'analyze' command").ExistingOnly(); + var oOpt = new Option(aliases: new[] { "--output-file", "-o" }, description: "Output file", getDefaultValue: () => "references.txt"); + var iOpt = new Option(aliases: new[] { "--object-id", "-i" }, description: "Object id ('id' column in the database)"); + var nOpt = new Option(aliases: new[] { "--object-name", "-n" }, description: "Object name"); + var tOpt = new Option(aliases: new[] { "--object-type", "-t" }, description: "Optional object type when searching by name"); + var aOpt = new Option(aliases: new[] { "--find-all", "-a" }, description: "Find all reference chains originating from the same asset (instead of only one), can be very slow"); + + var findRefsCommand = new Command("find-refs", "Find reference chains to specified object(s).") { - var pathArg = new Argument("databasePath", "The path to the database generated by the 'analyze' command").ExistingOnly(); - var oOpt = new Option(aliases: new[] { "--output-file", "-o" }, description: "Output file", getDefaultValue: () => "references.txt"); - var iOpt = new Option(aliases: new[] { "--object-id", "-i" }, description: "Object id ('id' column in the database)"); - var nOpt = new Option(aliases: new[] { "--object-name", "-n" }, description: "Object name"); - var tOpt = new Option(aliases: new[] { "--object-type", "-t" }, description: "Optional object type when searching by name"); - var aOpt = new Option(aliases: new[] { "--find-all", "-a" }, description: "Find all reference chains originating from the same asset (instead of only one), can be very slow"); - - var findRefsCommand = new Command("find-refs", "Find reference chains to specified object(s).") - { - pathArg, - oOpt, - aOpt, - nOpt, - tOpt, - iOpt, - }; - - findRefsCommand.SetHandler( - (FileInfo fi, string o, long? i, string n, string t, bool a) => Task.FromResult(HandleFindReferences(fi, o, i, n, t, a)), - pathArg, oOpt, iOpt, nOpt, tOpt, aOpt); - - rootCommand.Add(findRefsCommand); - } + pathArg, + oOpt, + aOpt, + nOpt, + tOpt, + iOpt, + }; + + findRefsCommand.SetHandler( + (FileInfo fi, string o, long? i, string n, string t, bool a) => Task.FromResult(HandleFindReferences(fi, o, i, n, t, a)), + pathArg, oOpt, iOpt, nOpt, tOpt, aOpt); + + return findRefsCommand; + } + static Command BuildDumpCommand() + { + var pathArg = new Argument("filename", "The path of the file to dump").ExistingOnly(); + var fOpt = new Option(aliases: new[] { "--output-format", "-f" }, description: "Output format", getDefaultValue: () => TextDumperTool.DumpFormat.Text); + var sOpt = new Option(aliases: new[] { "--skip-large-arrays", "-s" }, description: "Do not dump large arrays of basic data types"); + 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 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).") + { + pathArg, + fOpt, + sOpt, + oOpt, + objectIdOpt, + typeOpt, + dOpt, + stdoutOpt, + }; + dumpCommand.AddValidator(commandResult => { - var pathArg = new Argument("filename", "The path of the file to dump").ExistingOnly(); - var fOpt = new Option(aliases: new[] { "--output-format", "-f" }, description: "Output format", getDefaultValue: () => DumpFormat.Text); - var sOpt = new Option(aliases: new[] { "--skip-large-arrays", "-s" }, description: "Do not dump large arrays of basic data types"); - 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 dOpt = new Option(aliases: new[] { "--typetree-data", "-d" }, description: typeTreeDataDescription); - - var dumpCommand = new Command("dump", - "Dump serialized objects from a SerializedFile as text. For an archive, dumps the objects from each SerializedFile\n ▎ inside; other archive content is ignored (use archive extract for that).") + var stdoutResult = commandResult.FindResultFor(stdoutOpt); + var oResult = commandResult.FindResultFor(oOpt); + bool stdoutSet = stdoutResult is { IsImplicit: false }; + bool oExplicit = oResult is { IsImplicit: false }; + if (stdoutSet && oExplicit) { - pathArg, - fOpt, - sOpt, - oOpt, - objectIdOpt, - typeOpt, - dOpt, - stdoutOpt, - }; - dumpCommand.AddValidator(commandResult => + commandResult.ErrorMessage = "--stdout and -o/--output-path are mutually exclusive."; + } + }); + dumpCommand.SetHandler( + (FileInfo fi, TextDumperTool.DumpFormat f, bool s, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout) => { - var stdoutResult = commandResult.FindResultFor(stdoutOpt); - var oResult = commandResult.FindResultFor(oOpt); - bool stdoutSet = stdoutResult is { IsImplicit: false }; - bool oExplicit = oResult is { IsImplicit: false }; - if (stdoutSet && oExplicit) + var ttResult = LoadTypeTreeDataFile(d); + if (ttResult != 0) return Task.FromResult(ttResult); + var options = new TextDumperTool.DumpOptions { - commandResult.ErrorMessage = "--stdout and -o/--output-path are mutually exclusive."; - } - }); - dumpCommand.SetHandler( - (FileInfo fi, DumpFormat f, bool s, DirectoryInfo o, long objectId, string type, FileInfo d, bool toStdout) => - { - var ttResult = LoadTypeTreeDataFile(d); - if (ttResult != 0) return Task.FromResult(ttResult); - return Task.FromResult(HandleDump(fi, f, s, o, objectId, type, toStdout)); - }, - pathArg, fOpt, sOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt); + Format = f, + Path = fi.FullName, + OutputPath = o.FullName, + SkipLargeArrays = s, + ObjectId = objectId, + TypeFilter = type, + ToStdout = toStdout, + }; + return Task.FromResult(HandleDump(options)); + }, + pathArg, fOpt, sOpt, oOpt, objectIdOpt, typeOpt, dOpt, stdoutOpt); + + return dumpCommand; + } - rootCommand.AddCommand(dumpCommand); - } + static Command BuildArchiveCommand() + { + var pathArg = new Argument("filename", "The path of the archive file").ExistingOnly(); + var oOpt = new Option(aliases: new[] { "--output-path", "-o" }, description: "Output directory of the extracted archive", getDefaultValue: () => new DirectoryInfo("archive")); + var filterOpt = new Option(aliases: new[] { "--filter" }, description: "Case-insensitive substring filter on file paths inside the archive"); + var extractArchiveCommand = new Command("extract", "Extract an AssetBundle or .data file.") { - var pathArg = new Argument("filename", "The path of the archive file").ExistingOnly(); - var oOpt = new Option(aliases: new[] { "--output-path", "-o" }, description: "Output directory of the extracted archive", getDefaultValue: () => new DirectoryInfo("archive")); - - var filterOpt = new Option(aliases: new[] { "--filter" }, description: "Case-insensitive substring filter on file paths inside the archive"); - - var extractArchiveCommand = new Command("extract", "Extract an AssetBundle or .data file.") - { - pathArg, - oOpt, - filterOpt, - }; - - extractArchiveCommand.SetHandler( - (FileInfo fi, DirectoryInfo o, string filter) => Task.FromResult(Archive.HandleExtract(fi, o, filter)), - pathArg, oOpt, filterOpt); - - var fOpt = new Option(aliases: new[] { "--format", "-f" }, description: "Output format", getDefaultValue: () => OutputFormat.Text); - - var listArchiveCommand = new Command("list", "List the contents of an AssetBundle or .data file.") - { - pathArg, - fOpt, - }; - - listArchiveCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleList(fi, f)), - pathArg, fOpt); + pathArg, + oOpt, + filterOpt, + }; + extractArchiveCommand.SetHandler( + (FileInfo fi, DirectoryInfo o, string filter) => Task.FromResult(Archive.HandleExtract(fi, o, filter)), + pathArg, oOpt, filterOpt); - var headerArchiveCommand = new Command("header", "Display the header of a Unity Archive file.") - { - pathArg, - fOpt, - }; - - headerArchiveCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleHeader(fi, f)), - pathArg, fOpt); - - var blocksArchiveCommand = new Command("blocks", "Display the block list of a Unity Archive file.") - { - pathArg, - fOpt, - }; - - blocksArchiveCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleBlocks(fi, f)), - pathArg, fOpt); - - var infoArchiveCommand = new Command("info", "Display a high-level summary of a Unity Archive file.") - { - pathArg, - fOpt, - }; - - infoArchiveCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleInfo(fi, f)), - pathArg, fOpt); - - var archiveCommand = new Command("archive", "Inspect or extract the contents of a Unity archive (AssetBundle or web platform .data file).") - { - extractArchiveCommand, - listArchiveCommand, - headerArchiveCommand, - blocksArchiveCommand, - infoArchiveCommand, - }; - - rootCommand.AddCommand(archiveCommand); - } + var fOpt = new Option(aliases: new[] { "--format", "-f" }, description: "Output format", getDefaultValue: () => OutputFormat.Text); + var listArchiveCommand = new Command("list", "List the contents of an AssetBundle or .data file.") { - var pathArg = new Argument("filename", "The path of the SerializedFile").ExistingOnly(); - var fOpt = new Option(aliases: new[] { "--format", "-f" }, description: "Output format", getDefaultValue: () => OutputFormat.Text); - - var externalRefsCommand = new Command("externalrefs", "List external file references in a SerializedFile.") - { - pathArg, - fOpt, - }; - - externalRefsCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleExternalRefs(fi, f)), - pathArg, fOpt); - - var objectListCommand = new Command("objectlist", "List all objects in a SerializedFile.") - { - pathArg, - fOpt, - }; - - objectListCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleObjectList(fi, f)), - pathArg, fOpt); - - var headerCommand = new Command("header", "Show SerializedFile header information.") - { - pathArg, - fOpt, - }; - - headerCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleHeader(fi, f)), - pathArg, fOpt); - - var metadataCommand = new Command("metadata", "Show information from the metadata section of the SerializedFile (use `-f Json` for detailed information).") - { - pathArg, - fOpt, - }; - - metadataCommand.SetHandler( - (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleMetadata(fi, f)), - pathArg, fOpt); - - var serializedFileCommand = new Command("serialized-file", "Inspect a SerializedFile (scene, assets, etc.).") - { - externalRefsCommand, - objectListCommand, - headerCommand, - metadataCommand, - }; - - serializedFileCommand.AddAlias("sf"); - - rootCommand.AddCommand(serializedFileCommand); - } - - var r = await rootCommand.InvokeAsync(args); - - UnityFileSystem.Cleanup(); - - return r; + pathArg, + fOpt, + }; + listArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleList(fi, f)), + pathArg, fOpt); + + var headerArchiveCommand = new Command("header", "Display the header of a Unity Archive file.") + { + pathArg, + fOpt, + }; + headerArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleHeader(fi, f)), + pathArg, fOpt); + + var blocksArchiveCommand = new Command("blocks", "Display the block list of a Unity Archive file.") + { + pathArg, + fOpt, + }; + blocksArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleBlocks(fi, f)), + pathArg, fOpt); + + var infoArchiveCommand = new Command("info", "Display a high-level summary of a Unity Archive file.") + { + pathArg, + fOpt, + }; + infoArchiveCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(Archive.HandleInfo(fi, f)), + pathArg, fOpt); + + return new Command("archive", "Inspect or extract the contents of a Unity archive (AssetBundle or web platform .data file).") + { + extractArchiveCommand, + listArchiveCommand, + headerArchiveCommand, + blocksArchiveCommand, + infoArchiveCommand, + }; } - enum DumpFormat + static Command BuildSerializedFileCommand() { - Text, + var pathArg = new Argument("filename", "The path of the SerializedFile").ExistingOnly(); + var fOpt = new Option(aliases: new[] { "--format", "-f" }, description: "Output format", getDefaultValue: () => OutputFormat.Text); + + var externalRefsCommand = new Command("externalrefs", "List external file references in a SerializedFile.") + { + pathArg, + fOpt, + }; + externalRefsCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleExternalRefs(fi, f)), + pathArg, fOpt); + + var objectListCommand = new Command("objectlist", "List all objects in a SerializedFile.") + { + pathArg, + fOpt, + }; + objectListCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleObjectList(fi, f)), + pathArg, fOpt); + + var headerCommand = new Command("header", "Show SerializedFile header information.") + { + pathArg, + fOpt, + }; + headerCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleHeader(fi, f)), + pathArg, fOpt); + + var metadataCommand = new Command("metadata", "Show information from the metadata section of the SerializedFile (use `-f Json` for detailed information).") + { + pathArg, + fOpt, + }; + metadataCommand.SetHandler( + (FileInfo fi, OutputFormat f) => Task.FromResult(SerializedFileCommands.HandleMetadata(fi, f)), + pathArg, fOpt); + + var serializedFileCommand = new Command("serialized-file", "Inspect a SerializedFile (scene, assets, etc.).") + { + externalRefsCommand, + objectListCommand, + headerCommand, + metadataCommand, + }; + serializedFileCommand.AddAlias("sf"); + return serializedFileCommand; } static int LoadTypeTreeDataFile(FileInfo typeTreeDataFile) @@ -329,17 +328,8 @@ static int HandleFindReferences(FileInfo databasePath, string outputFile, long? } } - static int HandleDump(FileInfo filename, DumpFormat format, bool skipLargeArrays, DirectoryInfo outputFolder, long objectId = 0, string typeFilter = null, bool toStdout = false) + static int HandleDump(TextDumperTool.DumpOptions options) { - switch (format) - { - case DumpFormat.Text: - { - var textDumper = new TextDumperTool(); - return textDumper.Dump(filename.FullName, outputFolder.FullName, skipLargeArrays, objectId, typeFilter, toStdout); - } - } - - return 1; + return new TextDumperTool().Dump(options); } } From ba6190faa323a81a2654481f98133530518d268b Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Thu, 14 May 2026 17:35:24 -0400 Subject: [PATCH 03/10] Add more tests for dump Also exclude external references when using dump with filtering (by type or object) --- TextDumper/TextDumperTool.cs | 39 ++--- UnityDataTool.Tests/DumpTests.cs | 142 ++++++++++++++++++ .../UnityDataToolAssetBundleTests.cs | 27 ++++ 3 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 UnityDataTool.Tests/DumpTests.cs diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 6fab2a4..243515c 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -34,13 +34,8 @@ public int Dump(DumpOptions options) { var path = options.Path; var outputPath = options.OutputPath; - var objectId = options.ObjectId; - var typeFilter = options.TypeFilter; var toStdout = options.ToStdout; - if (string.IsNullOrWhiteSpace(typeFilter)) - typeFilter = null; - m_SkipLargeArrays = options.SkipLargeArrays; try @@ -85,7 +80,7 @@ public int Dump(DumpOptions options) var node2 = singleSerializedFile.Value; Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}"); m_Writer = Console.Out; - OutputSerializedFile("/" + node2.Path, objectId, typeFilter); + OutputSerializedFile("/" + node2.Path, options); m_Writer.Flush(); } else @@ -98,7 +93,7 @@ public int Dump(DumpOptions options) { using var writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(node.Path) + ".txt"), false); m_Writer = writer; - OutputSerializedFile("/" + node.Path, objectId, typeFilter); + OutputSerializedFile("/" + node.Path, options); } } } @@ -117,14 +112,14 @@ public int Dump(DumpOptions options) if (toStdout) { m_Writer = Console.Out; - OutputSerializedFile(path, objectId, typeFilter); + OutputSerializedFile(path, options); m_Writer.Flush(); } else { using var writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false); m_Writer = writer; - OutputSerializedFile(path, objectId, typeFilter); + OutputSerializedFile(path, options); } } catch (SerializedFileOpenException) @@ -446,23 +441,33 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT return true; } - void OutputSerializedFile(string path, long objectId, string typeFilter) + void OutputSerializedFile(string path, DumpOptions options) { + var objectId = options.ObjectId; + var typeFilter = string.IsNullOrWhiteSpace(options.TypeFilter) ? null : options.TypeFilter; + bool filtered = objectId != 0 || typeFilter != null; + int filterTypeId = 0; bool filterByTypeId = typeFilter != null && int.TryParse(typeFilter, out filterTypeId); using (m_Reader = new UnityFileReader(path, 64 * 1024 * 1024)) using (m_SerializedFile = UnityFileSystem.OpenSerializedFile(path)) { - var i = 1; - - m_Writer.WriteLine("External References"); - foreach (var extRef in m_SerializedFile.ExternalReferences) + // External references provide context for PPtrs across the whole file. Skip them when a + // filter is in use - the output is about a specific object, and `sf externalrefs` is the + // dedicated command for listing external refs. + if (!filtered) { - m_Writer.WriteLine($"path({i}): \"{extRef.Path}\" GUID: {extRef.Guid} Type: {(int)extRef.Type}"); - ++i; + var i = 1; + + m_Writer.WriteLine("External References"); + foreach (var extRef in m_SerializedFile.ExternalReferences) + { + m_Writer.WriteLine($"path({i}): \"{extRef.Path}\" GUID: {extRef.Guid} Type: {(int)extRef.Type}"); + ++i; + } + m_Writer.WriteLine(); } - m_Writer.WriteLine(); bool dumpedObject = false; foreach (var obj in m_SerializedFile.Objects) diff --git a/UnityDataTool.Tests/DumpTests.cs b/UnityDataTool.Tests/DumpTests.cs new file mode 100644 index 0000000..20010a5 --- /dev/null +++ b/UnityDataTool.Tests/DumpTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace UnityDataTools.UnityDataTool.Tests; + +#pragma warning disable NUnit2005, NUnit2006 + +public class DumpTests +{ + private string m_TestDataFolder; + private string m_SerializedFilePath; + private string m_ResourceFilePath; + + [OneTimeSetUp] + public void OneTimeSetup() + { + m_TestDataFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data"); + m_SerializedFilePath = Path.Combine(m_TestDataFolder, "PlayerWithTypeTrees", "level0"); + m_ResourceFilePath = Path.Combine(m_TestDataFolder, "PlayerWithTypeTrees", "sharedassets0.assets.resS"); + } + + [Test] + public async Task Dump_Stdout_DefaultArgs_ContainsExternalReferences() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout" })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + Assert.That(output, Does.Contain("External References")); + } + + [Test] + public async Task Dump_Stdout_FilterByObjectId_DumpsGameObject() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-i", "1" })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + Assert.That(output, Does.Contain("ID: 1 (ClassID: 1) GameObject")); + Assert.That(output, Does.Contain("m_Name (string) RefHolder")); + } + + [Test] + public async Task Dump_Stdout_FilterByObjectId_DumpsRenderSettings() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-i", "3" })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + Assert.That(output, Does.Contain("(ClassID: 104)")); + Assert.That(output, Does.Contain("m_FogColor (ColorRGBA)")); + } + + [Test] + public async Task Dump_Stdout_FilterByObjectId_DoesNotIncludeExternalReferences() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-i", "1" })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + Assert.That(output, Does.Not.Contain("External References")); + } + + [Test] + public async Task Dump_Stdout_FilterByObjectId_NotFound_PrintsNotFoundMessage() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-i", "99999999" })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + Assert.That(output, Does.Contain("Object with ID 99999999 not found.")); + } + + [Test] + public async Task Dump_Stdout_InvalidFileType_Fails() + { + 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); + Assert.AreNotEqual(0, await Program.Main(new string[] { "dump", m_ResourceFilePath, "--stdout" })); + } + finally + { + Console.SetOut(currentOut); + Console.SetError(currentErr); + } + + Assert.That(swErr.ToString(), Does.Contain("does not appear to be a valid Unity SerializedFile or Unity Archive")); + } +} diff --git a/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs b/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs index d5461fe..cd6b157 100644 --- a/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs +++ b/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs @@ -148,6 +148,33 @@ public async Task DumpText_SkipLargeArrays_TextFileCreatedCorrectly( Assert.AreEqual(expected, content); } + [Test] + public async Task DumpText_Stdout_WritesDumpToStdout() + { + var path = Path.Combine(Context.UnityDataFolder, "assetbundle"); + + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", path, "--stdout" })); + } + finally + { + Console.SetOut(currentOut); + } + + var content = sw.ToString(); + var expected = File.ReadAllText(Path.Combine(Context.ExpectedDataFolder, "dump", "CAB-5d40f7cad7c871cf2ad2af19ac542994.txt")); + + // Normalize line endings. + content = Regex.Replace(content, @"\r\n|\n\r|\r", "\n"); + expected = Regex.Replace(expected, @"\r\n|\n\r|\r", "\n"); + + Assert.AreEqual(expected, content); + } + [Test] public async Task DumpText_TypeFilterByName_OnlyMatchingObjectsDumped() { From f4c9d81df79eb866ed1fa90c9cd80da0727e6487 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Thu, 14 May 2026 17:46:31 -0400 Subject: [PATCH 04/10] Refactor Dump() method Simplify the main entry point into dump so that archive and serialized file error handling and other details do not clutter the main flow. --- TextDumper/TextDumperTool.cs | 188 +++++++++++++++++------------------ 1 file changed, 93 insertions(+), 95 deletions(-) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 243515c..d567726 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -32,125 +32,123 @@ public class DumpOptions public int Dump(DumpOptions options) { - var path = options.Path; - var outputPath = options.OutputPath; - var toStdout = options.ToStdout; - m_SkipLargeArrays = options.SkipLargeArrays; try { - if (!File.Exists(path)) + if (!File.Exists(options.Path)) { - Console.Error.WriteLine($"Error: File not found: {path}"); + Console.Error.WriteLine($"Error: File not found: {options.Path}"); return 1; } - if (ArchiveDetector.IsUnityArchive(path)) - { - // The input is a Unity archive (e.g. AssetBundle); dump each serialized file inside it. - using var archive = UnityFileSystem.MountArchive(path, "/"); + if (ArchiveDetector.IsUnityArchive(options.Path)) + return DumpArchive(options); - if (toStdout) - { - ArchiveNode? singleSerializedFile = null; - int serializedFileCount = 0; - foreach (var node in archive.Nodes) - { - if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) - { - ++serializedFileCount; - singleSerializedFile ??= node; - } - } + if (YamlSerializedFileDetector.IsYamlSerializedFile(options.Path)) + { + Console.Error.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); + Console.Error.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); + return 1; + } - if (serializedFileCount == 0) - { - Console.Error.WriteLine("Error: Archive contains no SerializedFiles."); - return 1; - } + if (SerializedFileDetector.TryDetectSerializedFile(options.Path, out _)) + return DumpSerializedFile(options); - 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.Error.WriteLine("Error: The file does not appear to be a valid Unity SerializedFile or Unity Archive."); + Console.Error.WriteLine($"File: {options.Path}"); + return 1; + } + catch (Exception e) + { + Console.Error.WriteLine($"Error: {e.GetType()}: {e.Message}"); + Console.Error.WriteLine(e.StackTrace); + return 1; + } + } - var node2 = singleSerializedFile.Value; - Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}"); - m_Writer = Console.Out; - OutputSerializedFile("/" + node2.Path, options); - m_Writer.Flush(); - } - else - { - foreach (var node in archive.Nodes) - { - Console.WriteLine($"Processing {node.Path} {node.Size} {node.Flags}"); - - if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) - { - using var writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(node.Path) + ".txt"), false); - m_Writer = writer; - OutputSerializedFile("/" + node.Path, options); - } - } - } + int DumpSerializedFile(DumpOptions options) + { + try + { + if (options.ToStdout) + { + m_Writer = Console.Out; + OutputSerializedFile(options.Path, options); + m_Writer.Flush(); } - else if (YamlSerializedFileDetector.IsYamlSerializedFile(path)) + else { - Console.Error.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); - Console.Error.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); - return 1; + using var writer = new StreamWriter(Path.Combine(options.OutputPath, Path.GetFileName(options.Path) + ".txt"), false); + m_Writer = writer; + OutputSerializedFile(options.Path, options); + } + } + catch (SerializedFileOpenException) + { + var hint = SerializedFileDetector.GetOpenFailureHint(options.Path); + if (hint != null) + { + Console.Error.WriteLine(); + Console.Error.WriteLine(hint); } - else if (SerializedFileDetector.TryDetectSerializedFile(path, out _)) + return 1; + } + + return 0; + } + + // For convenience we also support directly dumping serialized files that are inside an archive, + // so that its not necessary to use `archive extract` if you only want to see values from the object serialization. + int DumpArchive(DumpOptions options) + { + using var archive = UnityFileSystem.MountArchive(options.Path, "/"); + + if (options.ToStdout) + { + ArchiveNode? singleSerializedFile = null; + int serializedFileCount = 0; + foreach (var node in archive.Nodes) { - // The input is a binary SerializedFile; dump it directly. - try - { - if (toStdout) - { - m_Writer = Console.Out; - OutputSerializedFile(path, options); - m_Writer.Flush(); - } - else - { - using var writer = new StreamWriter(Path.Combine(outputPath, Path.GetFileName(path) + ".txt"), false); - m_Writer = writer; - OutputSerializedFile(path, options); - } - } - catch (SerializedFileOpenException) + if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) { - var hint = SerializedFileDetector.GetOpenFailureHint(path); - if (hint != null) - { - Console.Error.WriteLine(); - Console.Error.WriteLine(hint); - } - return 1; - } - catch (Exception e) - { - Console.Error.WriteLine($"Error: {e.GetType()}: {e.Message}"); - Console.Error.WriteLine(e.StackTrace); - return 1; + ++serializedFileCount; + singleSerializedFile ??= node; } } - else + + if (serializedFileCount == 0) + { + Console.Error.WriteLine("Error: Archive contains no SerializedFiles."); + return 1; + } + + if (serializedFileCount > 1) { - Console.Error.WriteLine("Error: The file does not appear to be a valid Unity SerializedFile or Unity Archive."); - Console.Error.WriteLine($"File: {path}"); + 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; } + + var node2 = singleSerializedFile.Value; + Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}"); + m_Writer = Console.Out; + OutputSerializedFile("/" + node2.Path, options); + m_Writer.Flush(); } - catch (Exception e) + else { - Console.Error.WriteLine($"Error: {e.GetType()}: {e.Message}"); - Console.Error.WriteLine(e.StackTrace); - return 1; + foreach (var node in archive.Nodes) + { + Console.WriteLine($"Processing {node.Path} {node.Size} {node.Flags}"); + + if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) + { + using var writer = new StreamWriter(Path.Combine(options.OutputPath, Path.GetFileName(node.Path) + ".txt"), false); + m_Writer = writer; + OutputSerializedFile("/" + node.Path, options); + } + } } return 0; From 849de7aa1374f5672301aa4716e1d3497568d3bd Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Thu, 14 May 2026 17:51:19 -0400 Subject: [PATCH 05/10] Refactor order of methods The OutputSerializedFile() method made sense earlier in the file so that the implementation is going approximately from higher level down to lower level details Sorry for the code churn but this aid readability. --- TextDumper/TextDumperTool.cs | 145 +++++++++++++++++------------------ 1 file changed, 72 insertions(+), 73 deletions(-) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index d567726..eb2e671 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -154,6 +154,78 @@ int DumpArchive(DumpOptions options) return 0; } + void OutputSerializedFile(string path, DumpOptions options) + { + var objectId = options.ObjectId; + var typeFilter = string.IsNullOrWhiteSpace(options.TypeFilter) ? null : options.TypeFilter; + bool filtered = objectId != 0 || typeFilter != null; + + int filterTypeId = 0; + bool filterByTypeId = typeFilter != null && int.TryParse(typeFilter, out filterTypeId); + + using (m_Reader = new UnityFileReader(path, 64 * 1024 * 1024)) + using (m_SerializedFile = UnityFileSystem.OpenSerializedFile(path)) + { + // External references provide context for PPtrs across the whole file. Skip them when a + // filter is in use - the output is about a specific object, and `sf externalrefs` is the + // dedicated command for listing external refs. + if (!filtered) + { + var i = 1; + + m_Writer.WriteLine("External References"); + foreach (var extRef in m_SerializedFile.ExternalReferences) + { + m_Writer.WriteLine($"path({i}): \"{extRef.Path}\" GUID: {extRef.Guid} Type: {(int)extRef.Type}"); + ++i; + } + m_Writer.WriteLine(); + } + + bool dumpedObject = false; + foreach (var obj in m_SerializedFile.Objects) + { + if (objectId != 0 && obj.Id != objectId) + continue; + + var root = m_SerializedFile.GetTypeTreeRoot(obj.Id); + + if (typeFilter != null) + { + if (filterByTypeId) + { + if (obj.TypeId != filterTypeId) + continue; + } + else + { + var typeName = TypeIdRegistry.GetTypeName(obj.TypeId); + // GetTypeName returns the id as a string when the type is unknown; + // fall back to the TypeTree root node for script types. + if (typeName == obj.TypeId.ToString()) + typeName = root.Type; + if (!string.Equals(typeName, typeFilter, StringComparison.OrdinalIgnoreCase)) + continue; + } + } + + var offset = obj.Offset; + + m_Writer.Write($"ID: {obj.Id} (ClassID: {obj.TypeId}) "); + RecursiveDump(root, ref offset, 0); + m_Writer.WriteLine(); + dumpedObject = true; + } + + if ((objectId != 0 || typeFilter != null) && !dumpedObject) + { + if (objectId != 0) + m_Writer.WriteLine($"Object with ID {objectId} not found."); + else + m_Writer.WriteLine($"No objects found matching type \"{typeFilter}\"."); + } + } + } void RecursiveDump(TypeTreeNode node, ref long offset, int level, int arrayIndex = -1) { @@ -439,79 +511,6 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT return true; } - void OutputSerializedFile(string path, DumpOptions options) - { - var objectId = options.ObjectId; - var typeFilter = string.IsNullOrWhiteSpace(options.TypeFilter) ? null : options.TypeFilter; - bool filtered = objectId != 0 || typeFilter != null; - - int filterTypeId = 0; - bool filterByTypeId = typeFilter != null && int.TryParse(typeFilter, out filterTypeId); - - using (m_Reader = new UnityFileReader(path, 64 * 1024 * 1024)) - using (m_SerializedFile = UnityFileSystem.OpenSerializedFile(path)) - { - // External references provide context for PPtrs across the whole file. Skip them when a - // filter is in use - the output is about a specific object, and `sf externalrefs` is the - // dedicated command for listing external refs. - if (!filtered) - { - var i = 1; - - m_Writer.WriteLine("External References"); - foreach (var extRef in m_SerializedFile.ExternalReferences) - { - m_Writer.WriteLine($"path({i}): \"{extRef.Path}\" GUID: {extRef.Guid} Type: {(int)extRef.Type}"); - ++i; - } - m_Writer.WriteLine(); - } - - bool dumpedObject = false; - foreach (var obj in m_SerializedFile.Objects) - { - if (objectId != 0 && obj.Id != objectId) - continue; - - var root = m_SerializedFile.GetTypeTreeRoot(obj.Id); - - if (typeFilter != null) - { - if (filterByTypeId) - { - if (obj.TypeId != filterTypeId) - continue; - } - else - { - var typeName = TypeIdRegistry.GetTypeName(obj.TypeId); - // GetTypeName returns the id as a string when the type is unknown; - // fall back to the TypeTree root node for script types. - if (typeName == obj.TypeId.ToString()) - typeName = root.Type; - if (!string.Equals(typeName, typeFilter, StringComparison.OrdinalIgnoreCase)) - continue; - } - } - - var offset = obj.Offset; - - m_Writer.Write($"ID: {obj.Id} (ClassID: {obj.TypeId}) "); - RecursiveDump(root, ref offset, 0); - m_Writer.WriteLine(); - dumpedObject = true; - } - - if ((objectId != 0 || typeFilter != null) && !dumpedObject) - { - if (objectId != 0) - m_Writer.WriteLine($"Object with ID {objectId} not found."); - else - m_Writer.WriteLine($"No objects found matching type \"{typeFilter}\"."); - } - } - } - string ReadValue(TypeTreeNode node, long offset) { switch (Type.GetTypeCode(node.CSharpType)) From b7a9f48605f4bef3f2dd46f5a7830b9c956621b7 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Thu, 14 May 2026 18:07:49 -0400 Subject: [PATCH 06/10] Simplify the doc for textdumper. Listing all the options to Dump() is rather redundant, the important documentation is command-dump.md and its just a maintenance burden to also list them here. --- Documentation/textdumper.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Documentation/textdumper.md b/Documentation/textdumper.md index 8c13a6c..20b6f03 100644 --- a/Documentation/textdumper.md +++ b/Documentation/textdumper.md @@ -5,14 +5,9 @@ file (AssetBundle or SerializedFile) into human-readable yaml-style text file. ## How to use -The library consists of a single class called [TextDumperTool](../TextDumper/TextDumperTool.cs). Call its `Dump` method, passing a `TextDumperTool.DumpOptions` object with the following properties: -* `Format` (DumpFormat, optional): output format. Defaults to `Text`. -* `Path` (string): path of the data file. -* `OutputPath` (string): path where the output files will be created. Ignored when `ToStdout` is true. -* `SkipLargeArrays` (bool): if true, the content of arrays larger than 1KB won't be dumped. -* `ObjectId` (long, optional): if specified and not 0, only the object with this signed 64-bit id will be dumped. If 0 (default), all objects are dumped. -* `TypeFilter` (string, optional): if specified, only objects matching this type are dumped. Accepts a numeric ClassID (e.g. 114) or a type name (e.g. MonoBehaviour, case-insensitive). -* `ToStdout` (bool, optional): if true, the dump is written to standard output instead of a file. Refused for archives that contain more than one SerializedFile. +The library consists of a single class called [TextDumperTool](../TextDumper/TextDumperTool.cs). Call its `Dump` method, passing a `TextDumperTool.DumpOptions` object that specify the path of the file to dump and various flags and options. + +The library is used to implement the [`UnityDataTool dump` command](command-dump.md). ## How to interpret the output files From 7336d5e1622c09cc1a6426f9755c4b95428a4b87 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Fri, 15 May 2026 10:34:04 -0400 Subject: [PATCH 07/10] Address Copilot review feedback - Fix typo (its -> it's) in DumpArchive comment - Document that filtered dumps omit External References (command-dump.md, textdumper.md) - Add tests: type-filter omits External References, --stdout/-o mutual exclusion error, multi-SerializedFile archive refusal (data.unity3d) - Assert no .txt file is written by --stdout in DumpText_Stdout_WritesDumpToStdout --- Documentation/command-dump.md | 2 +- Documentation/textdumper.md | 2 +- TextDumper/TextDumperTool.cs | 2 +- UnityDataTool.Tests/DumpTests.cs | 68 +++++++++++++++++++ .../UnityDataToolAssetBundleTests.cs | 3 + 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Documentation/command-dump.md b/Documentation/command-dump.md index de0f41d..7765ae0 100644 --- a/Documentation/command-dump.md +++ b/Documentation/command-dump.md @@ -145,7 +145,7 @@ UnityDataTool dump /path/to/file.bundle --typetree-data /path/to/typetree.bin ## Output Format -The output is similar to Unity's `binary2text` tool. Each file begins with external references: +The output is similar to Unity's `binary2text` tool. Unfiltered dumps begin with external references (when filtering with `-i` or `-t` this section is omitted — use the [`serialized-file externalrefs`](#) command if you want them separately): ``` External References diff --git a/Documentation/textdumper.md b/Documentation/textdumper.md index 20b6f03..e6488b6 100644 --- a/Documentation/textdumper.md +++ b/Documentation/textdumper.md @@ -15,7 +15,7 @@ There will be one output file per SerializedFile. Depending on the type of the i be more than one output file (e.g. AssetBundles are archives that can contain several SerializedFiles). -The first lines of the output file looks like this: +For an unfiltered dump, the first lines of the output file look like this (when `ObjectId` or `TypeFilter` are set the External References section is omitted, and the output starts directly with the matching object entries): External References path(1): "Library/unity default resources" GUID: 0000000000000000e000000000000000 Type: 0 diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index eb2e671..31ffab3 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -99,7 +99,7 @@ int DumpSerializedFile(DumpOptions options) } // For convenience we also support directly dumping serialized files that are inside an archive, - // so that its not necessary to use `archive extract` if you only want to see values from the object serialization. + // so that it's not necessary to use `archive extract` if you only want to see values from the object serialization. int DumpArchive(DumpOptions options) { using var archive = UnityFileSystem.MountArchive(options.Path, "/"); diff --git a/UnityDataTool.Tests/DumpTests.cs b/UnityDataTool.Tests/DumpTests.cs index 20010a5..918b4d7 100644 --- a/UnityDataTool.Tests/DumpTests.cs +++ b/UnityDataTool.Tests/DumpTests.cs @@ -12,6 +12,7 @@ public class DumpTests private string m_TestDataFolder; private string m_SerializedFilePath; private string m_ResourceFilePath; + private string m_MultiSerializedFileArchivePath; [OneTimeSetUp] public void OneTimeSetup() @@ -19,6 +20,7 @@ public void OneTimeSetup() m_TestDataFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "Data"); 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"); } [Test] @@ -99,6 +101,72 @@ public async Task Dump_Stdout_FilterByObjectId_DoesNotIncludeExternalReferences( Assert.That(output, Does.Not.Contain("External References")); } + [Test] + public async Task Dump_Stdout_FilterByType_DoesNotIncludeExternalReferences() + { + using var sw = new StringWriter(); + var currentOut = Console.Out; + try + { + Console.SetOut(sw); + Assert.AreEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-t", "RenderSettings" })); + } + finally + { + Console.SetOut(currentOut); + } + + var output = sw.ToString(); + Assert.That(output, Does.Contain("(ClassID: 104)")); + Assert.That(output, Does.Not.Contain("External References")); + } + + [Test] + public async Task Dump_Stdout_WithOutputPath_ReturnsError() + { + 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); + Assert.AreNotEqual(0, await Program.Main(new string[] { "dump", m_SerializedFilePath, "--stdout", "-o", m_TestDataFolder })); + } + finally + { + Console.SetOut(currentOut); + Console.SetError(currentErr); + } + + Assert.That(swErr.ToString(), Does.Contain("--stdout and -o/--output-path are mutually exclusive.")); + } + + [Test] + public async Task Dump_Stdout_MultipleSerializedFilesArchive_Refused() + { + 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); + Assert.AreNotEqual(0, await Program.Main(new string[] { "dump", m_MultiSerializedFileArchivePath, "--stdout", "-t", "MonoBehaviour" })); + } + finally + { + Console.SetOut(currentOut); + Console.SetError(currentErr); + } + + 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)")); + } + [Test] public async Task Dump_Stdout_FilterByObjectId_NotFound_PrintsNotFoundMessage() { diff --git a/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs b/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs index cd6b157..acffc04 100644 --- a/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs +++ b/UnityDataTool.Tests/UnityDataToolAssetBundleTests.cs @@ -152,6 +152,7 @@ public async Task DumpText_SkipLargeArrays_TextFileCreatedCorrectly( public async Task DumpText_Stdout_WritesDumpToStdout() { var path = Path.Combine(Context.UnityDataFolder, "assetbundle"); + var unwantedOutputFile = Path.Combine(m_TestOutputFolder, "CAB-5d40f7cad7c871cf2ad2af19ac542994.txt"); using var sw = new StringWriter(); var currentOut = Console.Out; @@ -165,6 +166,8 @@ public async Task DumpText_Stdout_WritesDumpToStdout() Console.SetOut(currentOut); } + Assert.IsFalse(File.Exists(unwantedOutputFile), "--stdout should not also write a .txt file"); + var content = sw.ToString(); var expected = File.ReadAllText(Path.Combine(Context.ExpectedDataFolder, "dump", "CAB-5d40f7cad7c871cf2ad2af19ac542994.txt")); From b9757fe048f4766144e92cb18dc553699afc99ed Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Fri, 15 May 2026 10:50:21 -0400 Subject: [PATCH 08/10] Move dump options into member variables --- TextDumper/TextDumperTool.cs | 73 ++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 31ffab3..2aa0d4f 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -9,7 +9,10 @@ namespace UnityDataTools.TextDumper; public class TextDumperTool { StringBuilder m_StringBuilder = new StringBuilder(1024); - bool m_SkipLargeArrays; + DumpOptions m_Options; + string m_TypeFilter; // m_Options.TypeFilter normalized: null when blank/unset, otherwise the user-provided string + bool m_FilterByTypeId; // true when m_TypeFilter parses as a ClassID (numeric) + int m_FilterTypeId; // valid only when m_FilterByTypeId is true UnityFileReader m_Reader; SerializedFile m_SerializedFile; TextWriter m_Writer; @@ -32,31 +35,33 @@ public class DumpOptions public int Dump(DumpOptions options) { - m_SkipLargeArrays = options.SkipLargeArrays; + m_Options = options; + m_TypeFilter = string.IsNullOrWhiteSpace(m_Options.TypeFilter) ? null : m_Options.TypeFilter; + m_FilterByTypeId = m_TypeFilter != null && int.TryParse(m_TypeFilter, out m_FilterTypeId); try { - if (!File.Exists(options.Path)) + if (!File.Exists(m_Options.Path)) { - Console.Error.WriteLine($"Error: File not found: {options.Path}"); + Console.Error.WriteLine($"Error: File not found: {m_Options.Path}"); return 1; } - if (ArchiveDetector.IsUnityArchive(options.Path)) - return DumpArchive(options); + if (ArchiveDetector.IsUnityArchive(m_Options.Path)) + return DumpArchive(); - if (YamlSerializedFileDetector.IsYamlSerializedFile(options.Path)) + if (YamlSerializedFileDetector.IsYamlSerializedFile(m_Options.Path)) { Console.Error.WriteLine("Error: The file is a YAML-format SerializedFile, which is not supported."); Console.Error.WriteLine("UnityDataTool only supports binary-format SerializedFiles."); return 1; } - if (SerializedFileDetector.TryDetectSerializedFile(options.Path, out _)) - return DumpSerializedFile(options); + if (SerializedFileDetector.TryDetectSerializedFile(m_Options.Path, out _)) + return DumpSerializedFile(); Console.Error.WriteLine("Error: The file does not appear to be a valid Unity SerializedFile or Unity Archive."); - Console.Error.WriteLine($"File: {options.Path}"); + Console.Error.WriteLine($"File: {m_Options.Path}"); return 1; } catch (Exception e) @@ -67,26 +72,26 @@ public int Dump(DumpOptions options) } } - int DumpSerializedFile(DumpOptions options) + int DumpSerializedFile() { try { - if (options.ToStdout) + if (m_Options.ToStdout) { m_Writer = Console.Out; - OutputSerializedFile(options.Path, options); + OutputSerializedFile(m_Options.Path); m_Writer.Flush(); } else { - using var writer = new StreamWriter(Path.Combine(options.OutputPath, Path.GetFileName(options.Path) + ".txt"), false); + using var writer = new StreamWriter(Path.Combine(m_Options.OutputPath, Path.GetFileName(m_Options.Path) + ".txt"), false); m_Writer = writer; - OutputSerializedFile(options.Path, options); + OutputSerializedFile(m_Options.Path); } } catch (SerializedFileOpenException) { - var hint = SerializedFileDetector.GetOpenFailureHint(options.Path); + var hint = SerializedFileDetector.GetOpenFailureHint(m_Options.Path); if (hint != null) { Console.Error.WriteLine(); @@ -100,11 +105,11 @@ int DumpSerializedFile(DumpOptions options) // 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. - int DumpArchive(DumpOptions options) + int DumpArchive() { - using var archive = UnityFileSystem.MountArchive(options.Path, "/"); + using var archive = UnityFileSystem.MountArchive(m_Options.Path, "/"); - if (options.ToStdout) + if (m_Options.ToStdout) { ArchiveNode? singleSerializedFile = null; int serializedFileCount = 0; @@ -133,7 +138,7 @@ int DumpArchive(DumpOptions options) var node2 = singleSerializedFile.Value; Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}"); m_Writer = Console.Out; - OutputSerializedFile("/" + node2.Path, options); + OutputSerializedFile("/" + node2.Path); m_Writer.Flush(); } else @@ -144,9 +149,9 @@ int DumpArchive(DumpOptions options) if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile)) { - using var writer = new StreamWriter(Path.Combine(options.OutputPath, Path.GetFileName(node.Path) + ".txt"), false); + using var writer = new StreamWriter(Path.Combine(m_Options.OutputPath, Path.GetFileName(node.Path) + ".txt"), false); m_Writer = writer; - OutputSerializedFile("/" + node.Path, options); + OutputSerializedFile("/" + node.Path); } } } @@ -154,14 +159,10 @@ int DumpArchive(DumpOptions options) return 0; } - void OutputSerializedFile(string path, DumpOptions options) + void OutputSerializedFile(string path) { - var objectId = options.ObjectId; - var typeFilter = string.IsNullOrWhiteSpace(options.TypeFilter) ? null : options.TypeFilter; - bool filtered = objectId != 0 || typeFilter != null; - - int filterTypeId = 0; - bool filterByTypeId = typeFilter != null && int.TryParse(typeFilter, out filterTypeId); + var objectId = m_Options.ObjectId; + bool filtered = objectId != 0 || m_TypeFilter != null; using (m_Reader = new UnityFileReader(path, 64 * 1024 * 1024)) using (m_SerializedFile = UnityFileSystem.OpenSerializedFile(path)) @@ -190,11 +191,11 @@ void OutputSerializedFile(string path, DumpOptions options) var root = m_SerializedFile.GetTypeTreeRoot(obj.Id); - if (typeFilter != null) + if (m_TypeFilter != null) { - if (filterByTypeId) + if (m_FilterByTypeId) { - if (obj.TypeId != filterTypeId) + if (obj.TypeId != m_FilterTypeId) continue; } else @@ -204,7 +205,7 @@ void OutputSerializedFile(string path, DumpOptions options) // fall back to the TypeTree root node for script types. if (typeName == obj.TypeId.ToString()) typeName = root.Type; - if (!string.Equals(typeName, typeFilter, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(typeName, m_TypeFilter, StringComparison.OrdinalIgnoreCase)) continue; } } @@ -217,12 +218,12 @@ void OutputSerializedFile(string path, DumpOptions options) dumpedObject = true; } - if ((objectId != 0 || typeFilter != null) && !dumpedObject) + if (filtered && !dumpedObject) { if (objectId != 0) m_Writer.WriteLine($"Object with ID {objectId} not found."); else - m_Writer.WriteLine($"No objects found matching type \"{typeFilter}\"."); + m_Writer.WriteLine($"No objects found matching type \"{m_TypeFilter}\"."); } } } @@ -347,7 +348,7 @@ void DumpArray(TypeTreeNode node, ref long offset, int level) { m_StringBuilder.Append(' ', (level + 1) * 2); - if (arraySize > 256 && m_SkipLargeArrays) + if (arraySize > 256 && m_Options.SkipLargeArrays) { m_StringBuilder.Append(""); offset += dataNode.Size * arraySize; From aba9d84ac8d7b6a700c6a93b3fb835a6d8a844d8 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Fri, 15 May 2026 11:33:25 -0400 Subject: [PATCH 09/10] "Simplify" run on entire implementation of TextDumperTool --- TextDumper/TextDumperTool.cs | 113 ++++++++++++++++------------------- 1 file changed, 53 insertions(+), 60 deletions(-) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 2aa0d4f..523b497 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -191,24 +191,8 @@ void OutputSerializedFile(string path) var root = m_SerializedFile.GetTypeTreeRoot(obj.Id); - if (m_TypeFilter != null) - { - if (m_FilterByTypeId) - { - if (obj.TypeId != m_FilterTypeId) - continue; - } - else - { - var typeName = TypeIdRegistry.GetTypeName(obj.TypeId); - // GetTypeName returns the id as a string when the type is unknown; - // fall back to the TypeTree root node for script types. - if (typeName == obj.TypeId.ToString()) - typeName = root.Type; - if (!string.Equals(typeName, m_TypeFilter, StringComparison.OrdinalIgnoreCase)) - continue; - } - } + if (!ObjectMatchesTypeFilter(obj, root)) + continue; var offset = obj.Offset; @@ -247,7 +231,7 @@ void RecursiveDump(TypeTreeNode node, ref long offset, int level, int arrayIndex } else { - m_StringBuilder.Append(' ', level * 2); + AppendIndent(level); if (level != 0) { @@ -268,7 +252,6 @@ void RecursiveDump(TypeTreeNode node, ref long offset, int level, int arrayIndex m_StringBuilder.Append(node.Type); } - // Basic data type. if (node.IsBasicType) { m_StringBuilder.Append(' '); @@ -314,7 +297,7 @@ void RecursiveDump(TypeTreeNode node, ref long offset, int level, int arrayIndex ((int)node.MetaFlags & (int)TypeTreeMetaFlags.AnyChildUsesAlignBytes) != 0 ) { - offset = (offset + 3) & ~(3); + offset = AlignTo4(offset); } } @@ -331,7 +314,7 @@ void DumpArray(TypeTreeNode node, ref long offset, int level) var arraySize = m_Reader.ReadInt32(offset); offset += 4; - m_StringBuilder.Append(' ', level * 2); + AppendIndent(level); m_StringBuilder.Append("Array"); m_StringBuilder.Append('<'); m_StringBuilder.Append(dataNode.Type); @@ -346,7 +329,7 @@ void DumpArray(TypeTreeNode node, ref long offset, int level) { if (dataNode.IsBasicType) { - m_StringBuilder.Append(' ', (level + 1) * 2); + AppendIndent(level + 1); if (arraySize > 256 && m_Options.SkipLargeArrays) { @@ -449,8 +432,8 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT if (refTypeNode.Children.Count < 3) throw new Exception("Invalid ReferencedManagedType"); - m_StringBuilder.Append(' ', level * 2); - m_StringBuilder.Append($"rid("); + AppendIndent(level); + m_StringBuilder.Append("rid("); m_StringBuilder.Append(id); m_StringBuilder.Append(") ReferencedObject"); @@ -460,28 +443,17 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT ++level; var refTypeOffset = offset; - var stringSize = m_Reader.ReadInt32(offset); - var className = m_Reader.ReadString(offset + 4, stringSize); - offset += stringSize + 4; - offset = (offset + 3) & ~(3); - - stringSize = m_Reader.ReadInt32(offset); - var namespaceName = m_Reader.ReadString(offset + 4, stringSize); - offset += stringSize + 4; - offset = (offset + 3) & ~(3); - - stringSize = m_Reader.ReadInt32(offset); - var assemblyName = m_Reader.ReadString(offset + 4, stringSize); - offset += stringSize + 4; - offset = (offset + 3) & ~(3); - - if (className == "Terminus" && namespaceName == "UnityEngine.DMAT" && assemblyName == "FAKE_ASM") + var className = ReadPascalStringAndAlign(ref offset); + var namespaceName = ReadPascalStringAndAlign(ref offset); + var assemblyName = ReadPascalStringAndAlign(ref offset); + + if (IsTerminusSentinel(className, namespaceName, assemblyName)) return false; // Not the most efficient way, but it simplifies the code. RecursiveDump(refTypeNode, ref refTypeOffset, level); - m_StringBuilder.Append(' ', level * 2); + AppendIndent(level); m_StringBuilder.Append(referencedTypeDataNode.Name); m_StringBuilder.Append(' '); m_StringBuilder.Append(referencedTypeDataNode.Type); @@ -492,7 +464,7 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT if (id == -1 || id == -2) { - m_StringBuilder.Append(' ', level * 2); + AppendIndent(level); m_StringBuilder.Append(id == -1 ? " unknown" : " null"); m_Writer.WriteLine(m_StringBuilder); @@ -512,6 +484,38 @@ bool DumpManagedReferenceData(TypeTreeNode refTypeNode, TypeTreeNode referencedT return true; } + static long AlignTo4(long offset) => (offset + 3) & ~3L; + + void AppendIndent(int level) => m_StringBuilder.Append(' ', level * 2); + + string ReadPascalStringAndAlign(ref long offset) + { + var size = m_Reader.ReadInt32(offset); + var value = m_Reader.ReadString(offset + 4, size); + offset = AlignTo4(offset + 4 + size); + return value; + } + + // Sentinel record that marks the end of the v1 ReferencedObject sequence. + static bool IsTerminusSentinel(string className, string namespaceName, string assemblyName) => + className == "Terminus" && namespaceName == "UnityEngine.DMAT" && assemblyName == "FAKE_ASM"; + + bool ObjectMatchesTypeFilter(ObjectInfo obj, TypeTreeNode root) + { + if (m_TypeFilter == null) + return true; + + if (m_FilterByTypeId) + return obj.TypeId == m_FilterTypeId; + + var typeName = TypeIdRegistry.GetTypeName(obj.TypeId); + // GetTypeName returns the id as a string when the type is unknown; + // fall back to the TypeTree root node for script types. + if (typeName == obj.TypeId.ToString()) + typeName = root.Type; + return string.Equals(typeName, m_TypeFilter, StringComparison.OrdinalIgnoreCase); + } + string ReadValue(TypeTreeNode node, long offset) { switch (Type.GetTypeCode(node.CSharpType)) @@ -557,28 +561,17 @@ string ReadValue(TypeTreeNode node, long offset) Array ReadBasicTypeArray(TypeTreeNode node, long offset, int arraySize) { - // Special case for boolean arrays. + // bool isn't blittable into Array.CreateInstance(typeof(bool), ...) the way other basic types + // are, so read into a byte buffer and convert. if (node.CSharpType == typeof(bool)) { var tmpArray = new byte[arraySize]; - var boolArray = new bool[arraySize]; - m_Reader.ReadArray(offset, arraySize * node.Size, tmpArray); - - for (int i = 0; i < arraySize; ++i) - { - boolArray[i] = tmpArray[i] != 0; - } - - return boolArray; + return Array.ConvertAll(tmpArray, b => b != 0); } - else - { - var array = Array.CreateInstance(node.CSharpType, arraySize); - - m_Reader.ReadArray(offset, arraySize * node.Size, array); - return array; - } + var array = Array.CreateInstance(node.CSharpType, arraySize); + m_Reader.ReadArray(offset, arraySize * node.Size, array); + return array; } } From f85b358d914eb65f22b2c75014788cf42838be36 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Fri, 15 May 2026 13:40:03 -0400 Subject: [PATCH 10/10] Further simplification of member variables --- TextDumper/TextDumperTool.cs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/TextDumper/TextDumperTool.cs b/TextDumper/TextDumperTool.cs index 523b497..e5e2dd1 100644 --- a/TextDumper/TextDumperTool.cs +++ b/TextDumper/TextDumperTool.cs @@ -11,11 +11,13 @@ public class TextDumperTool StringBuilder m_StringBuilder = new StringBuilder(1024); DumpOptions m_Options; string m_TypeFilter; // m_Options.TypeFilter normalized: null when blank/unset, otherwise the user-provided string - bool m_FilterByTypeId; // true when m_TypeFilter parses as a ClassID (numeric) - int m_FilterTypeId; // valid only when m_FilterByTypeId is true + int m_FilterTypeId; // > 0 when filtering by Unity ClassID (numeric form of m_TypeFilter); 0 means no ID filter + + TextWriter m_Writer; // Output, either to a file or Console.Out + + // Set during the processed of each Serialized File UnityFileReader m_Reader; SerializedFile m_SerializedFile; - TextWriter m_Writer; public enum DumpFormat { @@ -37,7 +39,7 @@ public int Dump(DumpOptions options) { m_Options = options; m_TypeFilter = string.IsNullOrWhiteSpace(m_Options.TypeFilter) ? null : m_Options.TypeFilter; - m_FilterByTypeId = m_TypeFilter != null && int.TryParse(m_TypeFilter, out m_FilterTypeId); + m_FilterTypeId = (m_TypeFilter != null && int.TryParse(m_TypeFilter, out var parsed) && parsed > 0) ? parsed : 0; try { @@ -189,9 +191,12 @@ void OutputSerializedFile(string path) if (objectId != 0 && obj.Id != objectId) continue; + if (m_FilterTypeId > 0 && obj.TypeId != m_FilterTypeId) + continue; + var root = m_SerializedFile.GetTypeTreeRoot(obj.Id); - if (!ObjectMatchesTypeFilter(obj, root)) + if (m_TypeFilter != null && m_FilterTypeId == 0 && !MatchesTypeNameFilter(obj, root)) continue; var offset = obj.Offset; @@ -500,14 +505,8 @@ string ReadPascalStringAndAlign(ref long offset) static bool IsTerminusSentinel(string className, string namespaceName, string assemblyName) => className == "Terminus" && namespaceName == "UnityEngine.DMAT" && assemblyName == "FAKE_ASM"; - bool ObjectMatchesTypeFilter(ObjectInfo obj, TypeTreeNode root) + bool MatchesTypeNameFilter(ObjectInfo obj, TypeTreeNode root) { - if (m_TypeFilter == null) - return true; - - if (m_FilterByTypeId) - return obj.TypeId == m_FilterTypeId; - var typeName = TypeIdRegistry.GetTypeName(obj.TypeId); // GetTypeName returns the id as a string when the type is unknown; // fall back to the TypeTree root node for script types. @@ -545,7 +544,7 @@ string ReadValue(TypeTreeNode node, long offset) return m_Reader.ReadUInt64(offset).ToString(); case TypeCode.SByte: - return m_Reader.ReadUInt8(offset).ToString(); + return m_Reader.ReadInt8(offset).ToString(); case TypeCode.Byte: case TypeCode.Char: