From 90e2abb672e710b5e917f407b03685b8b2b44e14 Mon Sep 17 00:00:00 2001 From: Andrew Skowronski Date: Tue, 22 Sep 2026 10:53:17 -0400 Subject: [PATCH] AssetBundle variant - doc and clear error handler Add documentation for this special feature. Analyze does not allow duplicate serialized files so show a clear error message to explain why only a single AssetBundle from a group from a group of variants is analyzed. --- Analyzer/AnalyzeDuplicateException.cs | 15 ++++- Analyzer/AnalyzerTool.cs | 6 +- .../SQLite/Parsers/SerializedFileParser.cs | 16 +++-- .../Writers/SerializedFileSQLiteWriter.cs | 33 +++++++-- Documentation/assetbundle-format.md | 67 +++++++++++++++++++ Documentation/command-analyze.md | 6 +- .../AnalyzeDuplicateNameTests.cs | 26 +++++++ 7 files changed, 154 insertions(+), 15 deletions(-) diff --git a/Analyzer/AnalyzeDuplicateException.cs b/Analyzer/AnalyzeDuplicateException.cs index 9faf19a..0b3f353 100644 --- a/Analyzer/AnalyzeDuplicateException.cs +++ b/Analyzer/AnalyzeDuplicateException.cs @@ -12,11 +12,24 @@ public class AnalyzeDuplicateException : Exception public bool IsArchive { get; } public AnalyzeDuplicateException(string duplicateName, bool isArchive) - : base(isArchive + : this(duplicateName, isArchive, isArchive ? $"Duplicate archive name '{duplicateName}'. Each analyzed archive must have a unique name; only a single build can be analyzed at a time." : $"Duplicate SerializedFile name '{duplicateName}'. Only a single build can be analyzed at a time; the same SerializedFile name cannot be analyzed twice.") + { + } + + private AnalyzeDuplicateException(string duplicateName, bool isArchive, string message) + : base(message) { DuplicateName = duplicateName; IsArchive = isArchive; } + + // AssetBundle variants ("ui.hd", "ui.sd") contain a SerializedFile with the same name by design, + // so hitting one is a known limitation rather than a sign of mixing builds. + public static AnalyzeDuplicateException AssetBundleVariant(string serializedFileName, string analyzedArchive) + { + return new AnalyzeDuplicateException(serializedFileName, isArchive: false, + $"AssetBundle variant of '{analyzedArchive}', which was already analyzed (both contain SerializedFile '{serializedFileName}'). Only one variant of each bundle can be analyzed."); + } } diff --git a/Analyzer/AnalyzerTool.cs b/Analyzer/AnalyzerTool.cs index 099c080..fb46155 100644 --- a/Analyzer/AnalyzerTool.cs +++ b/Analyzer/AnalyzerTool.cs @@ -131,9 +131,9 @@ public int Analyze(AnalyzeOptions options) } catch (AnalyzeDuplicateException e) { - // A file or archive with this name was already analyzed. Only a single build - // can be analyzed at a time; print a clear one-line message (always visible, - // not just with -v) and continue, counting this file as failed. + // This file, archive, or a SerializedFile inside the archive was already + // analyzed. Print a clear one-line message (always visible, not just with -v) + // and continue, counting this file as failed. EraseProgressLine(); Console.Error.WriteLine($"Skipping {relativePath}: {e.Message}"); countFailures++; diff --git a/Analyzer/SQLite/Parsers/SerializedFileParser.cs b/Analyzer/SQLite/Parsers/SerializedFileParser.cs index 366f347..f761fbe 100644 --- a/Analyzer/SQLite/Parsers/SerializedFileParser.cs +++ b/Analyzer/SQLite/Parsers/SerializedFileParser.cs @@ -97,6 +97,7 @@ void ProcessFile(string file, string rootDirectory) { bool archiveHadErrors = false; bool archiveHadMissingTypeTrees = false; + AnalyzeDuplicateException archiveDuplicate = null; using (UnityArchive archive = UnityFileSystem.MountArchive(file, "archive:" + Path.DirectorySeparatorChar)) { if (archive == null) @@ -125,10 +126,9 @@ void ProcessFile(string file, string rootDirectory) catch (AnalyzeDuplicateException e) { // A SerializedFile with this name was already analyzed (e.g. two - // differently-named bundles containing the same CAB). Report the - // self-contained message rather than a raw SQLite constraint error. - Console.Error.WriteLine($"Skipping {node.Path} in archive {archiveName}: {e.Message}"); - archiveHadErrors = true; + // differently-named bundles containing the same CAB, or AssetBundle + // variants). Reported once for the whole archive, below. + archiveDuplicate ??= e; } catch (Exception e) { @@ -151,12 +151,18 @@ void ProcessFile(string file, string rootDirectory) } } - // Genuine errors take precedence over missing TypeTrees when reporting the archive's outcome. + // Genuine errors take precedence over duplicates and missing TypeTrees when reporting + // the archive's outcome. if (archiveHadErrors) { throw new Exception("One or more files in the archive failed to process"); } + if (archiveDuplicate != null) + { + throw archiveDuplicate; + } + if (archiveHadMissingTypeTrees) { throw new SerializedFileOpenException(file, missingTypeTrees: true); diff --git a/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs b/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs index af63e2f..254f46c 100644 --- a/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs +++ b/Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs @@ -23,9 +23,12 @@ public class SerializedFileSQLiteWriter : IDisposable // second copy of the same content with a clear error instead of a raw UNIQUE constraint // failure. Only a single build can be analyzed at a time (see AnalyzeDuplicateException). // Archive names are compared case-sensitively, matching the archives.name schema constraint - // and the name as it exists on the file system. + // and the name as it exists on the file system. Each serialized file id maps to the archive + // it was found in (null for a loose file) so a duplicate can be recognized as an AssetBundle + // variant of that archive. private HashSet m_WrittenArchiveNames = new(); - private HashSet m_WrittenSerializedFileIds = new(); + private Dictionary m_WrittenSerializedFiles = new(); + private string m_CurrentArchiveName; private bool m_SkipReferences; private bool m_SkipCrc; @@ -156,6 +159,7 @@ public void BeginArchive(string name, long size) throw new AnalyzeDuplicateException(name, isArchive: true); } + m_CurrentArchiveName = name; m_AddArchiveCommand.SetValue("id", m_CurrentArchiveId); m_AddArchiveCommand.SetValue("name", name); m_AddArchiveCommand.SetValue("file_size", size); @@ -170,6 +174,21 @@ public void EndArchive() } m_CurrentArchiveId = -1; + m_CurrentArchiveName = null; + } + + // 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. + private static bool LooksLikeAssetBundleVariantPair(string archiveA, string archiveB) + { + if (archiveA == null || archiveB == null || archiveA == archiveB) + return false; + + if (Path.GetExtension(archiveA) == "" || Path.GetExtension(archiveB) == "") + return false; + + return Path.ChangeExtension(archiveA, null) == Path.ChangeExtension(archiveB, null); } public void WriteSerializedFile(string relativePath, string fullPath, string containingFolder) @@ -199,9 +218,13 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con // Two SerializedFiles with the same name map to the same id (the provider deduplicates by // name), so a second one would collide on serialized_files.id. Reject it before opening a // transaction; the file name is what matters to the user, not the analyzer id. - if (m_WrittenSerializedFileIds.Contains(serializedFileId)) + if (m_WrittenSerializedFiles.TryGetValue(serializedFileId, out var analyzedArchive)) { - throw new AnalyzeDuplicateException(Path.GetFileName(fullPath), isArchive: false); + var fileName = Path.GetFileName(fullPath); + if (LooksLikeAssetBundleVariantPair(m_CurrentArchiveName, analyzedArchive)) + throw AnalyzeDuplicateException.AssetBundleVariant(fileName, analyzedArchive); + + throw new AnalyzeDuplicateException(fileName, isArchive: false); } using var transaction = m_Database.BeginTransaction(); @@ -387,7 +410,7 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con } transaction.Commit(); - m_WrittenSerializedFileIds.Add(serializedFileId); + m_WrittenSerializedFiles[serializedFileId] = m_CurrentArchiveName; } catch (Exception) { diff --git a/Documentation/assetbundle-format.md b/Documentation/assetbundle-format.md index 76b4bcd..5ccf076 100644 --- a/Documentation/assetbundle-format.md +++ b/Documentation/assetbundle-format.md @@ -35,6 +35,7 @@ understand them for normal use, but they show up throughout UnityDataTool output - **Regular (non-scene) bundles** contain one SerializedFile named `CAB-`, where the hash is the **MD4** hash of the AssetBundle name (not the `Hash128` / spooky hash exposed in the C# API). + For [AssetBundle variants](#assetbundle-variants) the name hashed excludes the variant suffix. - **Scene bundles** name their scene files differently depending on the build pipeline: - `BuildPipeline.BuildAssetBundles` uses `BuildPlayer-`. - The Scriptable Build Pipeline / Addressables uses `CAB-`. @@ -91,6 +92,72 @@ In UnityDataTool output, these layers appear in different places: Keeping those layers separate helps explain why a query over `refs` may show cross-bundle relationships without directly mentioning an AssetBundle filename on each reference row. +## AssetBundle variants + +`BuildPipeline.BuildAssetBundles` supports **AssetBundle variants**: two or more bundles that hold +interchangeable versions of the same content, for example high and low resolution textures, or the +text for different languages. The application decides at runtime which variant to load, and any +other bundle that references the content resolves to whichever variant is loaded. + +Variants are a feature of `BuildPipeline.BuildAssetBundles` only. The Scriptable Build Pipeline and +Addressables do not support them. Variants are a low-level mechanism that makes it harder to reason +about what a build contains, so they are generally discouraged for new projects, but some shipped +titles rely on them and their bundles show up in UnityDataTool output. + +### How variants are built + +A variant is declared by setting `assetBundleVariant` alongside `assetBundleName`, either in the +`AssetBundleBuild` array passed to `BuildPipeline.BuildAssetBundles` or in the Inspector for an asset +or folder. The variant name is lowercased and appended to the bundle name like a file extension, so +bundle `textures` with variants `hd` and `sd` produces the files `textures.hd` and `textures.sd` +(plus a `.manifest` file for each). Because the variant occupies the extension position there is no +room for a fixed file extension, which is one reason `BuildPipeline.BuildAssetBundles` output has +no standard extension. + +The variants are separate bundles in the build output, but the build makes their internals match: + +- **Same SerializedFile name.** The `CAB-` name is the MD4 hash of the base bundle name + (`textures`), not the full name with the variant. `textures.hd` and `textures.sd` therefore both + contain a SerializedFile named `CAB-`. +- **Same local object ids.** In a normal bundle an object's local file id is derived from its asset + GUID. In a variant bundle it is instead derived from the asset's name (its file name, or its path + relative to the folder marked with the variant). Two assets with matching names in the `hd` and + `sd` folders therefore get identical local file ids, even though they are different assets with + different GUIDs. Dependencies that are pulled into a variant bundle implicitly, rather than being + marked with the variant, keep the GUID-based id. +- **Same dependency name.** Other bundles record the dependency in `m_Dependencies` by the base name + (`textures`), and the `m_AssetBundleName` field of every variant's AssetBundle object is also the + base name. Only the AssetBundle object's `m_Name` carries the full name (`textures.hd`). + +This is what makes the substitution work. A `PPtr` in another bundle identifies its target by +SerializedFile path and local file id (see +[Bundle dependencies and object references](#bundle-dependencies-and-object-references)). Both +values are identical across the variants, so the reference resolves into whichever variant the +application has loaded. The Unity runtime has no variant-specific logic: it simply finds the mounted +SerializedFile with the matching name. + +For this to work, each variant should contain the same set of asset names. The build also rejects a +bundle that uses the plain base name in the same build as a variant of that name (`textures` next to +`textures.hd`). The `AssetBundleManifest` lists the full names, and its +`GetAllAssetBundlesWithVariant()` method returns those that were built as variants. + +### Variants and UnityDataTool + +Apart from the shared internal names, variant bundles are regular AssetBundles, and +[`archive`](command-archive.md), [`dump`](command-dump.md) and +[`serialized-file`](command-serialized-file.md) work on them as on any other bundle. + +[`analyze`](command-analyze.md) is the exception. Its schema requires every SerializedFile name to +be unique within a database, and every variant of a bundle contains a SerializedFile with the same +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)). +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 +[Comparing Builds](comparing-builds.md). + ## Built-in resources Bundle content can reference objects in Unity's two built-in resource files (described in diff --git a/Documentation/command-analyze.md b/Documentation/command-analyze.md index 4a26dc9..7343434 100644 --- a/Documentation/command-analyze.md +++ b/Documentation/command-analyze.md @@ -188,6 +188,10 @@ 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 @@ -203,7 +207,7 @@ This is expected when the input contains more than one build, and in these commo | Cause | What to do | |-------|------------| | Multiple builds passed together (or nested in one directory) | Analyze each build into its own database | -| AssetBundle variants (same content, different variant) | Analyze each variant separately | +| [AssetBundle variants](assetbundle-format.md#assetbundle-variants) (same content, different variant) | Expected within a single build; analyze one variant of each bundle, or each variant into its own database | | Hashed AssetBundle file names across two builds | The file names differ but the inner SerializedFile (`CAB-`) is shared — analyze each build separately | | Player scenes with the same file name (`level0`, …) from different builds | Analyze each build separately | diff --git a/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs b/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs index 665bfdb..45c30ae 100644 --- a/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs +++ b/UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs @@ -103,6 +103,32 @@ public async Task Analyze_LooseFilesWithSameName_SkippedWithClearMessage() 1, "only one SerializedFile named 'level0' should be recorded"); } + // AssetBundle variant shape: archives that differ only in their extension and share the same + // inner SerializedFile. The second is skipped with the variant-specific message, printed once + // for the archive rather than once per inner file plus a generic failure line. + [Test] + public async Task Analyze_AssetBundleVariants_SkippedWithVariantMessage() + { + var source = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1", "assetbundle"); + var variantHd = Path.Combine(m_TestOutputFolder, "ui.hd"); + var variantSd = Path.Combine(m_TestOutputFolder, "ui.sd"); + File.Copy(source, variantHd); + File.Copy(source, variantSd); + var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder); + + var (exitCode, stderr) = await RunAnalyze(variantHd, variantSd, "-o", databasePath); + + Assert.AreEqual(0, exitCode, "analyze should continue and exit 0 after skipping the variant"); + StringAssert.Contains("Skipping ui.sd: AssetBundle variant of 'ui.hd'", stderr); + StringAssert.DoesNotContain("Duplicate SerializedFile name", stderr); + StringAssert.DoesNotContain("Failed to process", stderr); + + using var db = SQLTestHelper.OpenDatabase(databasePath); + SQLTestHelper.AssertQueryInt(db, + "SELECT COUNT(*) FROM archives WHERE name IN ('ui.hd', 'ui.sd')", + 2, "both variant archives should be recorded"); + } + // Hashed-name shape: the same archive under two different file names (as with hashed bundle // names). The archive names differ, so both are recorded, but they share the same inner // SerializedFile ("CAB-"), which is rejected the second time.