Skip to content

Commit 40d7578

Browse files
[#77] Detect missing TypeTrees up front in analyze and dump (#78)
Analyzing AssetBundles whose SerializedFiles have no TypeTrees handed the files to the native loader, which emitted misleading "Invalid serialized file version" errors and could crash the process with an access violation (0xC0000005) Fix is to detect missing TypeTrees before the native open and skip such files cleanly, Fix applies to both dump and analyze commands. Fix applies to Player data and other build types in addition to AssetBundles.
1 parent 1ac7e19 commit 40d7578

9 files changed

Lines changed: 327 additions & 40 deletions

File tree

Analyzer/AnalyzerTool.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using UnityDataTools.Analyzer.SQLite.Parsers;
77
using UnityDataTools.Analyzer.SQLite.Writers;
88
using UnityDataTools.Models;
9-
using UnityDataTools.BinaryFormat;
109
using UnityDataTools.FileSystem;
1110

1211
namespace UnityDataTools.Analyzer;
@@ -66,6 +65,7 @@ public int Analyze(AnalyzeOptions options)
6665
int countFailures = 0;
6766
int countSuccess = 0;
6867
int countIgnored = 0;
68+
int countNoTypeTrees = 0;
6969
int i = 1;
7070
foreach (var (file, displayRoot) in files)
7171
{
@@ -82,15 +82,21 @@ public int Analyze(AnalyzeOptions options)
8282
ReportProgress(relativePath, i, files.Count);
8383
countSuccess++;
8484
}
85-
catch (SerializedFileOpenException e)
85+
catch (SerializedFileOpenException e) when (e.MissingTypeTrees)
86+
{
87+
// The file has no TypeTrees and was rejected before opening. This is an
88+
// expected, distinct outcome — reported and counted separately so a large
89+
// run can tell these apart from genuine failures.
90+
EraseProgressLine();
91+
Console.Error.WriteLine($"Skipped (no TypeTrees): {relativePath}");
92+
countNoTypeTrees++;
93+
}
94+
catch (SerializedFileOpenException)
8695
{
8796
// Expected failure — the file content could not be parsed.
8897
// Don't print a stack trace; it adds no value for this known failure mode.
8998
EraseProgressLine();
9099
Console.Error.WriteLine($"Failed to open: {relativePath}");
91-
var hint = SerializedFileDetector.GetOpenFailureHint(e.FilePath);
92-
if (hint != null)
93-
Console.Error.WriteLine(hint);
94100
countFailures++;
95101
}
96102
catch (Exception e)
@@ -123,7 +129,7 @@ public int Analyze(AnalyzeOptions options)
123129
}
124130

125131
Console.WriteLine();
126-
Console.WriteLine($"Finalizing database. Successfully processed files: {countSuccess}, Failed files: {countFailures}, Ignored files: {countIgnored}");
132+
Console.WriteLine($"Finalizing database. Successfully processed files: {countSuccess}, Failed files: {countFailures}, Files without TypeTrees: {countNoTypeTrees}, Ignored files: {countIgnored}");
127133

128134
writer.End();
129135
foreach (var parser in parsers)

