Skip to content

Commit d930e40

Browse files
[#85] Track dangling references in analyze (#96)
* [#85] Track dangling references in analyze Add a dangling_refs table and dangling_refs_view recording references whose target object was assigned an id but never written to the objects table because its serialized file was not part of the analyzed input (partial bundle sets, unity default resources, ContentDirectory builds without ContentLayout.json). Every object id now resolves to exactly one of objects or dangling_refs. Detection runs after all files are processed via a new ISQLiteFileParser.FinalizeDatabase() hook: the writer reads back the written object/file ids and records any assigned id with no objects row, adding a serialized_files row (archive NULL) for each un-analyzed target file so its name is available. Honors --skip-references. Schema user_version bumped 4 -> 5. Existing serialized_files count assertions now count analyzed files only (equal to the previous golden values, so no ExpectedData regeneration). * [#85] Don't record null m_GameObject as a dangling ref The writer resolved a component's m_GameObject PPtr to an object id even when it was null (m_FileID 0 and m_PathID 0), allocating a phantom (file, 0) object that no row is ever written for. With dangling_refs tracking this surfaced as confusing object_id 0 entries pointing at already-analyzed files. Only resolve m_GameObject when the PPtr is non-null; otherwise leave the game_object column empty, as the no-m_GameObject branch already does. A complete AssetBundle build now has zero dangling_refs, and every dangling target is reachable through refs, so dangling_refs_view covers them all. Tests assert dangling_refs has no object_id 0 rows. * [#85] Skip null-PPtr AssetBundle container entries Editor-only assets (ShaderSubGraph, .preset) can appear in an AssetBundle's m_Container with a null PPtr (m_FileID 0 and m_PathID 0) because they have no runtime object. The non-scene branch resolved that to a phantom (file, 0) object id and wrote an assetbundle_assets row pointing at it, which then surfaced as a false dangling reference with object_id 0 into an already-analyzed file. Skip container entries whose PPtr is null. assetbundle_asset_view already dropped these rows via its INNER JOIN, so this only removes rows that pointed at a non-existent object. Verified on a large Addressables build: the false object_id 0 dangling refs are gone, leaving only the expected unity default resources targets.
1 parent 9cde9d8 commit d930e40

14 files changed

Lines changed: 378 additions & 16 deletions

Analyzer/AnalyzerTool.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
using UnityDataTools.Analyzer.SQLite.Handlers;
66
using UnityDataTools.Analyzer.SQLite.Parsers;
77
using UnityDataTools.Analyzer.SQLite.Writers;
8-
using UnityDataTools.Models;
98
using UnityDataTools.FileSystem;
9+
using UnityDataTools.Models;
1010

1111
namespace UnityDataTools.Analyzer;
1212

@@ -131,6 +131,13 @@ public int Analyze(AnalyzeOptions options)
131131
Console.WriteLine();
132132
Console.WriteLine($"Finalizing database. Successfully processed files: {countSuccess}, Failed files: {countFailures}, Files without TypeTrees: {countNoTypeTrees}, Ignored files: {countIgnored}");
133133

134+
// Record data that can only be determined once every file has been processed (e.g. which
135+
// referenced objects were never resolved) before the database is finalized.
136+
foreach (var parser in parsers)
137+
{
138+
parser.FinalizeDatabase();
139+
}
140+
134141
writer.End();
135142
foreach (var parser in parsers)
136143
{

Analyzer/Resources/Init.sql

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,53 @@ CREATE TABLE IF NOT EXISTS refs
8181
property_type INTEGER
8282
);
8383

84+
-- One row per referenced object that analyze assigned an id to but never wrote an objects row
85+
-- for (its serialized file was not part of the analyzed input). Written after all files are
86+
-- processed, once we can tell which assigned ids never became objects. Common causes: analyzing a
87+
-- partial set of AssetBundles, references into "unity default resources" (shipped without
88+
-- TypeTrees), or a ContentDirectory build analyzed without ContentLayout.json.
89+
-- Columns mirror the objects table so every object id resolves to exactly one of objects or
90+
-- dangling_refs:
91+
-- id - the analyzer object id (no row in objects has this id).
92+
-- object_id - the target's local file id (LFID / m_PathID) within its serialized file.
93+
-- serialized_file - references the serialized_files row of the (un-analyzed) file the target
94+
-- lives in. That row has archive NULL and no objects of its own.
95+
CREATE TABLE IF NOT EXISTS dangling_refs
96+
(
97+
id INTEGER,
98+
object_id INTEGER,
99+
serialized_file INTEGER,
100+
PRIMARY KEY (id)
101+
);
102+
84103
-- Resolves the property_path and property_type ids in the refs table to their string values.
85104
CREATE VIEW refs_view AS
86105
SELECT r.object, r.referenced_object, pn.name AS property_path, pt.name AS property_type
87106
FROM refs r
88107
INNER JOIN property_names pn ON r.property_path = pn.id
89108
INNER JOIN property_types pt ON r.property_type = pt.id;
90109

110+
-- Resolves dangling_refs to the source object(s) that reference each missing target, one row per
111+
-- (referencing object -> dangling target) reference. Not populated when analyze is run with
112+
-- --skip-references (neither refs nor dangling_refs are populated in that mode).
113+
CREATE VIEW dangling_refs_view AS
114+
SELECT
115+
r.object AS source_id,
116+
src_sf.name AS source_serialized_file,
117+
src_o.object_id AS source_object_id,
118+
pn.name AS property_path,
119+
pt.name AS property_type,
120+
d.id AS target_id,
121+
tgt_sf.name AS target_serialized_file,
122+
d.object_id AS target_object_id
123+
FROM dangling_refs d
124+
INNER JOIN refs r ON r.referenced_object = d.id
125+
INNER JOIN objects src_o ON r.object = src_o.id
126+
INNER JOIN serialized_files src_sf ON src_o.serialized_file = src_sf.id
127+
INNER JOIN serialized_files tgt_sf ON d.serialized_file = tgt_sf.id
128+
LEFT JOIN property_names pn ON r.property_path = pn.id
129+
LEFT JOIN property_types pt ON r.property_type = pt.id;
130+
91131
CREATE VIEW object_view AS
92132
SELECT o.id, o.object_id, ab.name AS archive, sf.name AS serialized_file, t.name AS type, o.name, o.game_object, o.size,
93133
CASE
@@ -157,8 +197,9 @@ INSERT INTO types (id, name) VALUES (-1, 'Scene');
157197
-- 1 = normalized refs table (issue #44); 2 = renamed assets/asset_dependencies tables to
158198
-- assetbundle_assets/preload_dependencies (issue #82); 3 = renamed asset_bundles table to archives
159199
-- and the asset_bundle column/alias to archive (issue #68); 4 = build_report_packed_asset_contents_view
160-
-- type column changed from numeric id to type name (issue #55); databases produced before versioning report 0.
161-
PRAGMA user_version = 4;
200+
-- type column changed from numeric id to type name (issue #55); 5 = added dangling_refs table/view
201+
-- (issue #85); databases produced before versioning report 0.
202+
PRAGMA user_version = 5;
162203

163204
PRAGMA synchronous = OFF;
164205
PRAGMA journal_mode = MEMORY;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Collections.Generic;
2+
using Microsoft.Data.Sqlite;
3+
using UnityDataTools.Analyzer.SQLite.Commands;
4+
5+
namespace UnityDataTools.Analyzer.SQLite.Commands.SerializedFile
6+
{
7+
/* TABLE DEFINITION:
8+
create table dangling_refs
9+
(
10+
id INTEGER,
11+
object_id INTEGER,
12+
serialized_file INTEGER,
13+
PRIMARY KEY (id)
14+
);
15+
*/
16+
internal class AddDanglingRef : AbstractCommand
17+
{
18+
protected override string TableName => "dangling_refs";
19+
20+
protected override string DDLSource => null;
21+
22+
protected override Dictionary<string, SqliteType> Fields => new()
23+
{
24+
{ "id", SqliteType.Integer },
25+
{ "object_id", SqliteType.Integer },
26+
{ "serialized_file", SqliteType.Integer }
27+
};
28+
}
29+
}

Analyzer/SQLite/Handlers/AssetBundleHandler.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ public void Process(Context ctx, long objectId, RandomAccessReader reader, out s
6868
{
6969
if (!assetBundle.IsSceneAssetBundle)
7070
{
71+
// Editor-only container entries (e.g. ShaderSubGraph, .preset) can appear in
72+
// m_Container with a null PPtr (m_FileID 0 and m_PathID 0) because they have no
73+
// runtime object. Skip them: resolving the null PPtr would allocate a phantom
74+
// (file, 0) object id and record a bogus dangling reference to it.
75+
if (asset.PPtr.FileId == 0 && asset.PPtr.PathId == 0)
76+
continue;
77+
7178
var fileId = ctx.LocalToDbFileId[asset.PPtr.FileId];
7279
var objId = ctx.ObjectIdProvider.GetId((fileId, asset.PPtr.PathId));
7380
m_InsertCommand.Transaction = ctx.Transaction;

Analyzer/SQLite/Handlers/ISQLiteHandler.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ public interface ISQLiteFileParser : IDisposable
2727
void Init(SqliteConnection db);
2828
bool CanParse(string filename);
2929
void Parse(string filename);
30+
31+
// Called once after all files have been parsed, so a parser can write data that can only be
32+
// determined from the complete set (e.g. dangling references). No-op for parsers that don't
33+
// need it.
34+
void FinalizeDatabase();
35+
3036
public bool Verbose { get; set; }
3137
public bool SkipReferences { get; set; }
3238
public bool SkipCrc { get; set; }

Analyzer/SQLite/Parsers/AddressablesBuildLayoutParser.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
using Newtonsoft.Json;
55
using Newtonsoft.Json.Linq;
66
using UnityDataTools.Analyzer.SQLite.Handlers;
7-
using UnityDataTools.Models;
87
using UnityDataTools.Analyzer.SQLite.Writers;
8+
using UnityDataTools.Models;
99

1010
namespace UnityDataTools.Analyzer.SQLite.Parsers
1111
{
@@ -21,6 +21,12 @@ public void Dispose()
2121
{
2222
m_Writer.Dispose();
2323
}
24+
25+
public void FinalizeDatabase()
26+
{
27+
// Addressables build reports don't produce dangling object references.
28+
}
29+
2430
public void Init(SqliteConnection db)
2531
{
2632
m_Writer = new AddressablesBuildLayoutSQLWriter(db);

Analyzer/SQLite/Parsers/SerializedFileParser.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ public void Dispose()
3535
m_Writer.Dispose();
3636
}
3737

38+
public void FinalizeDatabase()
39+
{
40+
// m_Writer is only Init'd once a file is actually parsed; nothing to finalize otherwise.
41+
m_Writer.FinalizeDatabase();
42+
}
43+
3844
public void Init(SqliteConnection db)
3945
{
4046
m_Writer = new SerializedFileSQLiteWriter(db, SkipReferences, SkipCrc);

Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public class SerializedFileSQLiteWriter : IDisposable
7474
private AddObject m_AddObjectCommand = new AddObject();
7575
private AddType m_AddTypeCommand = new AddType();
7676
private AddPreloadDependency m_InsertDepCommand = new AddPreloadDependency();
77+
private AddDanglingRef m_AddDanglingRefCommand = new AddDanglingRef();
7778

7879
private bool m_Initialized;
7980
private SqliteConnection m_Database;
@@ -112,6 +113,7 @@ private void CreateSQLiteCommands()
112113
m_AddObjectCommand.CreateCommand(m_Database);
113114
m_AddTypeCommand.CreateCommand(m_Database);
114115
m_InsertDepCommand.CreateCommand(m_Database);
116+
m_AddDanglingRefCommand.CreateCommand(m_Database);
115117

116118
m_LastId = m_Database.CreateCommand();
117119
m_LastId.CommandText = "SELECT last_insert_rowid()";
@@ -277,17 +279,22 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con
277279
name = randomAccessReader["m_Name"].GetValue<string>();
278280
}
279281

282+
// Resolve m_GameObject to its analyzer object id for the game_object column, but only
283+
// when the PPtr is non-null. A null PPtr (m_FileID 0 and m_PathID 0) is expected for
284+
// any object that is not a component; resolving it would allocate a phantom id for a
285+
// (file, 0) object that never exists and then surface as a bogus dangling ref.
286+
object gameObject = "";
280287
if (randomAccessReader.HasChild("m_GameObject"))
281288
{
282289
var pptr = randomAccessReader["m_GameObject"];
283-
var fileId = m_LocalToDbFileId[pptr["m_FileID"].GetValue<int>()];
284-
var gameObjectID = m_ObjectIdProvider.GetId((fileId, pptr["m_PathID"].GetValue<long>()));
285-
m_AddObjectCommand.SetValue("game_object", gameObjectID);
286-
}
287-
else
288-
{
289-
m_AddObjectCommand.SetValue("game_object", "");
290+
var gameObjectFileId = pptr["m_FileID"].GetValue<int>();
291+
var gameObjectPathId = pptr["m_PathID"].GetValue<long>();
292+
if (gameObjectFileId != 0 || gameObjectPathId != 0)
293+
{
294+
gameObject = m_ObjectIdProvider.GetId((m_LocalToDbFileId[gameObjectFileId], gameObjectPathId));
295+
}
290296
}
297+
m_AddObjectCommand.SetValue("game_object", gameObject);
291298

292299
// The walk both extracts references and accumulates the CRC, so it is needed
293300
// unless both are disabled. When CRC is on but references are off, the walk
@@ -328,6 +335,78 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con
328335
}
329336
}
330337

338+
// Records every referenced object that was assigned an id but never written to the objects
339+
// table (its serialized file was not part of the analyzed input). Must run after all files are
340+
// processed, since an id is only known to be dangling once nothing has claimed it as an object.
341+
// The set of written objects/files is read back from the database so this is robust to every
342+
// object write site (regular objects, synthetic Scene objects, etc.) without instrumenting them.
343+
public void FinalizeDatabase()
344+
{
345+
// Dangling refs are part of reference tracking, so honor --skip-references (the view that
346+
// makes them useful joins the refs table, which is empty in that mode anyway).
347+
if (!m_Initialized || m_SkipReferences)
348+
return;
349+
350+
var writtenObjectIds = ReadIntSet("SELECT id FROM objects");
351+
var writtenFileIds = ReadIntSet("SELECT id FROM serialized_files");
352+
353+
// Invert the file-name provider so a dangling target's file id maps back to its name.
354+
var fileIdToName = new Dictionary<int, string>();
355+
foreach (var entry in m_SerializedFileIdProvider.Entries)
356+
fileIdToName[entry.Value] = entry.Key;
357+
358+
using var transaction = m_Database.BeginTransaction();
359+
try
360+
{
361+
foreach (var entry in m_ObjectIdProvider.Entries)
362+
{
363+
var objectId = entry.Value;
364+
if (writtenObjectIds.Contains(objectId))
365+
continue;
366+
367+
var (fileId, pathId) = entry.Key;
368+
369+
// Ensure the (un-analyzed) target file has a serialized_files row so the dangling
370+
// ref can name it. We have to put null for archive - even if the target file is inside an AssetBundle
371+
// or other archive file, because we simply don't have that information. This is fundamental to
372+
// how references work in Unity.
373+
if (writtenFileIds.Add(fileId))
374+
{
375+
m_AddSerializedFileCommand.SetTransaction(transaction);
376+
m_AddSerializedFileCommand.SetValue("id", fileId);
377+
m_AddSerializedFileCommand.SetValue("archive", null);
378+
m_AddSerializedFileCommand.SetValue("name",
379+
fileIdToName.TryGetValue(fileId, out var name) ? name : "");
380+
m_AddSerializedFileCommand.ExecuteNonQuery();
381+
}
382+
383+
m_AddDanglingRefCommand.SetTransaction(transaction);
384+
m_AddDanglingRefCommand.SetValue("id", objectId);
385+
m_AddDanglingRefCommand.SetValue("object_id", pathId);
386+
m_AddDanglingRefCommand.SetValue("serialized_file", fileId);
387+
m_AddDanglingRefCommand.ExecuteNonQuery();
388+
}
389+
390+
transaction.Commit();
391+
}
392+
catch (Exception)
393+
{
394+
transaction.Rollback();
395+
throw;
396+
}
397+
}
398+
399+
private HashSet<int> ReadIntSet(string sql)
400+
{
401+
var result = new HashSet<int>();
402+
using var command = m_Database.CreateCommand();
403+
command.CommandText = sql;
404+
using var reader = command.ExecuteReader();
405+
while (reader.Read())
406+
result.Add(reader.GetInt32(0));
407+
return result;
408+
}
409+
331410
// Callback from PPtrAndCrcProcessor for each reference discovered in the SerializedFile
332411
private int AddReference(long objectId, int fileId, long pathId, string propertyPath, string propertyType)
333412
{
@@ -395,6 +474,7 @@ public void Dispose()
395474
m_AddObjectCommand.Dispose();
396475
m_AddTypeCommand.Dispose();
397476
m_InsertDepCommand.Dispose();
477+
m_AddDanglingRefCommand.Dispose();
398478

399479
m_LastId.Dispose();
400480
}

Analyzer/Util/IdProvider.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ public class IdProvider<Key>
1111
{
1212
private Dictionary<Key, int> m_Ids = new();
1313

14+
// Exposes the key->id assignments so callers can iterate or invert the mapping (used at
15+
// finalize to map a dangling object id back to its (fileId, pathId) or file name).
16+
public IReadOnlyDictionary<Key, int> Entries => m_Ids;
17+
1418
public int GetId(Key key)
1519
{
1620
if (m_Ids.TryGetValue(key, out var id))

Documentation/analyzer.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ What the `object` is depends on the build:
9898
`sharedassetsN.assets`) plus one in `globalgamemanagers.assets` for the always-loaded set.
9999

100100
The `dependency` side can reference an object that analyze never recorded, leaving a "dangling" id
101-
with no matching `objects` row. The most common case is objects in `unity default resources`, the
101+
with no matching `objects` row (such ids are catalogued in the `dangling_refs` table). The most
102+
common case is objects in `unity default resources`, the
102103
built-in resource file that ships with the Unity Editor without TypeTrees and so cannot be analyzed
103104
without a specially built copy. (`Resources/unity_builtin_extra` is built alongside your content and
104105
can be analyzed, but produces the same dangling references when it is not part of the analyzed set.)
@@ -251,6 +252,36 @@ SELECT * FROM refs_view WHERE property_type = 'MonoScript';
251252

252253
These tables are not populated when analyze is run with `--skip-references`.
253254

255+
## dangling_refs / dangling_refs_view
256+
257+
When a reference points to an object whose serialized file was not part of the analyzed input,
258+
analyze still assigns that target an object id but never writes an `objects` row for it. `dangling_refs`
259+
records those targets so the reference information is preserved and every object id is accounted for
260+
(each id resolves to exactly one of `objects` or `dangling_refs`, never both). Common causes:
261+
262+
* Analyzing a single AssetBundle or a partial subset of a bundle group, so cross-bundle references
263+
point at bundles that were not analyzed.
264+
* A Player build referencing `unity default resources` (and `Resources/unity_builtin_extra` when it
265+
is not part of the analyzed set) - built-in files that ship without TypeTrees and are not analyzed.
266+
* A ContentDirectory build analyzed without its `ContentLayout.json`, so references cannot be resolved.
267+
268+
The columns mirror the `objects` table: `id` (the assigned object id, absent from `objects`),
269+
`object_id` (the target's local file id / LFID) and `serialized_file`. The target file is recorded in
270+
`serialized_files` (with `archive` NULL and no objects of its own) so its name is available; the
271+
lowercased file name is all that is known about it - there is no type, size, or other detail.
272+
273+
`dangling_refs_view` joins each dangling target back to the object(s) that reference it, one row per
274+
reference, with columns `source_id`, `source_serialized_file`, `source_object_id`, `property_path`,
275+
`property_type`, `target_id`, `target_serialized_file`, `target_object_id`.
276+
277+
```sql
278+
-- what does object 42 fail to resolve, and where should those objects have come from?
279+
SELECT * FROM dangling_refs_view WHERE source_id = 42;
280+
```
281+
282+
Because the view joins `refs`, it is not populated when analyze is run with `--skip-references`
283+
(in that mode neither `refs` nor `dangling_refs` are populated).
284+
254285
## BuildReport
255286

256287
See [BuildReport.md](buildreport.md) for details of the tables and views related to analyzing BuildReport files.

0 commit comments

Comments
 (0)