Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Analyzer/AnalyzeDuplicateException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
}
6 changes: 3 additions & 3 deletions Analyzer/AnalyzerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down
16 changes: 11 additions & 5 deletions Analyzer/SQLite/Parsers/SerializedFileParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand All @@ -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);
Expand Down
33 changes: 28 additions & 5 deletions Analyzer/SQLite/Writers/SerializedFileSQLiteWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> m_WrittenArchiveNames = new();
private HashSet<int> m_WrittenSerializedFileIds = new();
private Dictionary<int, string> m_WrittenSerializedFiles = new();
private string m_CurrentArchiveName;

private bool m_SkipReferences;
private bool m_SkipCrc;
Expand Down Expand Up @@ -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);
Expand All @@ -170,6 +174,21 @@ public void EndArchive()
}

m_CurrentArchiveId = -1;
m_CurrentArchiveName = null;
}

// AssetBundle variants are named "<bundle>.<variant>", 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)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
{
Expand Down
67 changes: 67 additions & 0 deletions Documentation/assetbundle-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>`, 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-<SceneName>`.
- The Scriptable Build Pipeline / Addressables uses `CAB-<hash of the scene path>`.
Expand Down Expand Up @@ -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-<hash>` 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-<hash of "textures">`.
- **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
Expand Down
6 changes: 5 additions & 1 deletion Documentation/command-analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<hash>`) is shared — analyze each build separately |
| Player scenes with the same file name (`level0`, …) from different builds | Analyze each build separately |

Expand Down
26 changes: 26 additions & 0 deletions UnityDataTool.Tests/AnalyzeDuplicateNameTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>"), which is rejected the second time.
Expand Down
Loading