Skip to content

Commit dfc6e2c

Browse files
[#99] Resolve ContentDirectory references via ContentLayout.json and validate analyze inputs (#102)
Finish the work for issue 99
1 parent ee9fda8 commit dfc6e2c

14 files changed

Lines changed: 784 additions & 72 deletions

Analyzer/AnalyzerTool.cs

Lines changed: 171 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
using System.Collections.Generic;
33
using System.Diagnostics;
44
using System.IO;
5+
using System.Linq;
6+
using Newtonsoft.Json;
57
using UnityDataTools.Analyzer.SQLite.Handlers;
68
using UnityDataTools.Analyzer.SQLite.Parsers;
79
using UnityDataTools.Analyzer.SQLite.Writers;
10+
using UnityDataTools.Analyzer.Util;
811
using UnityDataTools.FileSystem;
912
using UnityDataTools.Models;
1013

@@ -14,12 +17,23 @@ public class AnalyzerTool
1417
{
1518
AnalyzeOptions m_Options;
1619

17-
public List<ISQLiteFileParser> parsers = new List<ISQLiteFileParser>()
20+
// Shared between the ContentLayout import and the serialized-file analysis: both must agree
21+
// on the id assigned to each serialized file name, and the layout's dependency information
22+
// is what resolves the external references of ContentDirectory files (issue #99).
23+
private IdProvider<string> m_SerializedFileIdProvider = new();
24+
private ContentFileDependencyMap m_ContentFileDependencies = new();
25+
26+
public List<ISQLiteFileParser> parsers;
27+
28+
public AnalyzerTool()
1829
{
19-
new ContentLayoutParser(),
20-
new AddressablesBuildLayoutParser(),
21-
new SerializedFileParser(),
22-
};
30+
parsers = new List<ISQLiteFileParser>()
31+
{
32+
new ContentLayoutParser(m_SerializedFileIdProvider, m_ContentFileDependencies),
33+
new AddressablesBuildLayoutParser(),
34+
new SerializedFileParser(m_SerializedFileIdProvider, m_ContentFileDependencies),
35+
};
36+
}
2337

2438
public class AnalyzeOptions
2539
{
@@ -38,6 +52,15 @@ public int Analyze(AnalyzeOptions options)
3852
{
3953
m_Options = options;
4054

55+
var files = CollectFiles();
56+
57+
// Validate the ContentDirectory-related inputs before creating the database, so an
58+
// invalid combination fails without leaving a partial database behind.
59+
if (!PrepareContentDirectoryInputs(files))
60+
{
61+
return 1;
62+
}
63+
4164
using SQLiteWriter writer = new(m_Options.DatabaseName);
4265

4366
try
@@ -61,8 +84,6 @@ public int Analyze(AnalyzeOptions options)
6184
var timer = new Stopwatch();
6285
timer.Start();
6386

64-
var files = CollectFiles();
65-
6687
int countFailures = 0;
6788
int countSuccess = 0;
6889
int countIgnored = 0;
@@ -152,6 +173,149 @@ public int Analyze(AnalyzeOptions options)
152173
return 0;
153174
}
154175

176+
// Validates the ContentDirectory-related inputs and prepares the file list (issue #99):
177+
// enforces that a single build is analyzed, selects the ContentLayout.json whose
178+
// BuildManifestHash matches that build (dropping any others), and moves it to the front of
179+
// the list so it is imported before the content files whose references it resolves. Returns
180+
// false, after printing an error, when the input combination is invalid.
181+
bool PrepareContentDirectoryInputs(List<(string FullPath, string DisplayRoot)> files)
182+
{
183+
const string hashFileName = "BuildManifestHash.txt";
184+
185+
var layoutCandidates = files.Where(f => ContentLayoutParser.IsContentLayoutFile(f.FullPath)).ToList();
186+
var hashFiles = files
187+
.Where(f => string.Equals(Path.GetFileName(f.FullPath), hashFileName, StringComparison.OrdinalIgnoreCase))
188+
.Select(f => f.FullPath)
189+
.ToList();
190+
191+
// ContentDirectory output is recognized by its .cf content files, or by its archive when
192+
// built compressed. The BuildManifestHash.txt identifying the build sits next to them; it
193+
// is not always on the input (e.g. when specific files are passed), so pick it up from
194+
// the directories containing the content.
195+
var contentDirectories = files
196+
.Where(f => HasExtension(f.FullPath, ".cf") || HasExtension(f.FullPath, ".archive"))
197+
.Select(f => Path.GetDirectoryName(Path.GetFullPath(f.FullPath)))
198+
.Distinct(StringComparer.OrdinalIgnoreCase);
199+
200+
if (hashFiles.Count == 0)
201+
{
202+
hashFiles.AddRange(contentDirectories
203+
.Select(dir => Path.Combine(dir, hashFileName))
204+
.Where(File.Exists));
205+
}
206+
207+
List<string> buildHashes;
208+
try
209+
{
210+
buildHashes = hashFiles.Select(f => File.ReadAllText(f).Trim()).Distinct().ToList();
211+
}
212+
catch (Exception e)
213+
{
214+
Console.Error.WriteLine($"Error reading {hashFileName}: {e.Message}");
215+
return false;
216+
}
217+
218+
if (buildHashes.Count > 1)
219+
{
220+
Console.Error.WriteLine("The input contains more than one ContentDirectory build (different BuildManifestHash.txt values). Analyze a single build at a time.");
221+
return false;
222+
}
223+
224+
var buildHash = buildHashes.Count == 1 ? buildHashes[0] : null;
225+
var hasContentDirectory = buildHash != null || files.Any(f => HasExtension(f.FullPath, ".cf"));
226+
227+
if (layoutCandidates.Count == 0)
228+
{
229+
if (hasContentDirectory)
230+
{
231+
Console.Error.WriteLine(
232+
"Warning: analyzing ContentDirectory output without its ContentLayout.json. The analysis will be incomplete: " +
233+
"references between content files cannot be resolved (they will appear in dangling_refs) and source asset " +
234+
"information is unavailable. Re-run with the build's ContentLayout.json (found in its build report folder) " +
235+
"included in the input paths.");
236+
}
237+
238+
return true;
239+
}
240+
241+
(string FullPath, string DisplayRoot) selected;
242+
243+
if (hasContentDirectory)
244+
{
245+
if (buildHash == null)
246+
{
247+
Console.Error.WriteLine("A ContentLayout.json is in the input but no BuildManifestHash.txt was found for the ContentDirectory content, so the layout cannot be validated against the build.");
248+
return false;
249+
}
250+
251+
// The hash match guarantees the layout describes exactly this build; a stale or
252+
// unrelated layout would silently produce misleading results.
253+
selected = layoutCandidates.FirstOrDefault(c => TryReadBuildManifestHash(c.FullPath) == buildHash);
254+
255+
if (selected.FullPath == null)
256+
{
257+
Console.Error.WriteLine($"No ContentLayout.json in the input matches the analyzed build (BuildManifestHash {buildHash}). Include the layout from the build report folder of this build.");
258+
return false;
259+
}
260+
}
261+
else if (layoutCandidates.Count == 1)
262+
{
263+
// A layout without its build content is a valid input (e.g. to query a large layout).
264+
selected = layoutCandidates[0];
265+
}
266+
else
267+
{
268+
Console.Error.WriteLine("The input contains multiple ContentLayout.json files but no ContentDirectory build to match them against. Only a single layout can be analyzed.");
269+
return false;
270+
}
271+
272+
foreach (var candidate in layoutCandidates)
273+
{
274+
if (candidate != selected)
275+
{
276+
Console.Error.WriteLine($"Ignoring \"{candidate.FullPath}\": its BuildManifestHash does not match the analyzed build.");
277+
files.Remove(candidate);
278+
}
279+
}
280+
281+
// Import the layout before the content files it describes, so their references can be
282+
// resolved through it.
283+
files.Remove(selected);
284+
files.Insert(0, selected);
285+
286+
return true;
287+
}
288+
289+
static bool HasExtension(string path, string extension)
290+
{
291+
return string.Equals(Path.GetExtension(path), extension, StringComparison.OrdinalIgnoreCase);
292+
}
293+
294+
// Reads the top-level BuildManifestHash of a ContentLayout.json without parsing the whole
295+
// file (layouts of large builds are big; the hash is one of the first properties). Returns
296+
// null when the value cannot be found or the file is not valid json.
297+
static string TryReadBuildManifestHash(string path)
298+
{
299+
try
300+
{
301+
using var reader = new JsonTextReader(File.OpenText(path));
302+
303+
for (int i = 0; i < 64 && reader.Read(); ++i)
304+
{
305+
if (reader.TokenType == JsonToken.PropertyName && reader.Depth == 1 &&
306+
"BuildManifestHash".Equals(reader.Value))
307+
{
308+
return reader.ReadAsString();
309+
}
310+
}
311+
}
312+
catch (Exception)
313+
{
314+
}
315+
316+
return null;
317+
}
318+
155319
// Expands the input paths into the concrete files to analyze. Each result pairs the file with the
156320
// root used to render its relative path in progress/error messages: the scanned directory for files
157321
// found by scanning, or the file's own directory for explicitly-named files. Duplicates reached via

Analyzer/SQLite/Parsers/ContentLayoutParser.cs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Newtonsoft.Json;
55
using UnityDataTools.Analyzer.SQLite.Handlers;
66
using UnityDataTools.Analyzer.SQLite.Writers;
7+
using UnityDataTools.Analyzer.Util;
78
using UnityDataTools.Models;
89

910
namespace UnityDataTools.Analyzer.SQLite.Parsers
@@ -15,24 +16,37 @@ public class ContentLayoutParser : ISQLiteFileParser
1516
{
1617
private ContentLayoutSQLWriter m_Writer;
1718
private string m_ImportedLayout;
19+
private IdProvider<string> m_SerializedFileIdProvider;
20+
private ContentFileDependencyMap m_ContentFileDependencies;
1821

1922
public bool Verbose { get; set; }
2023
public bool SkipReferences { get; set; }
2124
public bool SkipCrc { get; set; }
2225

26+
public ContentLayoutParser(IdProvider<string> serializedFileIdProvider, ContentFileDependencyMap contentFileDependencies)
27+
{
28+
m_SerializedFileIdProvider = serializedFileIdProvider;
29+
m_ContentFileDependencies = contentFileDependencies;
30+
}
31+
2332
public void Init(SqliteConnection db)
2433
{
25-
m_Writer = new ContentLayoutSQLWriter(db);
34+
m_Writer = new ContentLayoutSQLWriter(db, m_SerializedFileIdProvider, m_ContentFileDependencies);
2635
}
2736

28-
public bool CanParse(string filename)
37+
// Unity always writes this exact filename into the build report directory, so unlike the
38+
// Addressables build reports (whose filenames can embed timestamps) no content sniffing
39+
// is needed.
40+
public static bool IsContentLayoutFile(string filename)
2941
{
30-
// Unity always writes this exact filename into the build report directory, so unlike
31-
// the Addressables build reports (whose filenames can embed timestamps) no content
32-
// sniffing is needed.
3342
return string.Equals(Path.GetFileName(filename), "ContentLayout.json", StringComparison.OrdinalIgnoreCase);
3443
}
3544

45+
public bool CanParse(string filename)
46+
{
47+
return IsContentLayoutFile(filename);
48+
}
49+
3650
public void Parse(string filename)
3751
{
3852
ContentLayout layout;

Analyzer/SQLite/Parsers/SerializedFileParser.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,19 @@ namespace UnityDataTools.Analyzer.SQLite.Parsers
1212
public class SerializedFileParser : ISQLiteFileParser
1313
{
1414
private SerializedFileSQLiteWriter m_Writer;
15+
private Util.IdProvider<string> m_SerializedFileIdProvider;
16+
private Util.ContentFileDependencyMap m_ContentFileDependencies;
1517

1618
public bool Verbose { get; set; }
1719
public bool SkipReferences { get; set; }
1820
public bool SkipCrc { get; set; }
1921

22+
public SerializedFileParser(Util.IdProvider<string> serializedFileIdProvider, Util.ContentFileDependencyMap contentFileDependencies)
23+
{
24+
m_SerializedFileIdProvider = serializedFileIdProvider;
25+
m_ContentFileDependencies = contentFileDependencies;
26+
}
27+
2028
public bool CanParse(string filename)
2129
{
2230
// First check if the file is in the ignore list (by extension or filename)
@@ -43,7 +51,8 @@ public void FinalizeDatabase()
4351

4452
public void Init(SqliteConnection db)
4553
{
46-
m_Writer = new SerializedFileSQLiteWriter(db, SkipReferences, SkipCrc);
54+
m_Writer = new SerializedFileSQLiteWriter(db, SkipReferences, SkipCrc,
55+
m_SerializedFileIdProvider, m_ContentFileDependencies);
4756
}
4857

4958
public void Parse(string filename)

0 commit comments

Comments
 (0)