From 0dd4b32f064e8dc99246041a7d9b73f07a97e85c Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Wed, 23 Sep 2026 11:54:38 -0400 Subject: [PATCH 1/3] [149] Name archives by their path relative to the scanned directory analyze recorded archives.name as the bare file name, so bundles sharing a file name in different folders were rejected as duplicates. AssetBundle names are often paths, and BuildPipeline.BuildAssetBundles writes them into a matching folder structure, so this skipped nearly every bundle in such a build. AnalyzerTool already tracked the scanned root per file; it is now passed to ISQLiteFileParser.Parse and used for the archive name, normalized to forward slashes. SerializedFile names are unchanged (bare file names), since external references and serialized_files.id are keyed on them. --- Analyzer/AnalyzerTool.cs | 2 +- Analyzer/Resources/Init.sql | 2 +- Analyzer/SQLite/Handlers/ISQLiteHandler.cs | 5 +- .../Parsers/AddressablesBuildLayoutParser.cs | 2 +- .../SQLite/Parsers/ContentLayoutParser.cs | 2 +- .../SQLite/Parsers/SerializedFileParser.cs | 17 ++++-- Documentation/analyzer-schema.md | 9 ++- Documentation/command-analyze.md | 13 +++++ .../AnalyzeDuplicateNameTests.cs | 55 +++++++++++++++++++ 9 files changed, 96 insertions(+), 11 deletions(-) diff --git a/Analyzer/AnalyzerTool.cs b/Analyzer/AnalyzerTool.cs index fb46155..e416629 100644 --- a/Analyzer/AnalyzerTool.cs +++ b/Analyzer/AnalyzerTool.cs @@ -107,7 +107,7 @@ public int Analyze(AnalyzeOptions options) foundParser = true; try { - parser.Parse(file); + parser.Parse(file, displayRoot); ReportProgress(relativePath, i, files.Count); countSuccess++; } diff --git a/Analyzer/Resources/Init.sql b/Analyzer/Resources/Init.sql index ef1ea5c..fa45529 100644 --- a/Analyzer/Resources/Init.sql +++ b/Analyzer/Resources/Init.sql @@ -168,7 +168,7 @@ WHERE m.type = 'Material'; INSERT INTO types (id, name) VALUES (-1, 'Scene'); -PRAGMA user_version = 7; +PRAGMA user_version = 8; PRAGMA synchronous = OFF; PRAGMA journal_mode = MEMORY; diff --git a/Analyzer/SQLite/Handlers/ISQLiteHandler.cs b/Analyzer/SQLite/Handlers/ISQLiteHandler.cs index d266272..a7a1836 100644 --- a/Analyzer/SQLite/Handlers/ISQLiteHandler.cs +++ b/Analyzer/SQLite/Handlers/ISQLiteHandler.cs @@ -26,7 +26,10 @@ public interface ISQLiteFileParser : IDisposable { void Init(SqliteConnection db); bool CanParse(string filename); - void Parse(string filename); + + // rootDirectory is the scanned input path the file was found under; names recorded in the + // database are relative to it, so same-named files in different sub-folders stay distinct. + void Parse(string filename, string rootDirectory); // Called once after all files have been parsed, so a parser can write data that can only be // determined from the complete set (e.g. dangling references). No-op for parsers that don't diff --git a/Analyzer/SQLite/Parsers/AddressablesBuildLayoutParser.cs b/Analyzer/SQLite/Parsers/AddressablesBuildLayoutParser.cs index 1b3fae7..417a164 100644 --- a/Analyzer/SQLite/Parsers/AddressablesBuildLayoutParser.cs +++ b/Analyzer/SQLite/Parsers/AddressablesBuildLayoutParser.cs @@ -67,7 +67,7 @@ public bool CanParse(string filename) return false; } - public void Parse(string filename) + public void Parse(string filename, string rootDirectory) { // only init our writer if we are actually parsing a file m_Writer.Init(); diff --git a/Analyzer/SQLite/Parsers/ContentLayoutParser.cs b/Analyzer/SQLite/Parsers/ContentLayoutParser.cs index 2add87f..e0b30f5 100644 --- a/Analyzer/SQLite/Parsers/ContentLayoutParser.cs +++ b/Analyzer/SQLite/Parsers/ContentLayoutParser.cs @@ -47,7 +47,7 @@ public bool CanParse(string filename) return IsContentLayoutFile(filename); } - public void Parse(string filename) + public void Parse(string filename, string rootDirectory) { ContentLayout layout; using (var reader = File.OpenText(filename)) diff --git a/Analyzer/SQLite/Parsers/SerializedFileParser.cs b/Analyzer/SQLite/Parsers/SerializedFileParser.cs index f761fbe..404000d 100644 --- a/Analyzer/SQLite/Parsers/SerializedFileParser.cs +++ b/Analyzer/SQLite/Parsers/SerializedFileParser.cs @@ -55,11 +55,11 @@ public void Init(SqliteConnection db) m_SerializedFileIdProvider, m_ContentFileDependencies); } - public void Parse(string filename) + public void Parse(string filename, string rootDirectory) { // only init our writer if we are actually parsing a file m_Writer.Init(); - ProcessFile(filename, Path.GetDirectoryName(filename)); + ProcessFile(filename, rootDirectory); } bool ShouldIgnoreFile(string file) @@ -105,7 +105,12 @@ void ProcessFile(string file, string rootDirectory) try { - var archiveName = Path.GetRelativePath(rootDirectory, file); + // Naming an archive by its path relative to the scanned root keeps + // bundles that share a file name in different folders distinct (issue + // #149). Forward slashes make the value platform independent and match + // the name the AssetBundleManifest uses. + var archiveName = Path.GetRelativePath(rootDirectory, file) + .Replace(Path.DirectorySeparatorChar, '/'); m_Writer.BeginArchive(archiveName, new FileInfo(file).Length); @@ -173,8 +178,10 @@ void ProcessFile(string file, string rootDirectory) // This isn't a Unity Archive file, so process it as a SerializedFile. // Note: The file has already been validated in CanParse() via SerializedFileDetector, // so we're confident it's a valid SerializedFile at this point. - var relativePath = Path.GetRelativePath(rootDirectory, file); - m_Writer.WriteSerializedFile(relativePath, file, Path.GetDirectoryName(file)); + // + // Unlike an archive, a SerializedFile is recorded under its bare file name: that is + // the name external references and serialized_files.id are keyed on (issue #149). + m_Writer.WriteSerializedFile(Path.GetFileName(file), file, Path.GetDirectoryName(file)); } } } diff --git a/Documentation/analyzer-schema.md b/Documentation/analyzer-schema.md index b05f37c..d4a0564 100644 --- a/Documentation/analyzer-schema.md +++ b/Documentation/analyzer-schema.md @@ -147,9 +147,15 @@ is not inside an archive has no row here. | Column | Type | Description | |---|---|---| | `id` | INTEGER | Analyzer-assigned id. Primary key. | -| `name` | TEXT | The archive's name on the file system. UNIQUE. | +| `name` | TEXT | The archive's path relative to the analyzed directory, with `/` separators. UNIQUE. | | `file_size` | INTEGER | Size of the archive file in bytes. | +`name` is the path of the archive file relative to the directory that was analyzed, for example +`dlc/weapons/main`. `BuildPipeline.BuildAssetBundles` writes a bundle whose name is a path into a +matching folder structure, so the relative path is both unique and the name the bundle has in the +`AssetBundleManifest`. An archive passed directly on the command line, rather than found by scanning +a directory, is recorded under its bare file name. + `name` is UNIQUE and case-sensitive because analyze supports a single build at a time: two archives with the same name would make every query ambiguous. A duplicate is detected while writing and reported as an error; the constraint is the durable backstop. See @@ -545,6 +551,7 @@ Any schema change - a new or changed table, view or column - must bump the pragm | 5 | Added the `dangling_refs` table and view ([#85](https://github.com/Unity-Technologies/UnityDataTools/issues/85)) | | 6 | `archives.name` is UNIQUE ([#51](https://github.com/Unity-Technologies/UnityDataTools/issues/51)) | | 7 | Unity 6.6 `build_reports` columns and `build_report_content_*` tables ([#107](https://github.com/Unity-Technologies/UnityDataTools/issues/107)); `asset_name` / `asset_extension` columns on `build_report_source_assets` ([#110](https://github.com/Unity-Technologies/UnityDataTools/issues/110)) | +| 8 | `archives.name` is the path relative to the scanned directory, not the bare file name ([#149](https://github.com/Unity-Technologies/UnityDataTools/issues/149)) | ## Related documentation diff --git a/Documentation/command-analyze.md b/Documentation/command-analyze.md index 7343434..d4151e9 100644 --- a/Documentation/command-analyze.md +++ b/Documentation/command-analyze.md @@ -64,6 +64,15 @@ UnityDataTool analyze /path/to/bundles --skip-references --skip-crc See also [Analyze Examples](analyze-examples.md). +## How archives are named + +The `archives.name` column, and the `archive` column of the views built on it, holds the path of +the archive relative to the directory that was analyzed, using `/` separators. An AssetBundle name +may itself be a path, and `BuildPipeline.BuildAssetBundles` writes such a bundle into a matching +folder structure, so a build containing `dlc/weapons/main` and `dlc/armor/main` records those two +names rather than two bundles called `main`. An archive named directly on the command line, rather +than found by scanning a directory, is recorded under its bare file name. + --- ## What Can Be Analyzed @@ -202,6 +211,10 @@ When analyze encounters a second file or archive with a name it has already proc of the messages above, **skips that file or archive** (counting it as a failed file), and continues with the rest of the input. The already-analyzed copy is kept; the duplicate's content is ignored. +Two bundles that share a file name in different sub-folders of the analyzed directory are not +duplicates: archives are named by their relative path, so `dlc/weapons/main` and `dlc/armor/main` +are both analyzed. See [How archives are named](#how-archives-are-named). + This is expected when the input contains more than one build, and in these common cases: | Cause | What to do | diff --git a/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs b/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs index 45c30ae..f2bdacf 100644 --- a/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs +++ b/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs @@ -13,6 +13,9 @@ namespace UnityDataTools.UnityDataTool.Tests; // single-line message instead of a raw "UNIQUE constraint failed" SQLite error. Covers the three // scenarios from the issue: loose files, archives with the same name, and differently-named // archives (hashed bundle names) that share the same inner SerializedFile. +// +// Also covers the opposite case (issue #149): bundles sharing a file name in different sub-folders +// of one scanned directory are distinct archives, not duplicates. public class AnalyzeDuplicateNameTests { private string m_TestOutputFolder; @@ -159,4 +162,56 @@ public async Task Analyze_DifferentArchiveNamesSharingSerializedFile_SkippedWith WHERE name LIKE 'CAB-%' AND id IN (SELECT serialized_file FROM objects)", 1, "the shared inner SerializedFile should be analyzed only once"); } + + // Issue #149: an AssetBundle name can be a path, so a build commonly contains several bundles + // with the same file name in different folders. They are recorded under their path relative to + // the scanned directory and are not treated as duplicates. + [Test] + public async Task Analyze_SameFileNameInDifferentFolders_NamedByRelativePath() + { + var sourceFolder = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1"); + var folderA = Path.Combine(m_TestOutputFolder, "dlc", "weapons"); + var folderB = Path.Combine(m_TestOutputFolder, "dlc", "armor"); + Directory.CreateDirectory(folderA); + Directory.CreateDirectory(folderB); + File.Copy(Path.Combine(sourceFolder, "assetbundle"), Path.Combine(folderA, "main")); + File.Copy(Path.Combine(sourceFolder, "scenes"), Path.Combine(folderB, "main")); + var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder); + + var (exitCode, stderr) = await RunAnalyze(m_TestOutputFolder, "-o", databasePath); + + Assert.AreEqual(0, exitCode); + StringAssert.DoesNotContain("Duplicate archive name", stderr); + StringAssert.DoesNotContain("UNIQUE constraint", stderr); + + using var db = SQLTestHelper.OpenDatabase(databasePath); + SQLTestHelper.AssertQueryInt(db, + "SELECT COUNT(*) FROM archives WHERE name IN ('dlc/weapons/main', 'dlc/armor/main')", + 2, "both bundles should be recorded under their relative path"); + Assert.Greater(SQLTestHelper.QueryInt(db, + "SELECT COUNT(*) FROM object_view WHERE archive = 'dlc/weapons/main'"), 0); + Assert.Greater(SQLTestHelper.QueryInt(db, + "SELECT COUNT(*) FROM object_view WHERE archive = 'dlc/armor/main'"), 0); + } + + // A file named directly on the command line has no scanned directory to be relative to, so it + // keeps its bare file name. + [Test] + public async Task Analyze_FileNamedDirectly_KeepsBareFileName() + { + var source = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1", "assetbundle"); + var nested = Path.Combine(m_TestOutputFolder, "dlc", "weapons"); + Directory.CreateDirectory(nested); + var bundle = Path.Combine(nested, "main"); + File.Copy(source, bundle); + var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder); + + var (exitCode, _) = await RunAnalyze(bundle, "-o", databasePath); + + Assert.AreEqual(0, exitCode); + + using var db = SQLTestHelper.OpenDatabase(databasePath); + SQLTestHelper.AssertQueryString(db, "SELECT name FROM archives", "main", + "a directly-named archive keeps its bare file name"); + } } From 82743f9fcf4ea0dfce8af5c3733c993a89e594a3 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Wed, 23 Sep 2026 12:36:57 -0400 Subject: [PATCH 2/3] [149] Address review: trim analyze docs, reword SerializedFile naming comment --- .../SQLite/Parsers/SerializedFileParser.cs | 4 +- Documentation/assetbundle-format.md | 2 +- Documentation/command-analyze.md | 37 ++++++++----------- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/Analyzer/SQLite/Parsers/SerializedFileParser.cs b/Analyzer/SQLite/Parsers/SerializedFileParser.cs index 404000d..82e5b48 100644 --- a/Analyzer/SQLite/Parsers/SerializedFileParser.cs +++ b/Analyzer/SQLite/Parsers/SerializedFileParser.cs @@ -179,8 +179,8 @@ void ProcessFile(string file, string rootDirectory) // Note: The file has already been validated in CanParse() via SerializedFileDetector, // so we're confident it's a valid SerializedFile at this point. // - // Unlike an archive, a SerializedFile is recorded under its bare file name: that is - // the name external references and serialized_files.id are keyed on (issue #149). + // SerializedFiles are recorded with the bare file name, matching how Unity + // references work and requiring uniqueness. (issue #36). m_Writer.WriteSerializedFile(Path.GetFileName(file), file, Path.GetDirectoryName(file)); } } diff --git a/Documentation/assetbundle-format.md b/Documentation/assetbundle-format.md index 5ccf076..df4d975 100644 --- a/Documentation/assetbundle-format.md +++ b/Documentation/assetbundle-format.md @@ -152,7 +152,7 @@ be unique within a database, and every variant of a bundle contains a Serialized name. If the input includes more than one variant of the same bundle, analyze processes the first one it meets and skips the rest, reporting each skipped file as an AssetBundle variant of the one that was analyzed (see -[Duplicate SerializedFile name](command-analyze.md#duplicate-serializedfile-name--duplicate-archive-name)). +[Duplicate SerializedFile name](command-analyze.md#duplicate-serializedfile-name)). The resulting database is still valid; it simply describes one variant. To choose which, pass only that variant of each bundle, for example only the `.hd` files together with the non-variant bundles. To compare variants, analyze each into its own database as described in diff --git a/Documentation/command-analyze.md b/Documentation/command-analyze.md index d4151e9..b9f0cc9 100644 --- a/Documentation/command-analyze.md +++ b/Documentation/command-analyze.md @@ -64,15 +64,6 @@ UnityDataTool analyze /path/to/bundles --skip-references --skip-crc See also [Analyze Examples](analyze-examples.md). -## How archives are named - -The `archives.name` column, and the `archive` column of the views built on it, holds the path of -the archive relative to the directory that was analyzed, using `/` separators. An AssetBundle name -may itself be a path, and `BuildPipeline.BuildAssetBundles` writes such a bundle into a matching -folder structure, so a build containing `dlc/weapons/main` and `dlc/armor/main` records those two -names rather than two bundles called `main`. An archive named directly on the command line, rather -than found by scanning a directory, is recorded under its bare file name. - --- ## What Can Be Analyzed @@ -188,32 +179,34 @@ This error occurs when SerializedFiles are built without TypeTrees. The command UnityDataTool analyze /path/to/bundles --typetree-data /path/to/typetree.bin ``` -### Duplicate SerializedFile name / Duplicate archive name +### Duplicate SerializedFile name ``` Skipping build2\level0: Duplicate SerializedFile name 'level0'. Only a single build can be analyzed at a time; the same SerializedFile name cannot be analyzed twice. ``` or ``` -Skipping build2\assetbundle: Duplicate archive name 'assetbundle'. Each analyzed archive must have a unique name; only a single build can be analyzed at a time. -``` -or -``` Skipping ui.sd: AssetBundle variant of 'ui.hd', which was already analyzed (both contain SerializedFile 'CAB-5d40f7cad7c871cf2ad2af19ac542994'). Only one variant of each bundle can be analyzed. ``` **analyze only supports a single build at a time.** Unity resolves references between SerializedFiles by file name, so two files that share a name are indistinguishable to those references — there is no -way to tell which copy a reference points at. For that reason each SerializedFile name (and each -archive name) may appear only once in a database. +way to tell which copy a reference points at. For that reason each SerializedFile name may appear +only once in a database. + +Archive names do not have to be unique across folders: an archive is recorded under its path +relative to the analyzed directory, so two bundles that share a file name in different sub-folders +(`dlc/weapons/main` and `dlc/armor/main`) are both analyzed. Two builds analyzed together still +collide, both on their SerializedFiles and, when each build is passed as its own input path, on +their archive names: -When analyze encounters a second file or archive with a name it has already processed, it prints one -of the messages above, **skips that file or archive** (counting it as a failed file), and continues -with the rest of the input. The already-analyzed copy is kept; the duplicate's content is ignored. +``` +Skipping build2\assetbundle: Duplicate archive name 'assetbundle'. Each analyzed archive must have a unique name; only a single build can be analyzed at a time. +``` -Two bundles that share a file name in different sub-folders of the analyzed directory are not -duplicates: archives are named by their relative path, so `dlc/weapons/main` and `dlc/armor/main` -are both analyzed. See [How archives are named](#how-archives-are-named). +When analyze encounters a second file or archive with a name it has already processed, it **skips +that file or archive** (counting it as a failed file) and continues with the rest of the input. +The already-analyzed copy is kept; the duplicate's content is ignored. This is expected when the input contains more than one build, and in these common cases: From 5d22bad1a570404131b5ae358dc10388da1d2bd1 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Wed, 23 Sep 2026 13:37:04 -0400 Subject: [PATCH 3/3] [149] Cover dotted folder names in the AssetBundle variant check --- .../Writers/SerializedFileSQLiteWriter.cs | 3 +++ .../AnalyzeDuplicateNameTests.cs | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs b/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs index 254f46c..602ff85 100644 --- a/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs +++ b/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs @@ -180,6 +180,9 @@ public void EndArchive() // AssetBundle variants are named ".", and every variant of a bundle contains // a SerializedFile with the same name. Two archives that differ only in their extension and // share a SerializedFile are therefore taken to be variants of the same bundle. + // + // The names are paths, and the Path methods below only consider the last segment, so a dot in + // a folder name ("v1.2/main") is not mistaken for a variant suffix. private static bool LooksLikeAssetBundleVariantPair(string archiveA, string archiveB) { if (archiveA == null || archiveB == null || archiveA == archiveB) diff --git a/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs b/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs index f2bdacf..9ced229 100644 --- a/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs +++ b/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs @@ -214,4 +214,30 @@ public async Task Analyze_FileNamedDirectly_KeepsBareFileName() SQLTestHelper.AssertQueryString(db, "SELECT name FROM archives", "main", "a directly-named archive keeps its bare file name"); } + + // The variant check compares the extensions of two archive names, which are now paths. A dot + // in a folder name is not an extension, so two bundles under "v1.2" and "v1.3" must be + // reported as a plain duplicate rather than as variants of each other. + [Test] + public async Task Analyze_DottedFolderNames_NotReportedAsVariants() + { + var source = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1", "assetbundle"); + foreach (var folder in new[] { "v1.2", "v1.3" }) + { + Directory.CreateDirectory(Path.Combine(m_TestOutputFolder, folder)); + File.Copy(source, Path.Combine(m_TestOutputFolder, folder, "main")); + } + var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder); + + var (exitCode, stderr) = await RunAnalyze(m_TestOutputFolder, "-o", databasePath); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains("Duplicate SerializedFile name", stderr); + StringAssert.DoesNotContain("AssetBundle variant", stderr); + + using var db = SQLTestHelper.OpenDatabase(databasePath); + SQLTestHelper.AssertQueryInt(db, + "SELECT COUNT(*) FROM archives WHERE name IN ('v1.2/main', 'v1.3/main')", + 2, "both archives should be recorded under their dotted-folder relative path"); + } }