Skip to content

Commit 5fa2fe5

Browse files
AssetBundle variant - doc and clear error handler (#148)
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.
1 parent faaf198 commit 5fa2fe5

7 files changed

Lines changed: 154 additions & 15 deletions

File tree

‎Analyzer/AnalyzeDuplicateException.cs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,24 @@ public class AnalyzeDuplicateException : Exception
1212
public bool IsArchive { get; }
1313

1414
public AnalyzeDuplicateException(string duplicateName, bool isArchive)
15-
: base(isArchive
15+
: this(duplicateName, isArchive, isArchive
1616
? $"Duplicate archive name '{duplicateName}'. Each analyzed archive must have a unique name; only a single build can be analyzed at a time."
1717
: $"Duplicate SerializedFile name '{duplicateName}'. Only a single build can be analyzed at a time; the same SerializedFile name cannot be analyzed twice.")
18+
{
19+
}
20+
21+
private AnalyzeDuplicateException(string duplicateName, bool isArchive, string message)
22+
: base(message)
1823
{
1924
DuplicateName = duplicateName;
2025
IsArchive = isArchive;
2126
}
27+
28+
// AssetBundle variants ("ui.hd", "ui.sd") contain a SerializedFile with the same name by design,
29+
// so hitting one is a known limitation rather than a sign of mixing builds.
30+
public static AnalyzeDuplicateException AssetBundleVariant(string serializedFileName, string analyzedArchive)
31+
{
32+
return new AnalyzeDuplicateException(serializedFileName, isArchive: false,
33+
$"AssetBundle variant of '{analyzedArchive}', which was already analyzed (both contain SerializedFile '{serializedFileName}'). Only one variant of each bundle can be analyzed.");
34+
}
2235
}

‎Analyzer/AnalyzerTool.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,9 @@ public int Analyze(AnalyzeOptions options)
131131
}
132132
catch (AnalyzeDuplicateException e)
133133
{
134-
// A file or archive with this name was already analyzed. Only a single build
135-
// can be analyzed at a time; print a clear one-line message (always visible,
136-
// not just with -v) and continue, counting this file as failed.
134+
// This file, archive, or a SerializedFile inside the archive was already
135+
// analyzed. Print a clear one-line message (always visible, not just with -v)
136+
// and continue, counting this file as failed.
137137
EraseProgressLine();
138138
Console.Error.WriteLine($"Skipping {relativePath}: {e.Message}");
139139
countFailures++;

‎Analyzer/SQLite/Parsers/SerializedFileParser.cs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ void ProcessFile(string file, string rootDirectory)
9797
{
9898
bool archiveHadErrors = false;
9999
bool archiveHadMissingTypeTrees = false;
100+
AnalyzeDuplicateException archiveDuplicate = null;
100101
using (UnityArchive archive = UnityFileSystem.MountArchive(file, "archive:" + Path.DirectorySeparatorChar))
101102
{
102103
if (archive == null)
@@ -125,10 +126,9 @@ void ProcessFile(string file, string rootDirectory)
125126
catch (AnalyzeDuplicateException e)
126127
{
127128
// A SerializedFile with this name was already analyzed (e.g. two
128-
// differently-named bundles containing the same CAB). Report the
129-
// self-contained message rather than a raw SQLite constraint error.
130-
Console.Error.WriteLine($"Skipping {node.Path} in archive {archiveName}: {e.Message}");
131-
archiveHadErrors = true;
129+
// differently-named bundles containing the same CAB, or AssetBundle
130+
// variants). Reported once for the whole archive, below.
131+
archiveDuplicate ??= e;
132132
}
133133
catch (Exception e)
134134
{
@@ -151,12 +151,18 @@ void ProcessFile(string file, string rootDirectory)
151151
}
152152
}
153153

154-
// Genuine errors take precedence over missing TypeTrees when reporting the archive's outcome.
154+
// Genuine errors take precedence over duplicates and missing TypeTrees when reporting
155+
// the archive's outcome.
155156
if (archiveHadErrors)
156157
{
157158
throw new Exception("One or more files in the archive failed to process");
158159
}
159160

161+
if (archiveDuplicate != null)
162+
{
163+
throw archiveDuplicate;
164+
}
165+
160166
if (archiveHadMissingTypeTrees)
161167
{
162168
throw new SerializedFileOpenException(file, missingTypeTrees: true);

‎Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ public class SerializedFileSQLiteWriter : IDisposable
2323
// second copy of the same content with a clear error instead of a raw UNIQUE constraint
2424
// failure. Only a single build can be analyzed at a time (see AnalyzeDuplicateException).
2525
// Archive names are compared case-sensitively, matching the archives.name schema constraint
26-
// and the name as it exists on the file system.
26+
// and the name as it exists on the file system. Each serialized file id maps to the archive
27+
// it was found in (null for a loose file) so a duplicate can be recognized as an AssetBundle
28+
// variant of that archive.
2729
private HashSet<string> m_WrittenArchiveNames = new();
28-
private HashSet<int> m_WrittenSerializedFileIds = new();
30+
private Dictionary<int, string> m_WrittenSerializedFiles = new();
31+
private string m_CurrentArchiveName;
2932

3033
private bool m_SkipReferences;
3134
private bool m_SkipCrc;
@@ -156,6 +159,7 @@ public void BeginArchive(string name, long size)
156159
throw new AnalyzeDuplicateException(name, isArchive: true);
157160
}
158161

162+
m_CurrentArchiveName = name;
159163
m_AddArchiveCommand.SetValue("id", m_CurrentArchiveId);
160164
m_AddArchiveCommand.SetValue("name", name);
161165
m_AddArchiveCommand.SetValue("file_size", size);
@@ -170,6 +174,21 @@ public void EndArchive()
170174
}
171175

172176
m_CurrentArchiveId = -1;
177+
m_CurrentArchiveName = null;
178+
}
179+
180+
// AssetBundle variants are named "<bundle>.<variant>", and every variant of a bundle contains
181+
// a SerializedFile with the same name. Two archives that differ only in their extension and
182+
// share a SerializedFile are therefore taken to be variants of the same bundle.
183+
private static bool LooksLikeAssetBundleVariantPair(string archiveA, string archiveB)
184+
{
185+
if (archiveA == null || archiveB == null || archiveA == archiveB)
186+
return false;
187+
188+
if (Path.GetExtension(archiveA) == "" || Path.GetExtension(archiveB) == "")
189+
return false;
190+
191+
return Path.ChangeExtension(archiveA, null) == Path.ChangeExtension(archiveB, null);
173192
}
174193