Analyzer/SQLite/Parsers/SerializedFileParser.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ void ProcessFile(string file, string rootDirectory)
8181
if (ArchiveDetector.IsUnityArchive(file))
8282
{
8383
bool archiveHadErrors = false;
84+
bool archiveHadMissingTypeTrees = false;
8485
using (UnityArchive archive = UnityFileSystem.MountArchive(file, "archive:" + Path.DirectorySeparatorChar))
8586
{
8687
if (archive == null)
@@ -100,6 +101,12 @@ void ProcessFile(string file, string rootDirectory)
100101
{
101102
m_Writer.WriteSerializedFile(node.Path, "archive:/" + node.Path, Path.GetDirectoryName(file));
102103
}
104+
catch (SerializedFileOpenException e) when (e.MissingTypeTrees)
105+
{
106+
// The file has no TypeTrees and was rejected before opening. This is
107+
// tracked separately so it isn't lumped with genuine processing errors.
108+
archiveHadMissingTypeTrees = true;
109+
}
103110
catch (Exception e)
104111
{
105112
// the most likely exception here is Microsoft.Data.Sqlite.SqliteException,
@@ -124,10 +131,16 @@ void ProcessFile(string file, string rootDirectory)
124131
}
125132
}
126133

134+
// Genuine errors take precedence over missing TypeTrees when reporting the archive's outcome.
127135
if (archiveHadErrors)
128136
{
129137
throw new Exception("One or more files in the archive failed to process");
130138
}
139+
140+
if (archiveHadMissingTypeTrees)
141+
{
142+
throw new SerializedFileOpenException(file, missingTypeTrees: true);
143+
}
131144
}
132145
else
133146
{

Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using UnityDataTools.Analyzer.SQLite.Commands.SerializedFile;
77
using UnityDataTools.Analyzer.SQLite.Handlers;
88
using UnityDataTools.Analyzer.Util;
9+
using UnityDataTools.BinaryFormat;
910
using UnityDataTools.FileSystem;
1011
using UnityDataTools.FileSystem.TypeTreeReaders;
1112

@@ -116,6 +117,16 @@ public void EndAssetBundle()
116117

117118
public void WriteSerializedFile(string relativePath, string fullPath, string containingFolder)
118119
{
120+
// A file without TypeTrees can only be opened when its types exactly match this build of
121+
// UnityFileSystemApi. Handing such a file to the native loader produces misleading version
122+
// mismatch errors and can crash the process, so detect and reject it up front. The native
123+
// VFS path here may be a real file or an entry inside a mounted archive.
124+
using (var detectStream = new UnityFileStream(fullPath))
125+
{
126+
if (SerializedFileDetector.IsMissingTypeTrees(detectStream))
127+
throw new SerializedFileOpenException(fullPath, missingTypeTrees: true);
128+
}
129+
119130
using var sf = UnityFileSystem.OpenSerializedFile(fullPath);
120131
using var reader = new UnityFileReader(fullPath, 64 * 1024 * 1024);
121132
using var pptrReader = new PPtrAndCrcProcessor(sf, reader, containingFolder, m_SkipCrc, AddReference);

TextDumper/TextDumperTool.cs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ public int Dump(DumpOptions options)
7676

7777
int DumpSerializedFile()
7878
{
79+
if (ReportIfMissingTypeTrees(m_Options.Path, m_Options.Path))
80+
return 1;
81+
7982
try
8083
{
8184
if (m_Options.ToStdout)
@@ -93,23 +96,33 @@ int DumpSerializedFile()
9396
}
9497
catch (SerializedFileOpenException)
9598
{
96-
var hint = SerializedFileDetector.GetOpenFailureHint(m_Options.Path);
97-
if (hint != null)
98-
{
99-
Console.Error.WriteLine();
100-
Console.Error.WriteLine(hint);
101-
}
99+
Console.Error.WriteLine($"Error: Failed to open serialized file: {m_Options.Path}");
102100
return 1;
103101
}
104102

105103
return 0;
106104
}
107105

106+
// dump needs TypeTrees to interpret object data, so a SerializedFile without them cannot be dumped.
107+
// Detecting this up front avoids handing the file to the native loader, which would otherwise emit
108+
// misleading version mismatch errors or crash the process. Returns true (and prints a clear message)
109+
// when the file has no TypeTrees. The path may be a real file or an entry in a mounted archive.
110+
bool ReportIfMissingTypeTrees(string path, string displayName)
111+
{
112+
using var stream = new UnityFileStream(path);
113+
if (!SerializedFileDetector.IsMissingTypeTrees(stream))
114+
return false;
115+
116+
Console.Error.WriteLine($"Error: \"{displayName}\" has no TypeTrees. The dump command needs TypeTrees to interpret the serialized object data, so this file cannot be dumped.");
117+
return true;
118+
}
119+
108120
// For convenience we also support directly dumping serialized files that are inside an archive,
109121
// so that it's not necessary to use `archive extract` if you only want to see values from the object serialization.
110122
int DumpArchive()
111123
{
112124
using var archive = UnityFileSystem.MountArchive(m_Options.Path, "/");
125+
bool anyMissingTypeTrees = false;
113126

114127
if (m_Options.ToStdout)
115128
{
@@ -139,6 +152,8 @@ int DumpArchive()
139152

140153
var node2 = singleSerializedFile.Value;
141154
Console.Error.WriteLine($"Processing {node2.Path} {node2.Size} {node2.Flags}");
155+
if (ReportIfMissingTypeTrees("/" + node2.Path, node2.Path))
156+
return 1;
142157
m_Writer = Console.Out;
143158
OutputSerializedFile("/" + node2.Path);
144159
m_Writer.Flush();
@@ -151,14 +166,20 @@ int DumpArchive()
151166

152167
if (node.Flags.HasFlag(ArchiveNodeFlags.SerializedFile))
153168
{
169+
if (ReportIfMissingTypeTrees("/" + node.Path, node.Path))
170+
{
171+
anyMissingTypeTrees = true;
172+
continue;
173+
}
174+
154175
using var writer = new StreamWriter(Path.Combine(m_Options.OutputPath, Path.GetFileName(node.Path) + ".txt"), false);
155176
m_Writer = writer;
156177
OutputSerializedFile("/" + node.Path);
157178
}
158179
}
159180
}
160181

161-
return 0;
182+
return anyMissingTypeTrees ? 1 : 0;
162183
}
163184

164185
void OutputSerializedFile(string path)

UnityBinaryFormat/SerializedFileDetector.cs

Lines changed: 75 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -303,16 +303,37 @@ public static bool TryDetectSerializedFile(string filePath, out SerializedFileIn
303303
try
304304
{
305305
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
306+
return TryDetectSerializedFile(stream, out info);
307+
}
308+
catch
309+
{
310+
return false;
311+
}
312+
}
313+
314+
/// <summary>
315+
/// Stream-based variant of <see cref="TryDetectSerializedFile(string, out SerializedFileInfo)"/>.
316+
/// Reads from the current contents of <paramref name="stream"/> (seeking it to the start first),
317+
/// allowing detection of files that are not directly on disk (e.g. inside a mounted archive).
318+
/// </summary>
319+
public static bool TryDetectSerializedFile(Stream stream, out SerializedFileInfo info)
320+
{
321+
info = null;
322+
323+
try
324+
{
306325
long fileLength = stream.Length;
307326

308327
// Quick rejection: file must be at least large enough for the legacy header
309328
if (fileLength < LegacyHeaderSize)
310329
return false;
311330

331+
stream.Seek(0, SeekOrigin.Begin);
332+
312333
// Read enough bytes to cover a modern header (48 bytes)
313334
// We'll determine which format to parse based on the version field
314335
byte[] headerBytes = new byte[ModernHeaderSize];
315-
int bytesRead = stream.Read(headerBytes, 0, headerBytes.Length);
336+
int bytesRead = stream.ReadAtLeast(headerBytes, ModernHeaderSize, throwOnEndOfStream: false);
316337

317338
if (bytesRead < LegacyHeaderSize)
318339
return false;
@@ -528,32 +549,71 @@ public static bool TryParseMetadata(string filePath, SerializedFileInfo headerIn
528549
metadata = null;
529550
errorMessage = null;
530551

552+
// The supported-version check depends only on the header, so do it before touching the file.
553+
if (!IsMetadataVersionSupported(headerInfo.Version, out errorMessage))
554+
return false;
555+
556+
try
557+
{
558+
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
559+
return TryParseMetadata(stream, headerInfo, out metadata, out errorMessage);
560+
}
561+
catch
562+
{
563+
errorMessage = "An unexpected error occurred while opening the file.";
564+
return false;
565+
}
566+
}
567+
568+
/// <summary>
569+
/// Validates that the SerializedFile version is within the range whose metadata layout this
570+
/// parser understands. Returns false with an explanatory message when it is not.
571+
/// </summary>
572+
private static bool IsMetadataVersionSupported(uint version, out string errorMessage)
573+
{
531574
// Only support version >= 19 (Unity 2019.1). Older files have metadata format
532575
// differences we have not implemented.
533-
if (headerInfo.Version < MinMetadataParseVersion)
576+
if (version < MinMetadataParseVersion)
534577
{
535-
errorMessage = $"Metadata parsing is not supported for SerializedFile version {headerInfo.Version}. " +
578+
errorMessage = $"Metadata parsing is not supported for SerializedFile version {version}. " +
536579
$"Version {MinMetadataParseVersion} (Unity 2019.1) or newer is required.";
537580
return false;
538581
}
539582

540583
// Reject versions beyond the highest known format. Future Unity versions may change the
541584
// metadata layout in ways that would cause incorrect results or a parse failure.
542585
// A newer version of UnityDataTool is required to read these files.
543-
if (headerInfo.Version > MaxMetadataParseVersion)
586+
if (version > MaxMetadataParseVersion)
544587
{
545-
errorMessage = $"SerializedFile version {headerInfo.Version} is not supported. " +
588+
errorMessage = $"SerializedFile version {version} is not supported. " +
546589
$"UnityDataTool supports up to version {MaxMetadataParseVersion}. " +
547590
$"Please use a newer version of UnityDataTool to read this file.";
548591
return false;
549592
}
550593

594+
errorMessage = null;
595+
return true;
596+
}
597+
598+
/// <summary>
599+
/// Stream-based variant of <see cref="TryParseMetadata(string, SerializedFileInfo, out SerializedFileMetadata, out string)"/>.
600+
/// When <paramref name="parseExtended"/> is false, only the leading metadata fields (Unity version,
601+
/// target platform and EnableTypeTree) are read; the type/object/reference arrays are skipped. This
602+
/// is the cheap path for callers that only need to know whether the file has TypeTrees.
603+
/// </summary>
604+
public static bool TryParseMetadata(Stream stream, SerializedFileInfo headerInfo, out SerializedFileMetadata metadata, out string errorMessage, bool parseExtended = true)
605+
{
606+
metadata = null;
607+
errorMessage = null;
608+
609+
if (!IsMetadataVersionSupported(headerInfo.Version, out errorMessage))
610+
return false;
611+
551612
try
552613
{
553614
long metadataOffset = headerInfo.IsLegacyFormat ? LegacyHeaderSize : ModernHeaderSize;
554615
bool swap = headerInfo.Endianness == BigEndian;
555616

556-
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
557617
stream.Seek(metadataOffset, SeekOrigin.Begin);
558618
using var reader = new BinaryReader(stream, System.Text.Encoding.ASCII, leaveOpen: true);
559619

@@ -583,7 +643,8 @@ public static bool TryParseMetadata(string filePath, SerializedFileInfo headerIn
583643

584644
// Parse the rest of the metadata section. Protected by its own try/catch so that any
585645
// failure there still returns a partially-populated metadata struct.
586-
ParseExtendedMetadata(reader, headerInfo, swap, metadataOffset, metadata);
646+
if (parseExtended)
647+
ParseExtendedMetadata(reader, headerInfo, swap, metadataOffset, metadata);
587648

588649
return true;
589650
}
@@ -595,22 +656,15 @@ public static bool TryParseMetadata(string filePath, SerializedFileInfo headerIn
595656
}
596657

597658
/// <summary>
598-
/// Returns a diagnostic hint explaining why a SerializedFile may have failed to open,
599-
/// or null if no specific diagnosis is available.
600-
/// Currently detects the common case of missing TypeTrees (player builds compiled
601-
/// without type information, which the DLL reports as a generic unknown error).
659+
/// Returns true when the stream is a SerializedFile we can positively confirm has no TypeTrees.
660+
/// Returns false for files that have TypeTrees and for anything we cannot parse (so callers fall
661+
/// back to the normal open path rather than skipping a file we simply did not understand).
602662
/// </summary>
603-
/// <param name="path">Real filesystem path to the file that failed to open.</param>
604-
public static string GetOpenFailureHint(string path)
663+
public static bool IsMissingTypeTrees(Stream stream)
605664
{
606-
if (TryDetectSerializedFile(path, out var fileInfo) &&
607-
TryParseMetadata(path, fileInfo, out var metadata, out _) &&
608-
!metadata.EnableTypeTree)
609-
{
610-
return "Note: This file does not have TypeTrees and can only be opened if all the " +
611-
"types it uses exactly match the types in the build of UnityFileSystemApi being used.";
612-
}
613-
return null;
665+
return TryDetectSerializedFile(stream, out var fileInfo)
666+
&& TryParseMetadata(stream, fileInfo, out var metadata, out _, parseExtended: false)
667+
&& !metadata.EnableTypeTree;
614668
}
615669

616670
/// <summary>

0 commit comments

Comments
 (0)