175194
public void WriteSerializedFile(string relativePath, string fullPath, string containingFolder)
@@ -199,9 +218,13 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con
199218
// Two SerializedFiles with the same name map to the same id (the provider deduplicates by
200219
// name), so a second one would collide on serialized_files.id. Reject it before opening a
201220
// transaction; the file name is what matters to the user, not the analyzer id.
202-
if (m_WrittenSerializedFileIds.Contains(serializedFileId))
221+
if (m_WrittenSerializedFiles.TryGetValue(serializedFileId, out var analyzedArchive))
203222
{
204-
throw new AnalyzeDuplicateException(Path.GetFileName(fullPath), isArchive: false);
223+
var fileName = Path.GetFileName(fullPath);
224+
if (LooksLikeAssetBundleVariantPair(m_CurrentArchiveName, analyzedArchive))
225+
throw AnalyzeDuplicateException.AssetBundleVariant(fileName, analyzedArchive);
226+
227+
throw new AnalyzeDuplicateException(fileName, isArchive: false);
205228
}
206229

207230
using var transaction = m_Database.BeginTransaction();
@@ -387,7 +410,7 @@ public void WriteSerializedFile(string relativePath, string fullPath, string con
387410
}
388411

389412
transaction.Commit();
390-
m_WrittenSerializedFileIds.Add(serializedFileId);
413+
m_WrittenSerializedFiles[serializedFileId] = m_CurrentArchiveName;
391414
}
392415
catch (Exception)
393416
{

‎Documentation/assetbundle-format.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ understand them for normal use, but they show up throughout UnityDataTool output
3535

3636
- **Regular (non-scene) bundles** contain one SerializedFile named `CAB-<hash>`, where the hash is
3737
the **MD4** hash of the AssetBundle name (not the `Hash128` / spooky hash exposed in the C# API).
38+
For [AssetBundle variants](#assetbundle-variants) the name hashed excludes the variant suffix.
3839
- **Scene bundles** name their scene files differently depending on the build pipeline:
3940
- `BuildPipeline.BuildAssetBundles` uses `BuildPlayer-<SceneName>`.
4041
- The Scriptable Build Pipeline / Addressables uses `CAB-<hash of the scene path>`.
@@ -91,6 +92,72 @@ In UnityDataTool output, these layers appear in different places:
9192
Keeping those layers separate helps explain why a query over `refs` may show cross-bundle
9293
relationships without directly mentioning an AssetBundle filename on each reference row.
9394

95+
## AssetBundle variants
96+
97+
`BuildPipeline.BuildAssetBundles` supports **AssetBundle variants**: two or more bundles that hold
98+
interchangeable versions of the same content, for example high and low resolution textures, or the
99+
text for different languages. The application decides at runtime which variant to load, and any
100+
other bundle that references the content resolves to whichever variant is loaded.
101+
102+
Variants are a feature of `BuildPipeline.BuildAssetBundles` only. The Scriptable Build Pipeline and
103+
Addressables do not support them. Variants are a low-level mechanism that makes it harder to reason
104+
about what a build contains, so they are generally discouraged for new projects, but some shipped
105+
titles rely on them and their bundles show up in UnityDataTool output.
106+
107+
### How variants are built
108+
109+
A variant is declared by setting `assetBundleVariant` alongside `assetBundleName`, either in the
110+
`AssetBundleBuild` array passed to `BuildPipeline.BuildAssetBundles` or in the Inspector for an asset
111+
or folder. The variant name is lowercased and appended to the bundle name like a file extension, so
112+
bundle `textures` with variants `hd` and `sd` produces the files `textures.hd` and `textures.sd`
113+
(plus a `.manifest` file for each). Because the variant occupies the extension position there is no
114+
room for a fixed file extension, which is one reason `BuildPipeline.BuildAssetBundles` output has
115+
no standard extension.
116+
117+
The variants are separate bundles in the build output, but the build makes their internals match:
118+
119+
- **Same SerializedFile name.** The `CAB-<hash>` name is the MD4 hash of the base bundle name
120+
(`textures`), not the full name with the variant. `textures.hd` and `textures.sd` therefore both
121+
contain a SerializedFile named `CAB-<hash of "textures">`.
122+
- **Same local object ids.** In a normal bundle an object's local file id is derived from its asset
123+
GUID. In a variant bundle it is instead derived from the asset's name (its file name, or its path
124+
relative to the folder marked with the variant). Two assets with matching names in the `hd` and
125+
`sd` folders therefore get identical local file ids, even though they are different assets with
126+
different GUIDs. Dependencies that are pulled into a variant bundle implicitly, rather than being
127+
marked with the variant, keep the GUID-based id.
128+
- **Same dependency name.** Other bundles record the dependency in `m_Dependencies` by the base name
129+
(`textures`), and the `m_AssetBundleName` field of every variant's AssetBundle object is also the
130+
base name. Only the AssetBundle object's `m_Name` carries the full name (`textures.hd`).
131+
132+
This is what makes the substitution work. A `PPtr` in another bundle identifies its target by
133+
SerializedFile path and local file id (see
134+
[Bundle dependencies and object references](#bundle-dependencies-and-object-references)). Both
135+
values are identical across the variants, so the reference resolves into whichever variant the
136+
application has loaded. The Unity runtime has no variant-specific logic: it simply finds the mounted
137+
SerializedFile with the matching name.
138+
139+
For this to work, each variant should contain the same set of asset names. The build also rejects a
140+
bundle that uses the plain base name in the same build as a variant of that name (`textures` next to
141+
`textures.hd`). The `AssetBundleManifest` lists the full names, and its
142+
`GetAllAssetBundlesWithVariant()` method returns those that were built as variants.
143+
144+
### Variants and UnityDataTool
145+
146+
Apart from the shared internal names, variant bundles are regular AssetBundles, and
147+
[`archive`](command-archive.md), [`dump`](command-dump.md) and
148+
[`serialized-file`](command-serialized-file.md) work on them as on any other bundle.
149+
150+
[`analyze`](command-analyze.md) is the exception. Its schema requires every SerializedFile name to
151+
be unique within a database, and every variant of a bundle contains a SerializedFile with the same
152+
name. If the input includes more than one variant of the same bundle, analyze processes the first
153+
one it meets and skips the rest, reporting each skipped file as an AssetBundle variant of the one
154+
that was analyzed (see
155+
[Duplicate SerializedFile name](command-analyze.md#duplicate-serializedfile-name--duplicate-archive-name)).
156+
The resulting database is still valid; it simply describes one variant. To choose which, pass only
157+
that variant of each bundle, for example only the `.hd` files together with the non-variant bundles.
158+
To compare variants, analyze each into its own database as described in
159+
[Comparing Builds](comparing-builds.md).
160+
94161
## Built-in resources
95162

96163
Bundle content can reference objects in Unity's two built-in resource files (described in

‎Documentation/command-analyze.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ or
188188
```
189189
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.
190190
```
191+
or
192+
```
193+
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.
194+
```
191195

192196
**analyze only supports a single build at a time.** Unity resolves references between SerializedFiles
193197
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
203207
| Cause | What to do |
204208
|-------|------------|
205209
| Multiple builds passed together (or nested in one directory) | Analyze each build into its own database |
206-
| AssetBundle variants (same content, different variant) | Analyze each variant separately |
210+
| [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 |
207211
| Hashed AssetBundle file names across two builds | The file names differ but the inner SerializedFile (`CAB-<hash>`) is shared — analyze each build separately |
208212
| Player scenes with the same file name (`level0`, …) from different builds | Analyze each build separately |
209213

‎UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,32 @@ public async Task Analyze_LooseFilesWithSameName_SkippedWithClearMessage()
103103
1, "only one SerializedFile named 'level0' should be recorded");
104104
}
105105

106+
// AssetBundle variant shape: archives that differ only in their extension and share the same
107+
// inner SerializedFile. The second is skipped with the variant-specific message, printed once
108+
// for the archive rather than once per inner file plus a generic failure line.
109+
[Test]
110+
public async Task Analyze_AssetBundleVariants_SkippedWithVariantMessage()
111+
{
112+
var source = Path.Combine(m_AssetBundlesFolder, "2019.4.0f1", "assetbundle");
113+
var variantHd = Path.Combine(m_TestOutputFolder, "ui.hd");
114+
var variantSd = Path.Combine(m_TestOutputFolder, "ui.sd");
115+
File.Copy(source, variantHd);
116+
File.Copy(source, variantSd);
117+
var databasePath = SQLTestHelper.GetDatabasePath(m_TestOutputFolder);
118+
119+
var (exitCode, stderr) = await RunAnalyze(variantHd, variantSd, "-o", databasePath);
120+
121+
Assert.AreEqual(0, exitCode, "analyze should continue and exit 0 after skipping the variant");
122+
StringAssert.Contains("Skipping ui.sd: AssetBundle variant of 'ui.hd'", stderr);
123+
StringAssert.DoesNotContain("Duplicate SerializedFile name", stderr);
124+
StringAssert.DoesNotContain("Failed to process", stderr);
125+
126+
using var db = SQLTestHelper.OpenDatabase(databasePath);
127+
SQLTestHelper.AssertQueryInt(db,
128+
"SELECT COUNT(*) FROM archives WHERE name IN ('ui.hd', 'ui.sd')",
129+
2, "both variant archives should be recorded");
130+
}
131+
106132
// Hashed-name shape: the same archive under two different file names (as with hashed bundle
107133
// names). The archive names differ, so both are recorded, but they share the same inner
108134
// SerializedFile ("CAB-<hash>"), which is rejected the second time.

0 commit comments

Comments
 (0)