From e7997136d81f4317729258677d92b1d8068f72ac Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 14:44:59 -0300 Subject: [PATCH 01/24] feat: request asset bundles by their digest-bearing file name v49+ manifests list files as __, but the client was requesting the digest-less name and carrying the digest as a separate field threaded through every cache layer. Now the intention Hash itself resolves to the verbatim manifest entry, so the URL, the in-memory AB cache, the disk cache, Unity's web cache and the GLTF container cache all key off a single identity: - AssetBundleManifestVersion stores bare hash -> verbatim file name and exposes GetHashWithDigest / TryGetFileNameWithDigest - GLTF containers, AB dependencies, ISS LOD assets and scene emotes resolve their hash through the scene manifest at intention creation - GetAssetBundleIntention.DepsDigest is gone; equality, GetHashCode and the disk key use Hash alone - Unity cache keys dispatch on HasDepsDigests(): scene manifests (the only ones that fetch files[]) key on version|hash; wearables/emotes keep buildDate keying since their bundles are republished in place - DepsDigestKeyingEnabled and its feature-flag wiring are removed; the manifest version published by the backend is the rollout gate. FeatureId.AB_DEPS_DIGEST_CACHE_KEY stays as [Obsolete] to preserve enum ordering - Dead staticscene_ code removed (BuildInitialSceneStateURL, the disabled ISS bundle-mode HEAD probe) Verified against production: v49 manifests mix 2-part and 3-part names, digests are 32-hex, and the CDN serves both the digest-full and digest-less object names, so unmapped files keep working unchanged. Co-Authored-By: Claude Fable 5 --- .../GetSceneEmoteFromRealmIntention.cs | 6 +- .../DCL/FeatureFlags/FeatureFlagsStrings.cs | 1 - .../DCL/FeatureFlags/FeaturesRegistry.cs | 3 +- .../Systems/PrepareGltfAssetLoadingSystem.cs | 17 +- .../AssetBundles/GetAssetBundleIntention.cs | 27 +- .../LoadISSDescriptorSystem.cs | 32 +- .../AssetBundles/LoadAssetBundleSystem.cs | 4 +- ...eAssetBundleLoadingParametersSystemBase.cs | 39 +-- .../EditModeTests/DepsDigestCacheKeyShould.cs | 306 +++++++----------- .../Global/Dynamic/Bootstraper.cs | 5 - .../Global/Dynamic/GlobalWorldFactory.cs | 2 +- .../DCL/LOD/Systems/ResolveISSLODSystem.cs | 18 +- .../AssetBundleManifestVersion.cs | 68 ++-- .../AssetBundleManifestVersionExtensions.cs | 9 +- 14 files changed, 223 insertions(+), 314 deletions(-) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs index 696a96dbf7b..bacfe89b982 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs @@ -59,9 +59,13 @@ public readonly URN NewSceneEmoteURN() => public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitionComponent, URLSubdirectory? customStreamingSubdirectory, IEmote emote) { + // Scene emotes are part of the scene's converted content — resolve to the manifest's digest-bearing + // file name (v49+) so the URL and cache identity match the rest of the scene's ABs. + string platformHash = SceneAssetBundleManifestVersion.GetHashWithDigest(this.EmoteHash + PlatformUtils.GetCurrentPlatform()); + var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - this.EmoteHash + PlatformUtils.GetCurrentPlatform(), + platformHash, typeof(GameObject), assetBundleManifestVersion: SceneAssetBundleManifestVersion, parentEntityID: SceneId, diff --git a/Explorer/Assets/DCL/FeatureFlags/FeatureFlagsStrings.cs b/Explorer/Assets/DCL/FeatureFlags/FeatureFlagsStrings.cs index 6185a93b858..776745d8a60 100644 --- a/Explorer/Assets/DCL/FeatureFlags/FeatureFlagsStrings.cs +++ b/Explorer/Assets/DCL/FeatureFlags/FeatureFlagsStrings.cs @@ -80,7 +80,6 @@ public static class FeatureFlagsStrings public const string NEARBY_VOICE_CHAT = "alfa-nearby-voice-chat"; public const string AVATAR_CONTEXT_MENU = "alfa-avatar-context-menu"; public const string DOUBLE_CLICK_WALK = "alfa-double-click-walk"; - public const string AB_DEPS_DIGEST_CACHE_KEY = "alfa-ab-deps-digest-cache-key"; public const string BYTE_WEIGHTED_LOADING_PROGRESS = "alfa-byte-weighted-loading-progress"; public const string NEW_LODS = "new-lods"; public const string FOUNDATION_COMMUNITY_ID = "alfa-foundation-community-id"; diff --git a/Explorer/Assets/DCL/FeatureFlags/FeaturesRegistry.cs b/Explorer/Assets/DCL/FeatureFlags/FeaturesRegistry.cs index ec0c6946612..3f160c832ce 100644 --- a/Explorer/Assets/DCL/FeatureFlags/FeaturesRegistry.cs +++ b/Explorer/Assets/DCL/FeatureFlags/FeaturesRegistry.cs @@ -1,6 +1,7 @@ using CodeLess.Attributes; using Cysharp.Threading.Tasks; using Global.AppArgs; +using System; using System.Collections.Generic; using System.Threading; using UnityEngine; @@ -68,7 +69,6 @@ public FeaturesRegistry( [FeatureId.AVATAR_CONTEXT_MENU] = appArgs.ResolveFeatureFlagArg(AppArgsFlags.AVATAR_CONTEXT_MENU, featureFlags.IsEnabled(FeatureFlagsStrings.AVATAR_CONTEXT_MENU) || Application.isEditor), [FeatureId.DOUBLE_CLICK_WALK] = appArgs.ResolveFeatureFlagArg(AppArgsFlags.DOUBLE_CLICK_WALK, featureFlags.IsEnabled(FeatureFlagsStrings.DOUBLE_CLICK_WALK)), [FeatureId.PULSE] = appArgs.ResolveFeatureFlagArg(AppArgsFlags.PULSE_MULTIPLAYER, featureFlags.IsEnabled(FeatureFlagsStrings.PULSE), requireDebug: false) && !localSceneDevelopment, - [FeatureId.AB_DEPS_DIGEST_CACHE_KEY] = featureFlags.IsEnabled(FeatureFlagsStrings.AB_DEPS_DIGEST_CACHE_KEY), [FeatureId.BYTE_WEIGHTED_LOADING_PROGRESS] = appArgs.ResolveFeatureFlagArg(AppArgsFlags.BYTE_WEIGHTED_LOADING_PROGRESS, featureFlags.IsEnabled(FeatureFlagsStrings.BYTE_WEIGHTED_LOADING_PROGRESS) || isEditor), [FeatureId.HARDWARE_FINGERPRINT] = appArgs.ResolveFeatureFlagArg(AppArgsFlags.HARDWARE_FINGERPRINT, featureFlags.IsEnabled(FeatureFlagsStrings.HARDWARE_FINGERPRINT)), // Note: COMMUNITIES feature is not cached here because it depends on user identity @@ -201,6 +201,7 @@ public enum FeatureId AVATAR_CONTEXT_MENU = 60, DOUBLE_CLICK_WALK = 61, NEARBY_VOICE_CHAT = 62, + [Obsolete("The v49 deps-digest scheme is always on (gated by the manifest version itself); the flag is no longer read. Kept to preserve enum ordering.")] AB_DEPS_DIGEST_CACHE_KEY = 63, BYTE_WEIGHTED_LOADING_PROGRESS = 64, PULSE = 65, diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs index b6d82021f82..47e6ea4ac95 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs @@ -70,14 +70,15 @@ private void Prepare(in Entity entity, ref GetGltfContainerAssetIntention intent World.Add(entity, GetGLTFIntention.Create(intention.Name, intention.Hash)); else { - var abIntention = GetAssetBundleIntention.Create(typeof(GameObject), $"{intention.Hash}{PlatformUtils.GetCurrentPlatform()}", intention.Name); - // Pre-populate so PrepareAssetBundleLoadingParametersSystem doesn't have to look it up by the - // platform-suffixed hash (the digest map is keyed by bare hashes). - //This will go away when the urls include the depsDigest - if (sceneData.SceneEntityDefinition.assetBundleManifestVersion is { } manifest - && manifest.TryGetDepsDigest(intention.Hash, out string digest)) - abIntention.DepsDigest = digest; - World.Add(entity, abIntention); + // For v49+ manifests the hash resolves to the digest-bearing file name + // (__) — the name that actually exists on the CDN and the sole + // identity used by every cache layer downstream. + string platformHash = $"{intention.Hash}{PlatformUtils.GetCurrentPlatform()}"; + + if (sceneData.SceneEntityDefinition.assetBundleManifestVersion is { } manifest) + platformHash = manifest.GetHashWithDigest(platformHash); + + World.Add(entity, GetAssetBundleIntention.Create(typeof(GameObject), platformHash, intention.Name)); } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs index 5ecb8f2d660..0549b8fb8b9 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs @@ -1,7 +1,6 @@ using AssetManagement; using CommunicationData.URLHelpers; using DCL.Ipfs; -using DCL.Utility; using ECS.StreamableLoading.Cache.Disk.Cacheables; using ECS.StreamableLoading.Common.Components; using SceneRunner.Scene; @@ -35,13 +34,6 @@ public struct GetAssetBundleIntention : ILoadingIntention, IEquatable internal Hash128? cacheHash; - /// - /// Per-file dependency digest from the v49+ scene asset-bundle manifest. Two scenes can request the same - /// with different dependency closures; this field disambiguates them in the cache. - /// Empty for legacy (pre-v49) entries — those keep their historical key. - /// - public string? DepsDigest; - public bool IsDependency; public bool LookForDependencies; @@ -62,7 +54,6 @@ private GetAssetBundleIntention(Type? expectedObjectType, string? name = null, CommonArguments = new CommonLoadingArguments(URLAddress.EMPTY, customEmbeddedSubDirectory, permittedSources: permittedSources, cancellationTokenSource: cancellationTokenSource); cacheHash = null; - DepsDigest = null; ParentEntityID = parentEntityID; AssetBundleManifestVersion = assetBundleVersion; @@ -75,9 +66,10 @@ internal GetAssetBundleIntention(CommonLoadingArguments commonArguments) : this( CommonArguments = commonArguments; } + // Hash alone identifies the bundle: v49+ hashes carry the deps digest inside the file name itself + // (__), so two dependency closures never share a Hash. public bool Equals(GetAssetBundleIntention other) => - StringComparer.OrdinalIgnoreCase.Equals(Hash, other.Hash) - && StringComparer.OrdinalIgnoreCase.Equals(DepsDigest ?? string.Empty, other.DepsDigest ?? string.Empty); + StringComparer.OrdinalIgnoreCase.Equals(Hash, other.Hash); public CommonLoadingArguments CommonArguments { get; set; } @@ -96,16 +88,11 @@ public override bool Equals(object obj) => obj is GetAssetBundleIntention other && Equals(other); public override int GetHashCode() => - HashCode.Combine( - StringComparer.OrdinalIgnoreCase.GetHashCode(Hash ?? string.Empty), - StringComparer.OrdinalIgnoreCase.GetHashCode(DepsDigest ?? string.Empty)); + StringComparer.OrdinalIgnoreCase.GetHashCode(Hash ?? string.Empty); public override string ToString() => $"Get Asset Bundle: {Name} ({Hash})"; - public static string BuildInitialSceneStateURL(string initialSceneStateID) => - $"staticscene_{initialSceneStateID}{PlatformUtils.GetCurrentPlatform()}"; - public class DiskHashCompute : AbstractDiskHashCompute { public static readonly DiskHashCompute INSTANCE = new (); @@ -114,11 +101,9 @@ private DiskHashCompute() { } protected override void FillPayload(IHashKeyPayload keyPayload, in GetAssetBundleIntention asset) { + // v49+ hashes carry the deps digest inside the name, so the hash alone keys the on-disk file; + // digest-less hashes produce the same key as before this scheme, keeping legacy entries hitting. keyPayload.Put(asset.Hash ?? asset.Name!); - - // Only contribute to the disk key when present so legacy 2-part-filename entries keep their existing on-disk file. - if (!string.IsNullOrEmpty(asset.DepsDigest)) - keyPayload.Put(asset.DepsDigest); } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/LoadISSDescriptorSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/LoadISSDescriptorSystem.cs index 2bd5f42d6d6..0d5bf7c3e49 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/LoadISSDescriptorSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/LoadISSDescriptorSystem.cs @@ -4,8 +4,6 @@ using Cysharp.Threading.Tasks; using DCL.Diagnostics; using DCL.FeatureFlags; -using DCL.Ipfs; -using DCL.Utility; using DCL.WebRequests; using ECS.Groups; using ECS.Prioritization.Components; @@ -24,10 +22,6 @@ namespace ECS.StreamableLoading.AssetBundles.InitialSceneState /// (pre-v49 manifest or missing descriptor JSON) yield a *failed* result — that way the framework /// never persists the "no ISS" outcome to disk (PutAsync is gated on success), and the consumer /// in ResolveISSDescriptorSystem calls ISSDescriptor.MarkAsNone. - /// - /// The HEAD probe that chooses between Bundle and Descriptor modes is temporarily disabled — see - /// . A later PR will re-enable Bundle mode. - /// /// [UpdateInGroup(typeof(LoadGlobalSystemGroup))] [LogCategory(ReportCategory.SCENE_LOADING)] @@ -36,16 +30,14 @@ public partial class LoadISSDescriptorSystem : LoadSystemBase cache, DiskCacheOptions? diskCacheOptions = null) : base(world, cache, diskCacheOptions) { this.webRequestController = webRequestController; - this.assetBundleURL = assetBundleURL; this.descriptorBaseUrl = descriptorBaseUrl; } @@ -68,11 +60,6 @@ protected override async UniTask> if (!metadata.HasValue) return new StreamableLoadingResult(GetReportData(), new Exception("No ISS descriptor JSON for this scene")); - // Bundle-mode HEAD probe is temporarily disabled — every ISS-capable scene goes through - // Descriptor mode for now. Kept the IsBundleReachableAsync helper below so re-enabling is - // a one-line restore in a later PR; see PR description for rationale. - // bool bundleReachable = await IsBundleReachableAsync(intention.SceneId, intention.ManifestVersion!, ct); - return new StreamableLoadingResult(metadata.Value); } @@ -96,22 +83,5 @@ protected override async UniTask> return null; } } - - // TODO (Juani): Currently unused: FlowInternalAsync forces Descriptor mode. Kept for the follow-up PR that - // re-enables Bundle-mode selection. - private async UniTask IsBundleReachableAsync(string sceneId, AssetBundleManifestVersion manifestVersion, CancellationToken ct) - { - if (manifestVersion == null || manifestVersion.assetBundleManifestRequestFailed) return false; - - string version = manifestVersion.GetAssetBundleManifestVersion(); - if (string.IsNullOrEmpty(version)) return false; - - string bundleHash = $"staticscene_{sceneId}{PlatformUtils.GetCurrentPlatform()}"; - URLAddress bundleUrl = manifestVersion.HasHashInPath() - ? assetBundleURL.Append(new URLPath($"{version}/{sceneId}/{bundleHash}")) - : assetBundleURL.Append(new URLPath($"{version}/{bundleHash}")); - - return await webRequestController.IsHeadReachableAsync(GetReportData(), bundleUrl, ct, suppressErrors: true); - } } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs index f0d173dda16..7ed862e5a5f 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs @@ -221,7 +221,9 @@ private async UniTask WaitForDependencyAsync( { // Inherit partition from the parent promise // we don't know the type of the dependency - var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(hash, assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); + // Dependency hashes baked into AB metadata are digest-less — resolve them to the manifest's + // digest-bearing file name so the URL and cache identity match the parent's scheme. + var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(assetBundleManifestVersion.GetHashWithDigest(hash), assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); try { diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index a527a6dbb6c..fc1c6bf3a20 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -67,17 +67,15 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifestVersion.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()); assetBundleIntention.CommonArguments = ca; - // DepsDigest is pre-populated upstream (e.g. PrepareGltfAssetLoadingSystem) from the bare hash, - // before the platform suffix is appended. The digest map itself is also keyed by bare hashes, so - // any path that wants digest-based keying must set DepsDigest before reaching this system. - // - // Dispatch on the manifest version, not on whether DepsDigest happens to be populated. A v49+ - // leaf AB that isn't listed in the manifest's deps map has an empty digest — routing it through - // the legacy path would key it on buildDate and prevent disk-cache sharing across CDN republishes. - assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifestVersion.SupportsDepsDigests() + // v49+ hashes carry the deps digest inside the file name (__), so + // (version + hash) is unique per dependency closure. Dispatch on whether the manifest carries the + // deps map (only scene manifests do), not on whether this hash happens to carry a digest — an AB + // with a legacy 2-part name inside a mapped manifest would otherwise be keyed on buildDate, + // preventing cache sharing across CDN republishes. Wearables/emotes never carry the map and keep + // buildDate keying: their bundles are republished in place and cannot be reused across builds. + assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifestVersion.HasDepsDigests() ? ComputeHashV49(assetBundleIntention.Hash, - assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion(), - assetBundleIntention.DepsDigest ?? string.Empty) + assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()) : ComputeHashLegacy(assetBundleIntention.Hash, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestBuildDate()); } @@ -103,24 +101,19 @@ public static unsafe Hash128 ComputeHashLegacy(string hash, string buildDate) fixed (char* ptr = hashBuilder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * hashBuilder.Length)); } } - public static unsafe Hash128 ComputeHashV49(string hash, string version, string depsDigest) + public static unsafe Hash128 ComputeHashV49(string hash, string version) { - // The per-file deps digest replaces the buildDate sledgehammer that was previously used to invalidate - // the cache whenever a dependency might have changed. Keying on (version + hash + digest) lets the disk - // cache stay shareable across CDN republishes when the dependency closure is unchanged. depsDigest may - // be empty for leaf ABs that aren't listed in the manifest's deps map — that's a valid input here. + // The per-file deps digest embedded in the hash replaces the buildDate sledgehammer that was previously + // used to invalidate the cache whenever a dependency might have changed. Keying on (version + hash) lets + // the cache stay shareable across CDN republishes when the dependency closure is unchanged. The delimiter + // prevents boundary collisions between version and hash. ReadOnlySpan hashSpan = hash.AsSpan(); ReadOnlySpan versionSpan = version.AsSpan(); - ReadOnlySpan digestSpan = depsDigest.AsSpan(); - Span builder = stackalloc char[versionSpan.Length + 1 + hashSpan.Length + 1 + digestSpan.Length]; + Span builder = stackalloc char[versionSpan.Length + 1 + hashSpan.Length]; versionSpan.CopyTo(builder); - int offset = versionSpan.Length; - builder[offset++] = '|'; - hashSpan.CopyTo(builder[offset..]); - offset += hashSpan.Length; - builder[offset++] = '|'; - digestSpan.CopyTo(builder[offset..]); + builder[versionSpan.Length] = '|'; + hashSpan.CopyTo(builder[(versionSpan.Length + 1)..]); fixed (char* ptr = builder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * builder.Length)); } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index fb67badf243..1ea6e8fa78e 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -1,4 +1,5 @@ using DCL.Ipfs; +using DCL.Utility; using ECS.StreamableLoading.Cache.Disk; using ECS.Unity.GLTFContainer.Asset.Components; using NUnit.Framework; @@ -12,107 +13,130 @@ public class DepsDigestCacheKeyShould private const string DIGEST_A = "dda1af30bdf4a19ce03e663a9a288afe"; private const string DIGEST_B = "243f68977939e1f526b4c1a05a40b43a"; - private bool depsDigestKeyingEnabledBackup; + private const string HASH_A = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; + private const string HASH_B = "bafkreice3qpeyeb4ni7fnlt6bijs57zrbuw7cwmbymzcndyllzho3hbgaa"; + private const string HASH_LEGACY = "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru"; - [OneTimeSetUp] - public void EnableDepsDigestKeyingForFixture() + private static AssetBundleManifestVersion CreateV49Manifest(params string[] files) { - // The production gate is off until the feature flag flips it from bootstrap. Tests exercise the v49 - // code paths directly, so opt in for the lifetime of the fixture and restore in teardown. - depsDigestKeyingEnabledBackup = AssetBundleManifestVersion.DepsDigestKeyingEnabled; - AssetBundleManifestVersion.DepsDigestKeyingEnabled = true; + var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + manifest.InjectDepsDigests(files); + return manifest; } - [OneTimeTearDown] - public void RestoreDepsDigestKeying() + [Test] + public void ResolveHashWithDigestToVerbatimManifestEntry() { - AssetBundleManifestVersion.DepsDigestKeyingEnabled = depsDigestKeyingEnabledBackup; + // GetHashWithDigest strips the *current* platform suffix off the input, so the manifest entries must be + // built with it for the test to be platform-agnostic. + string platform = PlatformUtils.GetCurrentPlatform(); + + AssetBundleManifestVersion manifest = CreateV49Manifest( + $"{HASH_LEGACY}{platform}", + $"{HASH_A}_{DIGEST_A}{platform}"); + + Assert.That(manifest.GetHashWithDigest($"{HASH_A}{platform}"), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + + Assert.That(manifest.GetHashWithDigest($"{HASH_LEGACY}{platform}"), Is.EqualTo($"{HASH_LEGACY}{platform}"), + "Files listed without a digest must fall back to the input hash"); + + Assert.That(manifest.GetHashWithDigest($"{HASH_B}{platform}"), Is.EqualTo($"{HASH_B}{platform}"), + "Hashes absent from the manifest must fall back to the input hash"); } [Test] - public void InjectDepsDigestsFromThreePartFilenames() + public void ResolveHashWithDigestCaseInsensitivelyToManifestCasing() { - string[] files = - { - "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru_mac", - $"bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4_{DIGEST_A}_mac", - $"bafkreice3qpeyeb4ni7fnlt6bijs57zrbuw7cwmbymzcndyllzho3hbgaa_{DIGEST_B}_mac", - }; + string platform = PlatformUtils.GetCurrentPlatform(); - var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - manifest.InjectDepsDigests(files); + AssetBundleManifestVersion manifest = CreateV49Manifest($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}"); - Assert.That(manifest.TryGetDepsDigest("bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru", out _), Is.False, "Legacy 2-part filenames must not contribute a digest"); - Assert.That(manifest.TryGetDepsDigest("bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4", out string digestA), Is.True); - Assert.That(digestA, Is.EqualTo(DIGEST_A)); - Assert.That(manifest.TryGetDepsDigest("bafkreice3qpeyeb4ni7fnlt6bijs57zrbuw7cwmbymzcndyllzho3hbgaa", out string digestB), Is.True); - Assert.That(digestB, Is.EqualTo(DIGEST_B)); + // The lookup is case-insensitive and returns the manifest's casing — the CDN is case-sensitive, + // so the verbatim entry is the one that actually exists. + Assert.That(manifest.GetHashWithDigest($"QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV{platform}"), + Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}")); } [Test] - public void TreatIntentionsWithDifferentDigestsAsDistinct() + public void ReportDepsDigestsOnlyWhenMapWasInjected() { - var sameHash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - var a = GetAssetBundleIntention.FromHash(sameHash); - a.DepsDigest = DIGEST_A; - var b = GetAssetBundleIntention.FromHash(sameHash); - b.DepsDigest = DIGEST_B; + // The cache-key dispatch (v49 version+hash vs legacy buildDate+hash) hinges on this: only manifests + // that received files[] (scenes) report true. Wearable/emote manifests never fetch files[] and must + // keep buildDate keying — their bundles are republished in place and cannot be reused across builds. + var withoutMap = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + Assert.That(withoutMap.HasDepsDigests(), Is.False); + + AssetBundleManifestVersion withMap = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); + Assert.That(withMap.HasDepsDigests(), Is.True); + + // A manifest whose files[] contained only legacy 2-part names carries no map either. + AssetBundleManifestVersion onlyLegacyFiles = CreateV49Manifest($"{HASH_LEGACY}_mac"); + Assert.That(onlyLegacyFiles.HasDepsDigests(), Is.False); + } - Assert.That(a.Equals(b), Is.False); - Assert.That(a.GetHashCode(), Is.Not.EqualTo(b.GetHashCode())); + [Test] + public void ComposeCacheKey_FallsBackToBareHashWhenManifestIsNull() + { + AssetBundleManifestVersion? manifest = null; + Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo(HASH_A)); } [Test] - public void TreatIntentionsWithSameHashAndDigestAsEqual() + public void ComposeCacheKey_FallsBackToBareHashWhenNoDigest() { - var sameHash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - var a = GetAssetBundleIntention.FromHash(sameHash); - a.DepsDigest = DIGEST_A; - var b = GetAssetBundleIntention.FromHash(sameHash); - b.DepsDigest = DIGEST_A; + AssetBundleManifestVersion manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo(HASH_A)); + } - Assert.That(a.Equals(b), Is.True); - Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); + [Test] + public void ComposeCacheKey_ReturnsVerbatimFileNameWhenPresent() + { + AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); + + Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}_mac")); } [Test] - public void PreserveLegacyEqualityWhenNoDigestPresent() + public void TreatIntentionsWithDifferentDigestBearingHashesAsDistinct() { - // Two intentions with the same hash and no digest must still match — preserves cache hits for pre-v49 entries. - var sameHash = "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru"; - var a = GetAssetBundleIntention.FromHash(sameHash); - var b = GetAssetBundleIntention.FromHash(sameHash); + // The digest is part of the Hash itself, so two dependency closures of the same bare hash never + // collide in the in-memory AB cache. + var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); + var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac"); + + Assert.That(a.Equals(b), Is.False); + Assert.That(a.GetHashCode(), Is.Not.EqualTo(b.GetHashCode())); + } + + [Test] + public void TreatIntentionsWithSameHashAsEqual() + { + var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); + var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); Assert.That(a.Equals(b), Is.True); Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); } [Test] - public void ProduceDistinctDiskFilenamesForDifferentDigests() + public void ProduceDistinctDiskFilenamesForDifferentDigestBearingHashes() { - var sameHash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - var a = GetAssetBundleIntention.FromHash(sameHash); - a.DepsDigest = DIGEST_A; - var b = GetAssetBundleIntention.FromHash(sameHash); - b.DepsDigest = DIGEST_B; + var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); + var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac"); using var keyA = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in a); using var keyB = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in b); - string nameA = HashNamings.HashNameFrom(keyA, ".ab"); - string nameB = HashNamings.HashNameFrom(keyB, ".ab"); - - Assert.That(nameA, Is.Not.EqualTo(nameB)); + Assert.That(HashNamings.HashNameFrom(keyA, ".ab"), Is.Not.EqualTo(HashNamings.HashNameFrom(keyB, ".ab"))); } [Test] - public void PreserveLegacyDiskFilenameWhenNoDigest() + public void PreserveLegacyDiskFilenameForDigestLessHashes() { - // An intention without a digest must produce the same on-disk file name as before this change so existing + // A digest-less hash must produce the same on-disk file name as before this scheme so existing // cached entries keep hitting after upgrade. - var sameHash = "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru"; - var legacy = GetAssetBundleIntention.FromHash(sameHash); - var alsoLegacy = GetAssetBundleIntention.FromHash(sameHash); + var legacy = GetAssetBundleIntention.FromHash(HASH_LEGACY); + var alsoLegacy = GetAssetBundleIntention.FromHash(HASH_LEGACY); using var keyA = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in legacy); using var keyB = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in alsoLegacy); @@ -123,18 +147,16 @@ public void PreserveLegacyDiskFilenameWhenNoDigest() [Test] public void GltfIntentionDefaultsCacheKeyToHash() { - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - var intention = new GetGltfContainerAssetIntention("model.glb", hash, new CancellationTokenSource()); + var intention = new GetGltfContainerAssetIntention("model.glb", HASH_A, new CancellationTokenSource()); - Assert.That(intention.CacheKey, Is.EqualTo(hash), "Legacy callers that don't supply a cache key must default to the bare hash"); + Assert.That(intention.CacheKey, Is.EqualTo(HASH_A), "Legacy callers that don't supply a cache key must default to the bare hash"); } [Test] public void GltfIntentionStoresPassedCacheKeyVerbatim() { - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - string customKey = $"{hash}@{DIGEST_A}"; - var intention = new GetGltfContainerAssetIntention("model.glb", hash, new CancellationTokenSource(), customKey); + string customKey = $"{HASH_A}_{DIGEST_A}_mac"; + var intention = new GetGltfContainerAssetIntention("model.glb", HASH_A, new CancellationTokenSource(), customKey); Assert.That(intention.CacheKey, Is.EqualTo(customKey)); } @@ -142,86 +164,29 @@ public void GltfIntentionStoresPassedCacheKeyVerbatim() [Test] public void GltfIntentionsWithDifferentCacheKeysAreDistinct() { - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - var a = new GetGltfContainerAssetIntention("model.glb", hash, new CancellationTokenSource(), $"{hash}@{DIGEST_A}"); - var b = new GetGltfContainerAssetIntention("model.glb", hash, new CancellationTokenSource(), $"{hash}@{DIGEST_B}"); + var a = new GetGltfContainerAssetIntention("model.glb", HASH_A, new CancellationTokenSource(), $"{HASH_A}_{DIGEST_A}_mac"); + var b = new GetGltfContainerAssetIntention("model.glb", HASH_A, new CancellationTokenSource(), $"{HASH_A}_{DIGEST_B}_mac"); Assert.That(a.CacheKey, Is.Not.EqualTo(b.CacheKey)); Assert.That(a.Equals(b), Is.False); } - [Test] - public void ComposeCacheKey_FallsBackToBareHashWhenManifestIsNull() - { - AssetBundleManifestVersion? manifest = null; - Assert.That(manifest.ComposeCacheKey("X"), Is.EqualTo("X")); - } - - [Test] - public void ComposeCacheKey_FallsBackToBareHashWhenNoDigest() - { - var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - Assert.That(manifest.ComposeCacheKey("X"), Is.EqualTo("X")); - } - - [Test] - public void ComposeCacheKey_AppendsDigestWhenPresent() - { - var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - manifest.InjectDepsDigests(new[] { $"X_{DIGEST_A}_mac" }); - - Assert.That(manifest.ComposeCacheKey("X"), Is.EqualTo($"X@{DIGEST_A}")); - } - [Test] public void V49HashIsStableForSameInputs() { - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", DIGEST_A); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", DIGEST_A); - - Assert.That(a, Is.EqualTo(b)); - } - - [Test] - public void V49HashAcceptsEmptyDigestForLeafBundles() - { - // v49+ leaf ABs that aren't listed in the manifest's deps map carry an empty digest. They must still - // produce a deterministic key — and crucially, one that doesn't depend on buildDate. - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", string.Empty); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", string.Empty); + Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); + Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); Assert.That(a, Is.EqualTo(b)); } [Test] - public void V49HashDiffersWhenDigestDiffers() - { - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", DIGEST_A); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", DIGEST_B); - - Assert.That(a, Is.Not.EqualTo(b)); - } - - [Test] - public void V49HashDiffersBetweenEmptyAndNonEmptyDigest() - { - // A v49+ leaf AB (no digest) and a v49+ AB with a digest must not share a cache key, even though - // both go through the v49 path — the digest is a real discriminator and absence is meaningful. - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - Hash128 leaf = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", string.Empty); - Hash128 withDigest = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", DIGEST_A); - - Assert.That(leaf, Is.Not.EqualTo(withDigest)); - } - - [Test] - public void V49HashDiffersWhenHashDiffers() + public void V49HashDiffersWhenDigestBearingHashDiffers() { - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("hashA", "v49", DIGEST_A); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("hashB", "v49", DIGEST_A); + // The digest travels inside the hash, so two dependency closures of the same bare hash produce + // different Unity-cache keys. + Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); + Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_B}_mac", "v49"); Assert.That(a, Is.Not.EqualTo(b)); } @@ -229,9 +194,8 @@ public void V49HashDiffersWhenHashDiffers() [Test] public void V49HashDiffersWhenVersionDiffers() { - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", DIGEST_A); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v50", DIGEST_A); + Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); + Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v50"); Assert.That(a, Is.Not.EqualTo(b)); } @@ -239,10 +203,10 @@ public void V49HashDiffersWhenVersionDiffers() [Test] public void V49DelimiterPreventsBoundaryCollisions() { - // Without delimiters, (version="v4", hash="9foo") and (version="v49", hash="foo") would produce the - // same byte stream. - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("9foo", "v4", string.Empty); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("foo", "v49", string.Empty); + // Without the delimiter, (version="v4", hash="9foo") and (version="v49", hash="foo") would produce + // the same byte stream. + Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("9foo", "v4"); + Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("foo", "v49"); Assert.That(a, Is.Not.EqualTo(b)); } @@ -250,9 +214,8 @@ public void V49DelimiterPreventsBoundaryCollisions() [Test] public void LegacyHashIsStableForSameInputs() { - const string hash = "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru"; - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(hash, "2026-05-01"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(hash, "2026-05-01"); + Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); + Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); Assert.That(a, Is.EqualTo(b)); } @@ -262,13 +225,23 @@ public void LegacyHashChangesWithBuildDate() { // Pre-v49 ABs have no per-file freshness signal, so buildDate is the only thing that flushes the cache // when dependencies might have changed — verify it is actually contributing to the key. - const string hash = "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru"; - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(hash, "2026-05-01"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(hash, "2026-05-02"); + Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); + Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-02"); Assert.That(a, Is.Not.EqualTo(b)); } + [Test] + public void V49AndLegacyDoNotCollideForSameHash() + { + // A v49+ AB with a digest-less name must not accidentally produce the same Hash128 as the legacy path + // for the same hash, even if the legacy buildDate happens to equal the v49 version string. + Hash128 legacy = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_A, "v49"); + Hash128 v49 = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(HASH_A, "v49"); + + Assert.That(legacy, Is.Not.EqualTo(v49)); + } + [Test] public void VersionPredicates_DoNotThrowOnNonVNVersions() { @@ -289,48 +262,5 @@ public void VersionPredicates_DoNotThrowOnNonVNVersions() var nonNumeric = AssetBundleManifestVersion.CreateFromFallback("vfoo", "dummyDate"); Assert.That(nonNumeric.SupportsDepsDigests(), Is.False); } - - [Test] - public void V49AndLegacyDoNotCollideForSameHash() - { - // A v49+ leaf AB with no digest must not accidentally produce the same Hash128 as the legacy path for - // the same bare hash, even if the legacy buildDate happens to equal the v49 version string. - const string hash = "bafkreif5xmg4un7cm4ouyqfoluc6ifcdouiatassnv5pykell4e4mw5xc4"; - Hash128 legacy = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(hash, "v49"); - Hash128 v49 = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(hash, "v49", string.Empty); - - Assert.That(legacy, Is.Not.EqualTo(v49)); - } - - [Test] - public void Gate_DisablesV49DigestSupportEvenForV49Manifests() - { - // With the kill-switch off, every manifest must report SupportsDepsDigests() == false so the dispatch - // in PrepareCommonArguments takes the legacy path for v49 traffic too. - AssetBundleManifestVersion.DepsDigestKeyingEnabled = false; - try - { - var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - Assert.That(manifest.SupportsDepsDigests(), Is.False); - } - finally { AssetBundleManifestVersion.DepsDigestKeyingEnabled = true; } - } - - [Test] - public void Gate_SuppressesDigestLookupsAndInjection() - { - // With the kill-switch off, TryGetDepsDigest must not return digests even if InjectDepsDigests is - // called — guarantees ComposeCacheKey returns the bare hash and GLTF/AB cache keys stay legacy-shaped. - AssetBundleManifestVersion.DepsDigestKeyingEnabled = false; - try - { - var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - manifest.InjectDepsDigests(new[] { $"X_{DIGEST_A}_mac" }); - - Assert.That(manifest.TryGetDepsDigest("X", out _), Is.False); - Assert.That(manifest.ComposeCacheKey("X"), Is.EqualTo("X")); - } - finally { AssetBundleManifestVersion.DepsDigestKeyingEnabled = true; } - } } } diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs index a5cce7fc25f..1df992fa999 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs @@ -5,7 +5,6 @@ using DCL.DebugUtilities; using DCL.Diagnostics; using DCL.FeatureFlags; -using DCL.Ipfs; using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.Notifications.NewNotification; using DCL.Optimization.PerformanceBudgeting; @@ -223,10 +222,6 @@ public async UniTask InitializeFeatureFlagsAsync(IWeb3Identity? identity, IDecen public void InitializeFeaturesRegistry() { FeaturesRegistry.Initialize(new FeaturesRegistry(appArgs, realmLaunchSettings.CurrentMode is LaunchMode.LocalSceneDevelopment)); - - // Gate the v49 deps-digest cache-keying scheme behind the feature flag. Off by default means every - // manifest reports SupportsDepsDigests() == false and the entire pipeline takes the legacy code path. - AssetBundleManifestVersion.DepsDigestKeyingEnabled = FeaturesRegistry.Instance.IsEnabled(FeatureId.AB_DEPS_DIGEST_CACHE_KEY); } public GlobalWorld CreateGlobalWorld( diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/GlobalWorldFactory.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/GlobalWorldFactory.cs index d33ee5e0a7b..231705f8f89 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/GlobalWorldFactory.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/GlobalWorldFactory.cs @@ -163,7 +163,7 @@ public GlobalWorld Create(ISceneFactory sceneFactory, Entity playerEntity) var assetBundleCdnUrl = URLDomain.FromString(urlsSource.Url(DecentralandUrl.AssetBundlesCDN)); var lodGeneratorCdnUrl = URLDomain.FromString(urlsSource.Url(DecentralandUrl.LodGeneratorCDN)); - LoadISSDescriptorSystem.InjectToWorld(ref builder, webRequestController, assetBundleCdnUrl, lodGeneratorCdnUrl, + LoadISSDescriptorSystem.InjectToWorld(ref builder, webRequestController, lodGeneratorCdnUrl, new NoCache(false, false), new DiskCacheOptions(staticContainer.ISSDescriptorDiskCache, GetISSDescriptorIntention.DiskHashCompute.INSTANCE, "iss.json")); diff --git a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs index 919574b85b5..ac4aef9d9c6 100644 --- a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs @@ -77,7 +77,7 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead { ISSDescriptorAsset entry = assets[i]; - // The GLTF container cache is keyed by "hash@digest" (see AssetBundleManifestVersionExtensions.ComposeCacheKey). + // The GLTF container cache is keyed by the digest-bearing file name (see AssetBundleManifestVersionExtensions.ComposeCacheKey). // Looking up by bare hash misses any bridged entry the SDK runtime left behind, so the LOD spawns a // second instance of an asset that's already resident — that's the visible "double" overlap. string cacheKey = manifest.ComposeCacheKey(entry.hash); @@ -92,20 +92,20 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead continue; } - // Descriptor mode: fetch each asset's own bundle — must include the platform suffix to match the deployed AB filename. + // Descriptor mode: fetch each asset's own bundle — must include the platform suffix and, for v49+, + // the deps digest to match the deployed AB filename. Resolving through the manifest also lands this + // promise in the same AssetBundleCache slot as the SDK runtime (which resolves identically in + // PrepareGltfAssetLoadingSystem); a diverging Hash would race two LoadAssetBundleSystem flows for + // the same physical bundle, which Unity refuses with "asset bundle already loaded". string promiseHash = $"{entry.hash}{PlatformUtils.GetCurrentPlatform()}"; + if (manifest != null) + promiseHash = manifest.GetHashWithDigest(promiseHash); + var intent = GetAssetBundleIntention.FromHash(promiseHash, assetBundleManifestVersion: manifest, parentEntityID: sceneDefinition.Definition.id); - // Mirror the digest populated by PrepareGltfAssetLoadingSystem so this promise lands in the same - // AssetBundleCache slot as the SDK runtime would. Without it the (Hash, DepsDigest) key diverges - // and two parallel LoadAssetBundleSystem flows race for the same physical bundle, which Unity - // refuses with "asset bundle already loaded". The digest map is keyed by bare CID. - if (manifest != null && manifest.TryGetDepsDigest(entry.hash, out string digest)) - intent.DepsDigest = digest; - AssetBundlePromise promise = AssetBundlePromise.Create(World, intent, PartitionComponent.TOP_PRIORITY); ISSAssetCreationHelper assetCreationHelper = new ISSAssetCreationHelper(initialSceneStateLOD, entry, cacheKey); diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 9779234b50f..9d148d56d7e 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -25,12 +25,6 @@ public class AssetBundleManifestVersion private static readonly char[] FILE_NAME_SEPARATOR = { '_' }; - // Global kill-switch for the v49 deps-digest cache-keying scheme. Default off so the build is byte-identical - // to legacy behavior until the feature flag is rolled out. Flipped from bootstrap based on FeaturesRegistry. - // When false: SupportsDepsDigests reports false for every manifest, TryGetDepsDigest never returns a digest, - // and every downstream call site (cache dispatch, GLTF key compose, digest fetch) falls back to legacy. - public static bool DepsDigestKeyingEnabled; - private bool? HasHashInPathValue; private bool? SupportsDepsDigestsValue; @@ -40,7 +34,9 @@ public class AssetBundleManifestVersion public AssetBundleManifestVersionPerPlatform? assets; private HashSet? convertedFiles; - private IReadOnlyDictionary? depsDigests; + + //Bare hash → verbatim manifest file name (__), see InjectDepsDigests + private IReadOnlyDictionary? depsFiles; public bool HasHashInPath() { @@ -50,12 +46,11 @@ public bool HasHashInPath() /// /// True when the manifest's version is v49 or newer — i.e. when the per-file deps-digest scheme is - /// in use for cache keying. This is purely a version check; an individual asset may still have an - /// empty digest (leaf ABs that aren't listed in the manifest's deps map). + /// in use. This is purely a version check; an individual file may still carry no digest (v49 manifests + /// mix digest-bearing and legacy 2-part file names). /// public bool SupportsDepsDigests() { - if (!DepsDigestKeyingEnabled) return false; SupportsDepsDigestsValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_SUPPORTS_DEPS_DIGEST; return SupportsDepsDigestsValue.Value; } @@ -82,15 +77,16 @@ private static bool TryParseVersionNumber(string? version, out int parsed) } /// - /// Parses the manifest's files[] entries and stores the per-file deps digest map. - /// Expects v49+ filenames in the form <hash>_<depsDigest>_<platform>; - /// legacy 2-part filenames split into fewer parts and are skipped. + /// Parses the manifest's files[] entries and stores, per bare hash, the verbatim + /// digest-bearing file name (<hash>_<depsDigest>_<platform>). Entries without a + /// valid digest segment (legacy 2-part names, which v49 manifests still mix in) are skipped and keep + /// resolving to their digest-less name. /// public void InjectDepsDigests(string[]? files) { - if (!DepsDigestKeyingEnabled || files == null || files.Length == 0) + if (files == null || files.Length == 0) { - depsDigests = null; + depsFiles = null; return; } @@ -104,18 +100,50 @@ public void InjectDepsDigests(string[]? files) if (parts.Length < 3) continue; map ??= new Dictionary(new UrlHashComparer()); - map[parts[0]] = parts[1]; + map[parts[0]] = file; } - depsDigests = map; + depsFiles = map; } - public bool TryGetDepsDigest(string hash, out string digest) + /// + /// True when this manifest carries the per-file deps map (injected from the manifest's files[]). + /// Only scene manifests get it — wearable/emote manifests never fetch files[], so they keep the + /// legacy buildDate-based cache keying regardless of their version (their bundles are republished in + /// place and cannot be reused across builds). + /// + public bool HasDepsDigests() => + depsFiles != null; + + /// + /// Resolves a platform-suffixed hash (e.g. bafk..._mac) to the verbatim digest-bearing manifest + /// entry (bafk..._<depsDigest>_mac) when the manifest lists one, or returns the input + /// unchanged when it doesn't (pre-v49 manifests, files without a digest). The lookup is + /// case-insensitive and the returned name carries the manifest's casing — the name that actually + /// exists on the case-sensitive CDN. + /// + public string GetHashWithDigest(string hash) + { + if (depsFiles == null) + return hash; + + string platformSuffix = PlatformUtils.GetCurrentPlatform(); + + if (platformSuffix.Length > 0 && !hash.EndsWith(platformSuffix, StringComparison.OrdinalIgnoreCase)) + return hash; + + return depsFiles.TryGetValue(hash[..^platformSuffix.Length], out string fileName) ? fileName : hash; + } + + /// + /// Same resolution as but keyed by the bare hash (no platform suffix). + /// + public bool TryGetFileNameWithDigest(string bareHash, out string fileName) { - if (DepsDigestKeyingEnabled && depsDigests != null && depsDigests.TryGetValue(hash, out digest!)) + if (depsFiles != null && depsFiles.TryGetValue(bareHash, out fileName!)) return true; - digest = string.Empty; + fileName = string.Empty; return false; } diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs index 3c1894e81b6..3e34e866731 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs @@ -3,11 +3,12 @@ namespace DCL.Ipfs public static class AssetBundleManifestVersionExtensions { /// - /// Composes the cache key for an asset bundle: hash@depsDigest when the manifest has a digest entry - /// for the bare hash, or just the hash when it doesn't. Used by upper-layer caches (GLTF container, etc.) - /// to differentiate two scenes that share an AB hash but resolve different dependency closures. + /// Composes the cache key for an asset bundle: the verbatim digest-bearing manifest file name when the + /// manifest has an entry for the bare hash, or just the hash when it doesn't. Used by upper-layer caches + /// (GLTF container, etc.) to differentiate two scenes that share an AB hash but resolve different + /// dependency closures. /// public static string ComposeCacheKey(this AssetBundleManifestVersion? manifest, string hash) => - manifest != null && manifest.TryGetDepsDigest(hash, out string digest) ? $"{hash}@{digest}" : hash; + manifest != null && manifest.TryGetFileNameWithDigest(hash, out string fileName) ? fileName : hash; } } From a279b5f0420c0c20548865b1080396be3deb290d Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 15:00:07 -0300 Subject: [PATCH 02/24] refactor: compose platform hash inside GetPlatformHashWithDigest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call sites that hold a bare hash (GLTF containers, ISS LOD assets, scene emotes) no longer append the platform suffix and null-check the manifest themselves — a single null-safe extension composes the suffixed hash and resolves it to the digest-bearing manifest entry, keyed directly by the bare hash (no append-then-strip round trip). GetHashWithDigest stays for the one caller with an already-suffixed hash (AB dependencies from bundle metadata). Co-Authored-By: Claude Fable 5 --- .../Intents/GetSceneEmoteFromRealmIntention.cs | 5 +---- .../Systems/PrepareGltfAssetLoadingSystem.cs | 16 ++++------------ .../EditModeTests/DepsDigestCacheKeyShould.cs | 16 ++++++++++++++++ .../DCL/LOD/Systems/ResolveISSLODSystem.cs | 11 ++--------- .../AssetBundleManifestVersionExtensions.cs | 14 ++++++++++++++ 5 files changed, 37 insertions(+), 25 deletions(-) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs index bacfe89b982..2aeac8c41a4 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs @@ -3,7 +3,6 @@ using CommunicationData.URLHelpers; using DCL.AvatarRendering.Loading.Components; using DCL.Ipfs; -using DCL.Utility; using ECS.Prioritization.Components; using ECS.StreamableLoading; using ECS.StreamableLoading.AssetBundles; @@ -61,11 +60,9 @@ public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitio { // Scene emotes are part of the scene's converted content — resolve to the manifest's digest-bearing // file name (v49+) so the URL and cache identity match the rest of the scene's ABs. - string platformHash = SceneAssetBundleManifestVersion.GetHashWithDigest(this.EmoteHash + PlatformUtils.GetCurrentPlatform()); - var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - platformHash, + SceneAssetBundleManifestVersion.GetPlatformHashWithDigest(this.EmoteHash), typeof(GameObject), assetBundleManifestVersion: SceneAssetBundleManifestVersion, parentEntityID: SceneId, diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs index 47e6ea4ac95..4ddda4bb436 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs @@ -2,7 +2,7 @@ using Arch.System; using Arch.SystemGroups; using DCL.Diagnostics; -using DCL.Utility; +using DCL.Ipfs; using ECS.Abstract; using ECS.StreamableLoading; using ECS.StreamableLoading.AssetBundles; @@ -69,17 +69,9 @@ private void Prepare(in Entity entity, ref GetGltfContainerAssetIntention intent if (loadRawGltf) World.Add(entity, GetGLTFIntention.Create(intention.Name, intention.Hash)); else - { - // For v49+ manifests the hash resolves to the digest-bearing file name - // (__) — the name that actually exists on the CDN and the sole - // identity used by every cache layer downstream. - string platformHash = $"{intention.Hash}{PlatformUtils.GetCurrentPlatform()}"; - - if (sceneData.SceneEntityDefinition.assetBundleManifestVersion is { } manifest) - platformHash = manifest.GetHashWithDigest(platformHash); - - World.Add(entity, GetAssetBundleIntention.Create(typeof(GameObject), platformHash, intention.Name)); - } + World.Add(entity, GetAssetBundleIntention.Create(typeof(GameObject), + sceneData.SceneEntityDefinition.assetBundleManifestVersion.GetPlatformHashWithDigest(intention.Hash), + intention.Name)); } public struct Options diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 1ea6e8fa78e..21031ba20f2 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -57,6 +57,22 @@ public void ResolveHashWithDigestCaseInsensitivelyToManifestCasing() Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}")); } + [Test] + public void ComposePlatformHashWithDigestFromBareHash() + { + string platform = PlatformUtils.GetCurrentPlatform(); + + AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); + + Assert.That(manifest.GetPlatformHashWithDigest(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + + Assert.That(manifest.GetPlatformHashWithDigest(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), + "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); + + AssetBundleManifestVersion? nullManifest = null; + Assert.That(nullManifest.GetPlatformHashWithDigest(HASH_A), Is.EqualTo($"{HASH_A}{platform}")); + } + [Test] public void ReportDepsDigestsOnlyWhenMapWasInjected() { diff --git a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs index ac4aef9d9c6..c848dd59ed8 100644 --- a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs @@ -5,7 +5,6 @@ using DCL.Ipfs; using DCL.LOD.Components; using DCL.Optimization.PerformanceBudgeting; -using DCL.Utility; using ECS.Abstract; using System.Collections.Generic; using ECS.Prioritization.Components; @@ -92,17 +91,11 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead continue; } - // Descriptor mode: fetch each asset's own bundle — must include the platform suffix and, for v49+, - // the deps digest to match the deployed AB filename. Resolving through the manifest also lands this + // Descriptor mode: fetch each asset's own bundle. Resolving through the manifest also lands this // promise in the same AssetBundleCache slot as the SDK runtime (which resolves identically in // PrepareGltfAssetLoadingSystem); a diverging Hash would race two LoadAssetBundleSystem flows for // the same physical bundle, which Unity refuses with "asset bundle already loaded". - string promiseHash = $"{entry.hash}{PlatformUtils.GetCurrentPlatform()}"; - - if (manifest != null) - promiseHash = manifest.GetHashWithDigest(promiseHash); - - var intent = GetAssetBundleIntention.FromHash(promiseHash, + var intent = GetAssetBundleIntention.FromHash(manifest.GetPlatformHashWithDigest(entry.hash), assetBundleManifestVersion: manifest, parentEntityID: sceneDefinition.Definition.id); diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs index 3e34e866731..806058bc496 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs @@ -1,7 +1,21 @@ +using DCL.Utility; + namespace DCL.Ipfs { public static class AssetBundleManifestVersionExtensions { + /// + /// Composes the platform-suffixed hash for a bare hash, resolved to the verbatim digest-bearing + /// manifest file name (<hash>_<depsDigest>_<platform>) when the manifest lists one — + /// the name that actually exists on the CDN and the sole identity used by every cache layer + /// downstream. Falls back to <hash>_<platform> otherwise (null or pre-v49 manifests, + /// files without a digest). + /// + public static string GetPlatformHashWithDigest(this AssetBundleManifestVersion? manifest, string bareHash) => + manifest != null && manifest.TryGetFileNameWithDigest(bareHash, out string fileName) + ? fileName + : $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; + /// /// Composes the cache key for an asset bundle: the verbatim digest-bearing manifest file name when the /// manifest has an entry for the bare hash, or just the hash when it doesn't. Used by upper-layer caches From 0b221200c78a9185f9262ad8e8e09dce45933e7c Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 16:40:51 -0300 Subject: [PATCH 03/24] refactor: build all AB request hashes through GetPlatformHashWithDigest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wearables, avatar emotes, thumbnails and LODs now compose their platform-suffixed hash through the same extension the scene paths use, so AB request-hash construction lives in a single place. Their manifests carry no deps map, so the output is unchanged (bare hash + platform suffix) — but if any of them ever ships digest-bearing file names, the URLs follow the manifest automatically. Co-Authored-By: Claude Fable 5 --- .../Emotes/Systems/Load/LoadEmotesByPointersSystem.cs | 3 +-- .../AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs | 4 ++-- .../Wearables/Helpers/WearablePolymorphicBehaviour.cs | 2 +- Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs | 6 +++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs index 56c4f391209..b780d059e3d 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs @@ -11,7 +11,6 @@ using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.PerformanceAndDiagnostics.Analytics; using DCL.SDKComponents.AudioSources; -using DCL.Utility; using DCL.WebRequests; using ECS.Prioritization.Components; using ECS.StreamableLoading.AssetBundles; @@ -239,7 +238,7 @@ private bool CreateAssetBundlePromiseIfRequired(IEmote component, in GetEmotesBy var promise = AssetBundlePromise.Create( World!, GetAssetBundleIntention.FromHash( - hash! + PlatformUtils.GetCurrentPlatform(), + component.DTO.assetBundleManifestVersion.GetPlatformHashWithDigest(hash!), typeof(GameObject), permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, diff --git a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs index 65a7a2a693e..2374f28be04 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs @@ -4,9 +4,9 @@ using Cysharp.Threading.Tasks; using DCL.AvatarRendering.Loading.Components; using DCL.Diagnostics; +using DCL.Ipfs; using DCL.Multiplayer.Connections.DecentralandUrls; using DCL.Optimization.Pools; -using DCL.Utility; using ECS; using ECS.Prioritization.Components; using ECS.StreamableLoading.AssetBundles; @@ -63,7 +63,7 @@ public static void CreateThumbnailABPromise( var promise = AssetBundlePromise.Create( world, GetAssetBundleIntention.FromHash( - hash: thumbnailPath.Value + PlatformUtils.GetCurrentPlatform(), + hash: assetBundleManifestVersion.GetPlatformHashWithDigest(thumbnailPath.Value), typeof(Texture2D), permittedSources: AssetSource.ALL, assetBundleManifestVersion: assetBundleManifestVersion, diff --git a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs index 3652fe02df0..68c7393d9e1 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs @@ -222,7 +222,7 @@ private static void CreatePromise( // An index is added to the promise to know to which slot of the WearableAssets it belongs to var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - hash + PlatformUtils.GetCurrentPlatform(), + wearable.DTO.assetBundleManifestVersion.GetPlatformHashWithDigest(hash), expectedObjectType, permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, diff --git a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs index da6363c1ac9..b03918b2796 100644 --- a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs @@ -86,14 +86,14 @@ private void StartLODPromise(ref SceneLODInfo sceneLODInfo, ref PartitionCompone // descriptor in None state — no ISS for this scene; fall through to legacy LOD. } - string platformLODKey = $"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}{PlatformUtils.GetCurrentPlatform()}"; + AssetBundleManifestVersion lodManifest = AssetBundleManifestVersion.CreateForLOD($"LOD/{level.ToString()}", "dummyDate"); var assetBundleIntention = GetAssetBundleIntention.FromHash( - platformLODKey, + lodManifest.GetPlatformHashWithDigest($"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), typeof(GameObject), permittedSources: AssetSource.ALL, customEmbeddedSubDirectory: LODUtils.LOD_EMBEDDED_SUBDIRECTORIES, - assetBundleManifestVersion: AssetBundleManifestVersion.CreateForLOD($"LOD/{level.ToString()}", "dummyDate"), + assetBundleManifestVersion: lodManifest, lookForDependencies: true ); From c7cacd4415bd17ea35fe4a7117b1ec9fc863f921 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 16:45:01 -0300 Subject: [PATCH 04/24] refactor: rename GetPlatformHashWithDigest to GetCdnRequestHash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method builds the exact hash requested from the CDN — the name now says so instead of describing its composition. Co-Authored-By: Claude Fable 5 --- .../Components/Intents/GetSceneEmoteFromRealmIntention.cs | 2 +- .../Emotes/Systems/Load/LoadEmotesByPointersSystem.cs | 2 +- .../Thumbnails/Utils/LoadThumbnailsUtils.cs | 2 +- .../Wearables/Helpers/WearablePolymorphicBehaviour.cs | 2 +- .../Asset/Systems/PrepareGltfAssetLoadingSystem.cs | 2 +- .../Tests/EditModeTests/DepsDigestCacheKeyShould.cs | 8 ++++---- Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs | 2 +- .../Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs | 2 +- .../AssetBundleManifestVersionExtensions.cs | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs index 2aeac8c41a4..eca73aec05a 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs @@ -62,7 +62,7 @@ public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitio // file name (v49+) so the URL and cache identity match the rest of the scene's ABs. var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - SceneAssetBundleManifestVersion.GetPlatformHashWithDigest(this.EmoteHash), + SceneAssetBundleManifestVersion.GetCdnRequestHash(this.EmoteHash), typeof(GameObject), assetBundleManifestVersion: SceneAssetBundleManifestVersion, parentEntityID: SceneId, diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs index b780d059e3d..e43cf5b9334 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs @@ -238,7 +238,7 @@ private bool CreateAssetBundlePromiseIfRequired(IEmote component, in GetEmotesBy var promise = AssetBundlePromise.Create( World!, GetAssetBundleIntention.FromHash( - component.DTO.assetBundleManifestVersion.GetPlatformHashWithDigest(hash!), + component.DTO.assetBundleManifestVersion.GetCdnRequestHash(hash!), typeof(GameObject), permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, diff --git a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs index 2374f28be04..8e0f873b78c 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs @@ -63,7 +63,7 @@ public static void CreateThumbnailABPromise( var promise = AssetBundlePromise.Create( world, GetAssetBundleIntention.FromHash( - hash: assetBundleManifestVersion.GetPlatformHashWithDigest(thumbnailPath.Value), + hash: assetBundleManifestVersion.GetCdnRequestHash(thumbnailPath.Value), typeof(Texture2D), permittedSources: AssetSource.ALL, assetBundleManifestVersion: assetBundleManifestVersion, diff --git a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs index 68c7393d9e1..0e6741ac013 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs @@ -222,7 +222,7 @@ private static void CreatePromise( // An index is added to the promise to know to which slot of the WearableAssets it belongs to var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - wearable.DTO.assetBundleManifestVersion.GetPlatformHashWithDigest(hash), + wearable.DTO.assetBundleManifestVersion.GetCdnRequestHash(hash), expectedObjectType, permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs index 4ddda4bb436..7c5bfb5549d 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs @@ -70,7 +70,7 @@ private void Prepare(in Entity entity, ref GetGltfContainerAssetIntention intent World.Add(entity, GetGLTFIntention.Create(intention.Name, intention.Hash)); else World.Add(entity, GetAssetBundleIntention.Create(typeof(GameObject), - sceneData.SceneEntityDefinition.assetBundleManifestVersion.GetPlatformHashWithDigest(intention.Hash), + sceneData.SceneEntityDefinition.assetBundleManifestVersion.GetCdnRequestHash(intention.Hash), intention.Name)); } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 21031ba20f2..1d862655316 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -58,19 +58,19 @@ public void ResolveHashWithDigestCaseInsensitivelyToManifestCasing() } [Test] - public void ComposePlatformHashWithDigestFromBareHash() + public void ComposeCdnRequestHashFromBareHash() { string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(manifest.GetPlatformHashWithDigest(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); - Assert.That(manifest.GetPlatformHashWithDigest(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), + Assert.That(manifest.GetCdnRequestHash(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); AssetBundleManifestVersion? nullManifest = null; - Assert.That(nullManifest.GetPlatformHashWithDigest(HASH_A), Is.EqualTo($"{HASH_A}{platform}")); + Assert.That(nullManifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}{platform}")); } [Test] diff --git a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs index c848dd59ed8..a05ce4c412f 100644 --- a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs @@ -95,7 +95,7 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead // promise in the same AssetBundleCache slot as the SDK runtime (which resolves identically in // PrepareGltfAssetLoadingSystem); a diverging Hash would race two LoadAssetBundleSystem flows for // the same physical bundle, which Unity refuses with "asset bundle already loaded". - var intent = GetAssetBundleIntention.FromHash(manifest.GetPlatformHashWithDigest(entry.hash), + var intent = GetAssetBundleIntention.FromHash(manifest.GetCdnRequestHash(entry.hash), assetBundleManifestVersion: manifest, parentEntityID: sceneDefinition.Definition.id); diff --git a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs index b03918b2796..ab96a08052b 100644 --- a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs @@ -89,7 +89,7 @@ private void StartLODPromise(ref SceneLODInfo sceneLODInfo, ref PartitionCompone AssetBundleManifestVersion lodManifest = AssetBundleManifestVersion.CreateForLOD($"LOD/{level.ToString()}", "dummyDate"); var assetBundleIntention = GetAssetBundleIntention.FromHash( - lodManifest.GetPlatformHashWithDigest($"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), + lodManifest.GetCdnRequestHash($"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), typeof(GameObject), permittedSources: AssetSource.ALL, customEmbeddedSubDirectory: LODUtils.LOD_EMBEDDED_SUBDIRECTORIES, diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs index 806058bc496..3c8c68808bf 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs @@ -11,7 +11,7 @@ public static class AssetBundleManifestVersionExtensions /// downstream. Falls back to <hash>_<platform> otherwise (null or pre-v49 manifests, /// files without a digest). /// - public static string GetPlatformHashWithDigest(this AssetBundleManifestVersion? manifest, string bareHash) => + public static string GetCdnRequestHash(this AssetBundleManifestVersion? manifest, string bareHash) => manifest != null && manifest.TryGetFileNameWithDigest(bareHash, out string fileName) ? fileName : $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; From 82d1e8a00d91e2be548dd7ad1a7f63dcbf3b7901 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 16:46:22 -0300 Subject: [PATCH 05/24] refactor: rename GetHashWithDigest to ResolveCdnRequestHash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with GetCdnRequestHash: both produce the hash requested from the CDN — Get composes it from a bare hash, Resolve rewrites an already platform-suffixed one (dependency hashes from AB metadata). Co-Authored-By: Claude Fable 5 --- .../AssetBundles/LoadAssetBundleSystem.cs | 2 +- .../EditModeTests/DepsDigestCacheKeyShould.cs | 14 +++++++------- .../AssetBundleManifestVersion.cs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs index 7ed862e5a5f..101863f38de 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs @@ -223,7 +223,7 @@ private async UniTask WaitForDependencyAsync( // we don't know the type of the dependency // Dependency hashes baked into AB metadata are digest-less — resolve them to the manifest's // digest-bearing file name so the URL and cache identity match the parent's scheme. - var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(assetBundleManifestVersion.GetHashWithDigest(hash), assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); + var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(assetBundleManifestVersion.ResolveCdnRequestHash(hash), assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); try { diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 1d862655316..2a625e19473 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -25,9 +25,9 @@ private static AssetBundleManifestVersion CreateV49Manifest(params string[] file } [Test] - public void ResolveHashWithDigestToVerbatimManifestEntry() + public void ResolveCdnRequestHashToVerbatimManifestEntry() { - // GetHashWithDigest strips the *current* platform suffix off the input, so the manifest entries must be + // ResolveCdnRequestHash strips the *current* platform suffix off the input, so the manifest entries must be // built with it for the test to be platform-agnostic. string platform = PlatformUtils.GetCurrentPlatform(); @@ -35,17 +35,17 @@ public void ResolveHashWithDigestToVerbatimManifestEntry() $"{HASH_LEGACY}{platform}", $"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(manifest.GetHashWithDigest($"{HASH_A}{platform}"), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(manifest.ResolveCdnRequestHash($"{HASH_A}{platform}"), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); - Assert.That(manifest.GetHashWithDigest($"{HASH_LEGACY}{platform}"), Is.EqualTo($"{HASH_LEGACY}{platform}"), + Assert.That(manifest.ResolveCdnRequestHash($"{HASH_LEGACY}{platform}"), Is.EqualTo($"{HASH_LEGACY}{platform}"), "Files listed without a digest must fall back to the input hash"); - Assert.That(manifest.GetHashWithDigest($"{HASH_B}{platform}"), Is.EqualTo($"{HASH_B}{platform}"), + Assert.That(manifest.ResolveCdnRequestHash($"{HASH_B}{platform}"), Is.EqualTo($"{HASH_B}{platform}"), "Hashes absent from the manifest must fall back to the input hash"); } [Test] - public void ResolveHashWithDigestCaseInsensitivelyToManifestCasing() + public void ResolveCdnRequestHashCaseInsensitivelyToManifestCasing() { string platform = PlatformUtils.GetCurrentPlatform(); @@ -53,7 +53,7 @@ public void ResolveHashWithDigestCaseInsensitivelyToManifestCasing() // The lookup is case-insensitive and returns the manifest's casing — the CDN is case-sensitive, // so the verbatim entry is the one that actually exists. - Assert.That(manifest.GetHashWithDigest($"QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV{platform}"), + Assert.That(manifest.ResolveCdnRequestHash($"QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV{platform}"), Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}")); } diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 9d148d56d7e..d7830d8b7b2 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -122,7 +122,7 @@ public bool HasDepsDigests() => /// case-insensitive and the returned name carries the manifest's casing — the name that actually /// exists on the case-sensitive CDN. /// - public string GetHashWithDigest(string hash) + public string ResolveCdnRequestHash(string hash) { if (depsFiles == null) return hash; @@ -136,7 +136,7 @@ public string GetHashWithDigest(string hash) } /// - /// Same resolution as but keyed by the bare hash (no platform suffix). + /// Same resolution as but keyed by the bare hash (no platform suffix). /// public bool TryGetFileNameWithDigest(string bareHash, out string fileName) { From bea0f21b58f70c21b16834150ae148a7c59caa52 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 16:53:36 -0300 Subject: [PATCH 06/24] style: condense multi-line comments to single lines Co-Authored-By: Claude Fable 5 --- .../GetSceneEmoteFromRealmIntention.cs | 3 +- .../AssetBundles/GetAssetBundleIntention.cs | 6 ++-- .../AssetBundles/LoadAssetBundleSystem.cs | 3 +- ...eAssetBundleLoadingParametersSystemBase.cs | 12 ++----- .../EditModeTests/DepsDigestCacheKeyShould.cs | 28 ++++++---------- .../DCL/LOD/Systems/ResolveISSLODSystem.cs | 5 +-- .../AssetBundleManifestVersion.cs | 32 +++---------------- .../AssetBundleManifestVersionExtensions.cs | 15 ++------- 8 files changed, 23 insertions(+), 81 deletions(-) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs index eca73aec05a..4f97e4080df 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs @@ -58,8 +58,7 @@ public readonly URN NewSceneEmoteURN() => public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitionComponent, URLSubdirectory? customStreamingSubdirectory, IEmote emote) { - // Scene emotes are part of the scene's converted content — resolve to the manifest's digest-bearing - // file name (v49+) so the URL and cache identity match the rest of the scene's ABs. + // Scene emotes are scene content — resolve to the digest-bearing name so URL and cache identity match the scene's ABs. var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( SceneAssetBundleManifestVersion.GetCdnRequestHash(this.EmoteHash), diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs index 0549b8fb8b9..0e8a5603213 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs @@ -66,8 +66,7 @@ internal GetAssetBundleIntention(CommonLoadingArguments commonArguments) : this( CommonArguments = commonArguments; } - // Hash alone identifies the bundle: v49+ hashes carry the deps digest inside the file name itself - // (__), so two dependency closures never share a Hash. + // Hash alone identifies the bundle: v49+ hashes carry the deps digest inside the file name, so two dependency closures never share a Hash. public bool Equals(GetAssetBundleIntention other) => StringComparer.OrdinalIgnoreCase.Equals(Hash, other.Hash); @@ -101,8 +100,7 @@ private DiskHashCompute() { } protected override void FillPayload(IHashKeyPayload keyPayload, in GetAssetBundleIntention asset) { - // v49+ hashes carry the deps digest inside the name, so the hash alone keys the on-disk file; - // digest-less hashes produce the same key as before this scheme, keeping legacy entries hitting. + // The hash alone keys the on-disk file (v49+ hashes embed the digest); digest-less hashes keep their legacy key so existing entries keep hitting. keyPayload.Put(asset.Hash ?? asset.Name!); } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs index 101863f38de..577bcf8d10b 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs @@ -221,8 +221,7 @@ private async UniTask WaitForDependencyAsync( { // Inherit partition from the parent promise // we don't know the type of the dependency - // Dependency hashes baked into AB metadata are digest-less — resolve them to the manifest's - // digest-bearing file name so the URL and cache identity match the parent's scheme. + // Dependency hashes from AB metadata are digest-less — resolve them so URL and cache identity match the parent's scheme. var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(assetBundleManifestVersion.ResolveCdnRequestHash(hash), assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); try diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index fc1c6bf3a20..bd7d1e341ad 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -67,12 +67,7 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifestVersion.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()); assetBundleIntention.CommonArguments = ca; - // v49+ hashes carry the deps digest inside the file name (__), so - // (version + hash) is unique per dependency closure. Dispatch on whether the manifest carries the - // deps map (only scene manifests do), not on whether this hash happens to carry a digest — an AB - // with a legacy 2-part name inside a mapped manifest would otherwise be keyed on buildDate, - // preventing cache sharing across CDN republishes. Wearables/emotes never carry the map and keep - // buildDate keying: their bundles are republished in place and cannot be reused across builds. + // Scene manifests carry the deps map and key on version+hash (the digest travels inside the hash); wearables/emotes never carry it and keep buildDate keying, as their bundles are republished in place. assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifestVersion.HasDepsDigests() ? ComputeHashV49(assetBundleIntention.Hash, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()) @@ -103,10 +98,7 @@ public static unsafe Hash128 ComputeHashLegacy(string hash, string buildDate) public static unsafe Hash128 ComputeHashV49(string hash, string version) { - // The per-file deps digest embedded in the hash replaces the buildDate sledgehammer that was previously - // used to invalidate the cache whenever a dependency might have changed. Keying on (version + hash) lets - // the cache stay shareable across CDN republishes when the dependency closure is unchanged. The delimiter - // prevents boundary collisions between version and hash. + // The digest embedded in the hash replaces buildDate-based invalidation, keeping the cache shareable across CDN republishes; the delimiter prevents version/hash boundary collisions. ReadOnlySpan hashSpan = hash.AsSpan(); ReadOnlySpan versionSpan = version.AsSpan(); diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 2a625e19473..78448f7534b 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -27,8 +27,7 @@ private static AssetBundleManifestVersion CreateV49Manifest(params string[] file [Test] public void ResolveCdnRequestHashToVerbatimManifestEntry() { - // ResolveCdnRequestHash strips the *current* platform suffix off the input, so the manifest entries must be - // built with it for the test to be platform-agnostic. + // ResolveCdnRequestHash strips the *current* platform suffix, so entries are built with it to stay platform-agnostic. string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest( @@ -51,8 +50,7 @@ public void ResolveCdnRequestHashCaseInsensitivelyToManifestCasing() AssetBundleManifestVersion manifest = CreateV49Manifest($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}"); - // The lookup is case-insensitive and returns the manifest's casing — the CDN is case-sensitive, - // so the verbatim entry is the one that actually exists. + // The lookup is case-insensitive and returns the manifest's casing — the name that exists on the case-sensitive CDN. Assert.That(manifest.ResolveCdnRequestHash($"QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV{platform}"), Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}")); } @@ -76,9 +74,7 @@ public void ComposeCdnRequestHashFromBareHash() [Test] public void ReportDepsDigestsOnlyWhenMapWasInjected() { - // The cache-key dispatch (v49 version+hash vs legacy buildDate+hash) hinges on this: only manifests - // that received files[] (scenes) report true. Wearable/emote manifests never fetch files[] and must - // keep buildDate keying — their bundles are republished in place and cannot be reused across builds. + // The cache-key dispatch hinges on this: only scene manifests receive files[]; wearables/emotes must keep buildDate keying. var withoutMap = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); Assert.That(withoutMap.HasDepsDigests(), Is.False); @@ -115,8 +111,7 @@ public void ComposeCacheKey_ReturnsVerbatimFileNameWhenPresent() [Test] public void TreatIntentionsWithDifferentDigestBearingHashesAsDistinct() { - // The digest is part of the Hash itself, so two dependency closures of the same bare hash never - // collide in the in-memory AB cache. + // The digest is part of the Hash itself, so two dependency closures of the same bare hash never collide. var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac"); @@ -149,8 +144,7 @@ public void ProduceDistinctDiskFilenamesForDifferentDigestBearingHashes() [Test] public void PreserveLegacyDiskFilenameForDigestLessHashes() { - // A digest-less hash must produce the same on-disk file name as before this scheme so existing - // cached entries keep hitting after upgrade. + // A digest-less hash must keep its pre-scheme on-disk file name so existing cached entries keep hitting. var legacy = GetAssetBundleIntention.FromHash(HASH_LEGACY); var alsoLegacy = GetAssetBundleIntention.FromHash(HASH_LEGACY); @@ -199,8 +193,7 @@ public void V49HashIsStableForSameInputs() [Test] public void V49HashDiffersWhenDigestBearingHashDiffers() { - // The digest travels inside the hash, so two dependency closures of the same bare hash produce - // different Unity-cache keys. + // The digest travels inside the hash, so two dependency closures produce different Unity-cache keys. Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_B}_mac", "v49"); @@ -219,8 +212,7 @@ public void V49HashDiffersWhenVersionDiffers() [Test] public void V49DelimiterPreventsBoundaryCollisions() { - // Without the delimiter, (version="v4", hash="9foo") and (version="v49", hash="foo") would produce - // the same byte stream. + // Without the delimiter, (version="v4", hash="9foo") and (version="v49", hash="foo") would produce the same byte stream. Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("9foo", "v4"); Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("foo", "v49"); @@ -239,8 +231,7 @@ public void LegacyHashIsStableForSameInputs() [Test] public void LegacyHashChangesWithBuildDate() { - // Pre-v49 ABs have no per-file freshness signal, so buildDate is the only thing that flushes the cache - // when dependencies might have changed — verify it is actually contributing to the key. + // Pre-v49 ABs have no per-file freshness signal — buildDate must contribute to the key to flush stale entries. Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-02"); @@ -250,8 +241,7 @@ public void LegacyHashChangesWithBuildDate() [Test] public void V49AndLegacyDoNotCollideForSameHash() { - // A v49+ AB with a digest-less name must not accidentally produce the same Hash128 as the legacy path - // for the same hash, even if the legacy buildDate happens to equal the v49 version string. + // A digest-less v49 hash must not collide with the legacy key even when buildDate equals the version string. Hash128 legacy = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_A, "v49"); Hash128 v49 = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(HASH_A, "v49"); diff --git a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs index a05ce4c412f..84e5891afde 100644 --- a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs @@ -91,10 +91,7 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead continue; } - // Descriptor mode: fetch each asset's own bundle. Resolving through the manifest also lands this - // promise in the same AssetBundleCache slot as the SDK runtime (which resolves identically in - // PrepareGltfAssetLoadingSystem); a diverging Hash would race two LoadAssetBundleSystem flows for - // the same physical bundle, which Unity refuses with "asset bundle already loaded". + // Resolving through the manifest lands this promise in the same AssetBundleCache slot as the SDK runtime — a diverging Hash races two loads of the same bundle ("asset bundle already loaded"). var intent = GetAssetBundleIntention.FromHash(manifest.GetCdnRequestHash(entry.hash), assetBundleManifestVersion: manifest, parentEntityID: sceneDefinition.Definition.id); diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index d7830d8b7b2..133c366e7db 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -44,11 +44,7 @@ public bool HasHashInPath() return HasHashInPathValue.Value; } - /// - /// True when the manifest's version is v49 or newer — i.e. when the per-file deps-digest scheme is - /// in use. This is purely a version check; an individual file may still carry no digest (v49 manifests - /// mix digest-bearing and legacy 2-part file names). - /// + /// True when the manifest's version is v49 or newer — a pure version check; individual files may still carry no digest. public bool SupportsDepsDigests() { SupportsDepsDigestsValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_SUPPORTS_DEPS_DIGEST; @@ -76,12 +72,7 @@ private static bool TryParseVersionNumber(string? version, out int parsed) return int.TryParse(version.AsSpan(1), out parsed); } - /// - /// Parses the manifest's files[] entries and stores, per bare hash, the verbatim - /// digest-bearing file name (<hash>_<depsDigest>_<platform>). Entries without a - /// valid digest segment (legacy 2-part names, which v49 manifests still mix in) are skipped and keep - /// resolving to their digest-less name. - /// + /// Stores, per bare hash, the verbatim digest-bearing file name (<hash>_<depsDigest>_<platform>) from the manifest's files[]; legacy 2-part names are skipped. public void InjectDepsDigests(string[]? files) { if (files == null || files.Length == 0) @@ -106,22 +97,11 @@ public void InjectDepsDigests(string[]? files) depsFiles = map; } - /// - /// True when this manifest carries the per-file deps map (injected from the manifest's files[]). - /// Only scene manifests get it — wearable/emote manifests never fetch files[], so they keep the - /// legacy buildDate-based cache keying regardless of their version (their bundles are republished in - /// place and cannot be reused across builds). - /// + /// True when the deps map was injected — only scene manifests fetch files[]; wearables/emotes never do and keep buildDate cache keying. public bool HasDepsDigests() => depsFiles != null; - /// - /// Resolves a platform-suffixed hash (e.g. bafk..._mac) to the verbatim digest-bearing manifest - /// entry (bafk..._<depsDigest>_mac) when the manifest lists one, or returns the input - /// unchanged when it doesn't (pre-v49 manifests, files without a digest). The lookup is - /// case-insensitive and the returned name carries the manifest's casing — the name that actually - /// exists on the case-sensitive CDN. - /// + /// Resolves a platform-suffixed hash to the verbatim digest-bearing manifest entry (case-insensitive, keeping the manifest's casing), or returns the input unchanged when unlisted. public string ResolveCdnRequestHash(string hash) { if (depsFiles == null) @@ -135,9 +115,7 @@ public string ResolveCdnRequestHash(string hash) return depsFiles.TryGetValue(hash[..^platformSuffix.Length], out string fileName) ? fileName : hash; } - /// - /// Same resolution as but keyed by the bare hash (no platform suffix). - /// + /// Same resolution as but keyed by the bare hash (no platform suffix). public bool TryGetFileNameWithDigest(string bareHash, out string fileName) { if (depsFiles != null && depsFiles.TryGetValue(bareHash, out fileName!)) diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs index 3c8c68808bf..a7ed46d36a1 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs @@ -4,24 +4,13 @@ namespace DCL.Ipfs { public static class AssetBundleManifestVersionExtensions { - /// - /// Composes the platform-suffixed hash for a bare hash, resolved to the verbatim digest-bearing - /// manifest file name (<hash>_<depsDigest>_<platform>) when the manifest lists one — - /// the name that actually exists on the CDN and the sole identity used by every cache layer - /// downstream. Falls back to <hash>_<platform> otherwise (null or pre-v49 manifests, - /// files without a digest). - /// + /// Builds the hash requested from the CDN: the verbatim digest-bearing manifest entry when listed, otherwise the platform-suffixed bare hash. public static string GetCdnRequestHash(this AssetBundleManifestVersion? manifest, string bareHash) => manifest != null && manifest.TryGetFileNameWithDigest(bareHash, out string fileName) ? fileName : $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; - /// - /// Composes the cache key for an asset bundle: the verbatim digest-bearing manifest file name when the - /// manifest has an entry for the bare hash, or just the hash when it doesn't. Used by upper-layer caches - /// (GLTF container, etc.) to differentiate two scenes that share an AB hash but resolve different - /// dependency closures. - /// + /// Composes the upper-layer cache key (GLTF container, etc.): the verbatim digest-bearing file name when listed, otherwise the bare hash. public static string ComposeCacheKey(this AssetBundleManifestVersion? manifest, string hash) => manifest != null && manifest.TryGetFileNameWithDigest(hash, out string fileName) ? fileName : hash; } From 1682f0ee79bc651b4209839be71bf6f293536921 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:03:36 -0300 Subject: [PATCH 07/24] refactor: unify convertedFiles and depsFiles into one cdnFiles map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both mapped a hash to the canonical CDN name — one from the manifest's files[] (digest-bearing entries), one from Qm entity content (casing fixes). They now share a single dictionary keyed by the digest-less platform-suffixed hash: - ResolveCdnRequestHash is a direct lookup (no more append-then-strip of the platform suffix) and fixes Qm casing too - CheckCasing is gone: every intention creation site already resolves through GetCdnRequestHash/ResolveCdnRequestHash, so the Prepare-time correction was redundant - HasDepsDigests is a dedicated bool so casing-only entries can never flip cache keying to the v49 scheme - InjectContent uses TryAdd so digest entries win regardless of injection order Co-Authored-By: Claude Fable 5 --- ...eAssetBundleLoadingParametersSystemBase.cs | 3 +- .../EditModeTests/DepsDigestCacheKeyShould.cs | 39 +++++++++- .../AssetBundleManifestVersion.cs | 78 ++++++------------- .../AssetBundleManifestVersionExtensions.cs | 15 ++-- 4 files changed, 72 insertions(+), 63 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index bd7d1e341ad..209d33d1bed 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -63,7 +63,8 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.Attempts = StreamableLoadingDefaults.ATTEMPTS_COUNT; ca.Timeout = StreamableLoadingDefaults.TIMEOUT; ca.CurrentSource = AssetSource.WEB; - assetBundleIntention.Hash = assetBundleIntention.AssetBundleManifestVersion.CheckCasing(assetBundleIntention.Hash); + + // Hash was already resolved to the canonical CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash/ResolveCdnRequestHash. ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifestVersion.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()); assetBundleIntention.CommonArguments = ca; diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 78448f7534b..ad9e76bf90e 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -103,9 +103,44 @@ public void ComposeCacheKey_FallsBackToBareHashWhenNoDigest() [Test] public void ComposeCacheKey_ReturnsVerbatimFileNameWhenPresent() { - AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); + string platform = PlatformUtils.GetCurrentPlatform(); + AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); + + Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + } + + [Test] + public void ResolveQmContentCasingThroughTheSameMap() + { + string platform = PlatformUtils.GetCurrentPlatform(); + var manifest = AssetBundleManifestVersion.CreateFromFallback("v35", "2026-05-01"); - Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}_mac")); + manifest.InjectContent("Qmf7DaJZRygoayfNn5Jq6QAykrhFpQUr2us2VFvjREiajk", + new[] { new ContentDefinition { file = "model.glb", hash = "QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV" } }); + + Assert.That(manifest.HasDepsDigests(), Is.False, "Content casing entries must not switch cache keying to the v49 scheme"); + + Assert.That(manifest.ResolveCdnRequestHash($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv{platform}"), + Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv{platform}").IgnoreCase); + } + + [Test] + public void PreferDigestEntriesOverContentCasingEntries() + { + string platform = PlatformUtils.GetCurrentPlatform(); + const string QM_ENTITY_ID = "Qmf7DaJZRygoayfNn5Jq6QAykrhFpQUr2us2VFvjREiajk"; + const string QM_HASH = "qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv"; + + // Regardless of injection order, the digest-bearing manifest entry wins over the casing entry. + var contentFirst = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + contentFirst.InjectContent(QM_ENTITY_ID, new[] { new ContentDefinition { file = "model.glb", hash = QM_HASH } }); + contentFirst.InjectDepsDigests(new[] { $"{QM_HASH}_{DIGEST_A}{platform}" }); + Assert.That(contentFirst.ResolveCdnRequestHash($"{QM_HASH}{platform}"), Is.EqualTo($"{QM_HASH}_{DIGEST_A}{platform}")); + + var digestsFirst = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + digestsFirst.InjectDepsDigests(new[] { $"{QM_HASH}_{DIGEST_A}{platform}" }); + digestsFirst.InjectContent(QM_ENTITY_ID, new[] { new ContentDefinition { file = "model.glb", hash = QM_HASH } }); + Assert.That(digestsFirst.ResolveCdnRequestHash($"{QM_HASH}{platform}"), Is.EqualTo($"{QM_HASH}_{DIGEST_A}{platform}")); } [Test] diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 133c366e7db..a3e693d51ff 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -33,10 +33,9 @@ public class AssetBundleManifestVersion public bool IsLSDAsset; public AssetBundleManifestVersionPerPlatform? assets; - private HashSet? convertedFiles; - - //Bare hash → verbatim manifest file name (__), see InjectDepsDigests - private IReadOnlyDictionary? depsFiles; + //Digest-less platform-suffixed hash → canonical CDN file name; fed by InjectDepsDigests (digest-bearing names) and InjectContent (Qm casing fixes). + private Dictionary? cdnFiles; + private bool hasDepsDigests; public bool HasHashInPath() { @@ -72,16 +71,10 @@ private static bool TryParseVersionNumber(string? version, out int parsed) return int.TryParse(version.AsSpan(1), out parsed); } - /// Stores, per bare hash, the verbatim digest-bearing file name (<hash>_<depsDigest>_<platform>) from the manifest's files[]; legacy 2-part names are skipped. + /// Stores, keyed by the digest-less platform-suffixed hash, the verbatim digest-bearing file name (<hash>_<depsDigest>_<platform>) from the manifest's files[]; legacy 2-part names are skipped. public void InjectDepsDigests(string[]? files) { - if (files == null || files.Length == 0) - { - depsFiles = null; - return; - } - - Dictionary? map = null; + if (files == null || files.Length == 0) return; foreach (string file in files) { @@ -90,35 +83,24 @@ public void InjectDepsDigests(string[]? files) string[] parts = file.Split(FILE_NAME_SEPARATOR, 3); if (parts.Length < 3) continue; - map ??= new Dictionary(new UrlHashComparer()); - map[parts[0]] = file; + cdnFiles ??= new Dictionary(new UrlHashComparer()); + cdnFiles[$"{parts[0]}_{parts[2]}"] = file; + hasDepsDigests = true; } - - depsFiles = map; } - /// True when the deps map was injected — only scene manifests fetch files[]; wearables/emotes never do and keep buildDate cache keying. + /// True when digest-bearing files were injected — only scene manifests fetch files[]; wearables/emotes never do and keep buildDate cache keying. public bool HasDepsDigests() => - depsFiles != null; + hasDepsDigests; - /// Resolves a platform-suffixed hash to the verbatim digest-bearing manifest entry (case-insensitive, keeping the manifest's casing), or returns the input unchanged when unlisted. - public string ResolveCdnRequestHash(string hash) - { - if (depsFiles == null) - return hash; - - string platformSuffix = PlatformUtils.GetCurrentPlatform(); - - if (platformSuffix.Length > 0 && !hash.EndsWith(platformSuffix, StringComparison.OrdinalIgnoreCase)) - return hash; - - return depsFiles.TryGetValue(hash[..^platformSuffix.Length], out string fileName) ? fileName : hash; - } + /// Resolves a platform-suffixed hash to the canonical CDN file name — digest-bearing and correctly cased when known (case-insensitive lookup) — or returns the input unchanged when unlisted. + public string ResolveCdnRequestHash(string hash) => + TryResolveCdnRequestHash(hash, out string fileName) ? fileName : hash; - /// Same resolution as but keyed by the bare hash (no platform suffix). - public bool TryGetFileNameWithDigest(string bareHash, out string fileName) + /// Same resolution as , reporting whether the manifest knows the file. + public bool TryResolveCdnRequestHash(string hash, out string fileName) { - if (depsFiles != null && depsFiles.TryGetValue(bareHash, out fileName!)) + if (cdnFiles != null && cdnFiles.TryGetValue(hash, out fileName!)) return true; fileName = string.Empty; @@ -206,17 +188,6 @@ public static AssetBundleManifestVersion CreateForLOD(string assetBundleManifest return assetBundleManifestVersion; } - public string CheckCasing(string inputHash) - { - if (convertedFiles == null || convertedFiles.Count == 0) - return inputHash; - - if (convertedFiles.TryGetValue(inputHash, out string convertedFile)) - return convertedFile; - - return inputHash; - } - public void InjectContent(string entityID, ContentDefinition[] entityDefinitionContent) { // TODO (JUANI): hack, for older Qm. Doesnt happen with bafk because they are all lowercase @@ -230,15 +201,16 @@ public void InjectContent(string entityID, ContentDefinition[] entityDefinitionC // Maybe one day, when `Qm` deployments dont exist anymore, this method can be removed if (!AssetBundleManifestHelper.IsQmEntity(entityID)) return; - convertedFiles = new HashSet(new UrlHashComparer()); - - if (IPlatform.DEFAULT.Is(IPlatform.Kind.Mac)) - for (var i = 0; i < entityDefinitionContent.Length; i++) - convertedFiles.Add($"{entityDefinitionContent[i].hash.ToLowerInvariant()}" + PlatformUtils.GetCurrentPlatform()); + cdnFiles ??= new Dictionary(new UrlHashComparer()); + string platformSuffix = PlatformUtils.GetCurrentPlatform(); + bool lowerCase = IPlatform.DEFAULT.Is(IPlatform.Kind.Mac); - if (IPlatform.DEFAULT.Is(IPlatform.Kind.Windows)) - for (var i = 0; i < entityDefinitionContent.Length; i++) - convertedFiles.Add($"{entityDefinitionContent[i].hash}" + PlatformUtils.GetCurrentPlatform()); + // TryAdd so digest-bearing entries from InjectDepsDigests always win, regardless of injection order. + for (var i = 0; i < entityDefinitionContent.Length; i++) + { + string fileName = (lowerCase ? entityDefinitionContent[i].hash.ToLowerInvariant() : entityDefinitionContent[i].hash) + platformSuffix; + cdnFiles.TryAdd(fileName, fileName); + } } } diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs index a7ed46d36a1..2e9133c0c90 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs @@ -4,14 +4,15 @@ namespace DCL.Ipfs { public static class AssetBundleManifestVersionExtensions { - /// Builds the hash requested from the CDN: the verbatim digest-bearing manifest entry when listed, otherwise the platform-suffixed bare hash. - public static string GetCdnRequestHash(this AssetBundleManifestVersion? manifest, string bareHash) => - manifest != null && manifest.TryGetFileNameWithDigest(bareHash, out string fileName) - ? fileName - : $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; + /// Builds the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. + public static string GetCdnRequestHash(this AssetBundleManifestVersion? manifest, string bareHash) + { + string platformHash = $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; + return manifest?.ResolveCdnRequestHash(platformHash) ?? platformHash; + } - /// Composes the upper-layer cache key (GLTF container, etc.): the verbatim digest-bearing file name when listed, otherwise the bare hash. + /// Composes the upper-layer cache key (GLTF container, etc.): the canonical CDN file name when known, otherwise the bare hash. public static string ComposeCacheKey(this AssetBundleManifestVersion? manifest, string hash) => - manifest != null && manifest.TryGetFileNameWithDigest(hash, out string fileName) ? fileName : hash; + manifest != null && manifest.TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; } } From 567434360f37b37b7326d69c969ddd6c20c2288c Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:07:29 -0300 Subject: [PATCH 08/24] refactor: fold extension methods into AssetBundleManifestVersion GetCdnRequestHash and ComposeCacheKey move from the extensions class into AssetBundleManifestVersion as static methods taking the nullable manifest, keeping the null handling and platform-suffix composition in one place. The extensions file is deleted. Co-Authored-By: Claude Fable 5 --- .../Intents/GetSceneEmoteFromRealmIntention.cs | 2 +- .../Systems/Load/LoadEmotesByPointersSystem.cs | 2 +- .../Thumbnails/Utils/LoadThumbnailsUtils.cs | 2 +- .../Helpers/WearablePolymorphicBehaviour.cs | 2 +- .../Systems/PrepareGltfAssetLoadingSystem.cs | 2 +- .../EditModeTests/DepsDigestCacheKeyShould.cs | 14 +++++++------- .../DCL/LOD/Systems/ResolveISSLODSystem.cs | 6 +++--- .../LOD/Systems/UpdateSceneLODInfoSystem.cs | 2 +- .../AssetBundleManifestVersion.cs | 11 +++++++++++ .../AssetBundleManifestVersionExtensions.cs | 18 ------------------ ...ssetBundleManifestVersionExtensions.cs.meta | 11 ----------- .../PluginSystem/World/GltfContainerPlugin.cs | 4 ++-- 12 files changed, 29 insertions(+), 47 deletions(-) delete mode 100644 Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs delete mode 100644 Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs.meta diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs index 4f97e4080df..3d67ab9996e 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs @@ -61,7 +61,7 @@ public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitio // Scene emotes are scene content — resolve to the digest-bearing name so URL and cache identity match the scene's ABs. var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - SceneAssetBundleManifestVersion.GetCdnRequestHash(this.EmoteHash), + AssetBundleManifestVersion.GetCdnRequestHash(SceneAssetBundleManifestVersion, this.EmoteHash), typeof(GameObject), assetBundleManifestVersion: SceneAssetBundleManifestVersion, parentEntityID: SceneId, diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs index e43cf5b9334..9a3ca0c1fd3 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs @@ -238,7 +238,7 @@ private bool CreateAssetBundlePromiseIfRequired(IEmote component, in GetEmotesBy var promise = AssetBundlePromise.Create( World!, GetAssetBundleIntention.FromHash( - component.DTO.assetBundleManifestVersion.GetCdnRequestHash(hash!), + AssetBundleManifestVersion.GetCdnRequestHash(component.DTO.assetBundleManifestVersion, hash!), typeof(GameObject), permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, diff --git a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs index 8e0f873b78c..84031494b22 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs @@ -63,7 +63,7 @@ public static void CreateThumbnailABPromise( var promise = AssetBundlePromise.Create( world, GetAssetBundleIntention.FromHash( - hash: assetBundleManifestVersion.GetCdnRequestHash(thumbnailPath.Value), + hash: AssetBundleManifestVersion.GetCdnRequestHash(assetBundleManifestVersion, thumbnailPath.Value), typeof(Texture2D), permittedSources: AssetSource.ALL, assetBundleManifestVersion: assetBundleManifestVersion, diff --git a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs index 0e6741ac013..0b5fd78ce71 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs @@ -222,7 +222,7 @@ private static void CreatePromise( // An index is added to the promise to know to which slot of the WearableAssets it belongs to var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - wearable.DTO.assetBundleManifestVersion.GetCdnRequestHash(hash), + AssetBundleManifestVersion.GetCdnRequestHash(wearable.DTO.assetBundleManifestVersion, hash), expectedObjectType, permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs index 7c5bfb5549d..e8c50cb27ef 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs @@ -70,7 +70,7 @@ private void Prepare(in Entity entity, ref GetGltfContainerAssetIntention intent World.Add(entity, GetGLTFIntention.Create(intention.Name, intention.Hash)); else World.Add(entity, GetAssetBundleIntention.Create(typeof(GameObject), - sceneData.SceneEntityDefinition.assetBundleManifestVersion.GetCdnRequestHash(intention.Hash), + AssetBundleManifestVersion.GetCdnRequestHash(sceneData.SceneEntityDefinition.assetBundleManifestVersion, intention.Hash), intention.Name)); } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index ad9e76bf90e..b6c9fc669ad 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -27,7 +27,7 @@ private static AssetBundleManifestVersion CreateV49Manifest(params string[] file [Test] public void ResolveCdnRequestHashToVerbatimManifestEntry() { - // ResolveCdnRequestHash strips the *current* platform suffix, so entries are built with it to stay platform-agnostic. + // The map is keyed by the *current* platform-suffixed hash, so entries are built with it to stay platform-agnostic. string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest( @@ -62,13 +62,13 @@ public void ComposeCdnRequestHashFromBareHash() AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(AssetBundleManifestVersion.GetCdnRequestHash(manifest, HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); - Assert.That(manifest.GetCdnRequestHash(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), + Assert.That(AssetBundleManifestVersion.GetCdnRequestHash(manifest, HASH_B), Is.EqualTo($"{HASH_B}{platform}"), "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); AssetBundleManifestVersion? nullManifest = null; - Assert.That(nullManifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}{platform}")); + Assert.That(AssetBundleManifestVersion.GetCdnRequestHash(nullManifest, HASH_A), Is.EqualTo($"{HASH_A}{platform}")); } [Test] @@ -90,14 +90,14 @@ public void ReportDepsDigestsOnlyWhenMapWasInjected() public void ComposeCacheKey_FallsBackToBareHashWhenManifestIsNull() { AssetBundleManifestVersion? manifest = null; - Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo(HASH_A)); + Assert.That(AssetBundleManifestVersion.ComposeCacheKey(manifest, HASH_A), Is.EqualTo(HASH_A)); } [Test] public void ComposeCacheKey_FallsBackToBareHashWhenNoDigest() { AssetBundleManifestVersion manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo(HASH_A)); + Assert.That(AssetBundleManifestVersion.ComposeCacheKey(manifest, HASH_A), Is.EqualTo(HASH_A)); } [Test] @@ -106,7 +106,7 @@ public void ComposeCacheKey_ReturnsVerbatimFileNameWhenPresent() string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(AssetBundleManifestVersion.ComposeCacheKey(manifest, HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); } [Test] diff --git a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs index 84e5891afde..f8ff42dfa58 100644 --- a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs @@ -76,10 +76,10 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead { ISSDescriptorAsset entry = assets[i]; - // The GLTF container cache is keyed by the digest-bearing file name (see AssetBundleManifestVersionExtensions.ComposeCacheKey). + // The GLTF container cache is keyed by the canonical CDN file name (see AssetBundleManifestVersion.ComposeCacheKey). // Looking up by bare hash misses any bridged entry the SDK runtime left behind, so the LOD spawns a // second instance of an asset that's already resident — that's the visible "double" overlap. - string cacheKey = manifest.ComposeCacheKey(entry.hash); + string cacheKey = AssetBundleManifestVersion.ComposeCacheKey(manifest, entry.hash); if (gltfCache.TryGet(cacheKey, out var asset)) { @@ -92,7 +92,7 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead } // Resolving through the manifest lands this promise in the same AssetBundleCache slot as the SDK runtime — a diverging Hash races two loads of the same bundle ("asset bundle already loaded"). - var intent = GetAssetBundleIntention.FromHash(manifest.GetCdnRequestHash(entry.hash), + var intent = GetAssetBundleIntention.FromHash(AssetBundleManifestVersion.GetCdnRequestHash(manifest, entry.hash), assetBundleManifestVersion: manifest, parentEntityID: sceneDefinition.Definition.id); diff --git a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs index ab96a08052b..797e748ff19 100644 --- a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs @@ -89,7 +89,7 @@ private void StartLODPromise(ref SceneLODInfo sceneLODInfo, ref PartitionCompone AssetBundleManifestVersion lodManifest = AssetBundleManifestVersion.CreateForLOD($"LOD/{level.ToString()}", "dummyDate"); var assetBundleIntention = GetAssetBundleIntention.FromHash( - lodManifest.GetCdnRequestHash($"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), + AssetBundleManifestVersion.GetCdnRequestHash(lodManifest, $"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), typeof(GameObject), permittedSources: AssetSource.ALL, customEmbeddedSubDirectory: LODUtils.LOD_EMBEDDED_SUBDIRECTORIES, diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index a3e693d51ff..1addf6019b6 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -93,6 +93,17 @@ public void InjectDepsDigests(string[]? files) public bool HasDepsDigests() => hasDepsDigests; + /// Builds the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. + public static string GetCdnRequestHash(AssetBundleManifestVersion? manifest, string bareHash) + { + string platformHash = $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; + return manifest?.ResolveCdnRequestHash(platformHash) ?? platformHash; + } + + /// Composes the upper-layer cache key (GLTF container, etc.): the canonical CDN file name when known, otherwise the bare hash. + public static string ComposeCacheKey(AssetBundleManifestVersion? manifest, string hash) => + manifest != null && manifest.TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; + /// Resolves a platform-suffixed hash to the canonical CDN file name — digest-bearing and correctly cased when known (case-insensitive lookup) — or returns the input unchanged when unlisted. public string ResolveCdnRequestHash(string hash) => TryResolveCdnRequestHash(hash, out string fileName) ? fileName : hash; diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs deleted file mode 100644 index 2e9133c0c90..00000000000 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using DCL.Utility; - -namespace DCL.Ipfs -{ - public static class AssetBundleManifestVersionExtensions - { - /// Builds the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. - public static string GetCdnRequestHash(this AssetBundleManifestVersion? manifest, string bareHash) - { - string platformHash = $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; - return manifest?.ResolveCdnRequestHash(platformHash) ?? platformHash; - } - - /// Composes the upper-layer cache key (GLTF container, etc.): the canonical CDN file name when known, otherwise the bare hash. - public static string ComposeCacheKey(this AssetBundleManifestVersion? manifest, string hash) => - manifest != null && manifest.TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; - } -} diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs.meta b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs.meta deleted file mode 100644 index e92dcd04ef1..00000000000 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersionExtensions.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b4d0c335cc9ed810663c85f25335a904 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs index a25d75efe2e..e6feb978b6c 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs @@ -120,10 +120,10 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder, // GLTF Container // Bridge GLTF cache keying to the AB layer without leaking AB symbols into LoadGltfContainerSystem: - // when the scene's manifest has a deps digest for the hash, the key becomes "hash@digest", else the bare hash. + // when the scene's manifest knows the hash, the key becomes the canonical CDN file name, else the bare hash. var sceneData = sharedDependencies.SceneData; LoadGltfContainerSystem.InjectToWorld(ref builder, buffer, sceneData, sharedDependencies.EntityCollidersSceneCache, - hash => sceneData.SceneEntityDefinition.assetBundleManifestVersion.ComposeCacheKey(hash)); + hash => AssetBundleManifestVersion.ComposeCacheKey(sceneData.SceneEntityDefinition.assetBundleManifestVersion, hash)); FinalizeGltfContainerLoadingSystem.InjectToWorld(ref builder, persistentEntities.SceneRoot, globalDeps.FrameTimeBudget, sharedDependencies.EntityCollidersSceneCache, sharedDependencies.SceneData, buffer); From ade3952e074c8f12b296c4289ae478f5c83e7fad Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:10:09 -0300 Subject: [PATCH 09/24] Inling --- .../DCL/NetworkDefinitions/AssetBundleManifestVersion.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 1addf6019b6..f9d6afffa29 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -94,11 +94,8 @@ public bool HasDepsDigests() => hasDepsDigests; /// Builds the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. - public static string GetCdnRequestHash(AssetBundleManifestVersion? manifest, string bareHash) - { - string platformHash = $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; - return manifest?.ResolveCdnRequestHash(platformHash) ?? platformHash; - } + public static string GetCdnRequestHash(AssetBundleManifestVersion? manifest, string bareHash) => + manifest?.ResolveCdnRequestHash(bareHash) ?? $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; /// Composes the upper-layer cache key (GLTF container, etc.): the canonical CDN file name when known, otherwise the bare hash. public static string ComposeCacheKey(AssetBundleManifestVersion? manifest, string hash) => @@ -109,7 +106,7 @@ public string ResolveCdnRequestHash(string hash) => TryResolveCdnRequestHash(hash, out string fileName) ? fileName : hash; /// Same resolution as , reporting whether the manifest knows the file. - public bool TryResolveCdnRequestHash(string hash, out string fileName) + bool TryResolveCdnRequestHash(string hash, out string fileName) { if (cdnFiles != null && cdnFiles.TryGetValue(hash, out fileName!)) return true; From 46ca877c7dcaa08ef654bcc7499018213d3522a3 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:22:17 -0300 Subject: [PATCH 10/24] refactor: make GetAssetBundleIntention.AssetBundleManifestVersion non-nullable An AB can never be requested without a manifest, so the intention now requires one at creation instead of tolerating null until Prepare time: - Create/FromHash take the manifest (and Create the parentEntityID), so the scene PrepareAssetBundleLoadingParametersSystem no longer injects them per frame and lost its ISceneData dependency - TrimmedEntityDefinitionBase exposes AssetBundleManifestVersionOrFailed: the nullable wire-format field stays, but consumers get the failed sentinel the pipeline already treats as a dead end - the null-manifest error check in PrepareCommonArguments collapses to the failed-manifest check - GetCdnRequestHash / ComposeCacheKey become instance methods (callers always hold a manifest now), also restoring the platform suffix in GetCdnRequestHash's lookup - thumbnails with no manifest now fall back to the content-server texture path instead of spawning an AB promise doomed to fail Co-Authored-By: Claude Fable 5 --- .../GetSceneEmoteFromRealmIntention.cs | 4 +- .../Load/LoadEmotesByPointersSystem.cs | 6 ++- .../Thumbnails/Utils/LoadThumbnailsUtils.cs | 11 +++--- .../Helpers/WearablePolymorphicBehaviour.cs | 6 ++- .../Systems/PrepareGltfAssetLoadingSystem.cs | 10 ++++- .../AssetBundles/GetAssetBundleIntention.cs | 17 ++++---- .../AssetBundles/LoadAssetBundleSystem.cs | 8 ++-- ...epareAssetBundleLoadingParametersSystem.cs | 11 +----- ...eAssetBundleLoadingParametersSystemBase.cs | 2 +- .../EditModeTests/DepsDigestCacheKeyShould.cs | 39 ++++++++++--------- .../LoadAssetBundlePartialSystemShould.cs | 3 +- .../DCL/LOD/Systems/ResolveISSLODSystem.cs | 6 +-- .../LOD/Systems/UpdateSceneLODInfoSystem.cs | 4 +- .../InstantiateSceneLODInfoSystemShould.cs | 4 +- .../AssetBundleManifestVersion.cs | 8 ++-- .../EntityDefinitionBase.cs | 4 ++ .../PluginSystem/World/AssetBundlesPlugin.cs | 2 +- .../PluginSystem/World/GltfContainerPlugin.cs | 2 +- 18 files changed, 78 insertions(+), 69 deletions(-) diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs index 3d67ab9996e..32b833c1241 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Components/Intents/GetSceneEmoteFromRealmIntention.cs @@ -61,9 +61,9 @@ public void CreateAndAddPromiseToWorld(World world, IPartitionComponent partitio // Scene emotes are scene content — resolve to the digest-bearing name so URL and cache identity match the scene's ABs. var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - AssetBundleManifestVersion.GetCdnRequestHash(SceneAssetBundleManifestVersion, this.EmoteHash), + SceneAssetBundleManifestVersion.GetCdnRequestHash(this.EmoteHash), + SceneAssetBundleManifestVersion, typeof(GameObject), - assetBundleManifestVersion: SceneAssetBundleManifestVersion, parentEntityID: SceneId, permittedSources: this.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory.Value, diff --git a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs index 9a3ca0c1fd3..2f1b7c09486 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Emotes/Systems/Load/LoadEmotesByPointersSystem.cs @@ -235,15 +235,17 @@ private bool CreateAssetBundlePromiseIfRequired(IEmote component, in GetEmotesBy if (component.AssetResults[intention.BodyShape] == null) { // The resolution of the AB promise will be finalized by FinalizeEmoteAssetBundleSystem + AssetBundleManifestVersion abManifest = component.DTO.AssetBundleManifestVersionOrFailed; + var promise = AssetBundlePromise.Create( World!, GetAssetBundleIntention.FromHash( - AssetBundleManifestVersion.GetCdnRequestHash(component.DTO.assetBundleManifestVersion, hash!), + abManifest.GetCdnRequestHash(hash!), + abManifest, typeof(GameObject), permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, cancellationTokenSource: intention.CancellationTokenSource, - assetBundleManifestVersion: component.DTO.assetBundleManifestVersion, parentEntityID: component.DTO.id ), partitionComponent diff --git a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs index 84031494b22..0e77f87cb61 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Thumbnails/Utils/LoadThumbnailsUtils.cs @@ -47,8 +47,9 @@ public static void CreateThumbnailABPromise( return; } - var assetBundleManifestVersion = attachment.GetAssetBundleManifestVersion(); - if (assetBundleManifestVersion != null && (assetBundleManifestVersion.IsLSDAsset || assetBundleManifestVersion.assetBundleManifestRequestFailed)) + AssetBundleManifestVersion? assetBundleManifestVersion = attachment.GetAssetBundleManifestVersion(); + + if (assetBundleManifestVersion == null || assetBundleManifestVersion.IsLSDAsset || assetBundleManifestVersion.assetBundleManifestRequestFailed) { ReportHub.Log( ReportCategory.THUMBNAILS, @@ -63,10 +64,10 @@ public static void CreateThumbnailABPromise( var promise = AssetBundlePromise.Create( world, GetAssetBundleIntention.FromHash( - hash: AssetBundleManifestVersion.GetCdnRequestHash(assetBundleManifestVersion, thumbnailPath.Value), - typeof(Texture2D), - permittedSources: AssetSource.ALL, + hash: assetBundleManifestVersion.GetCdnRequestHash(thumbnailPath.Value), assetBundleManifestVersion: assetBundleManifestVersion, + expectedAssetType: typeof(Texture2D), + permittedSources: AssetSource.ALL, parentEntityID: attachment.GetEntityId(), cancellationTokenSource: cancellationTokenSource ?? new CancellationTokenSource() ), diff --git a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs index 0b5fd78ce71..6859e99c45a 100644 --- a/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs +++ b/Explorer/Assets/DCL/AvatarRendering/Wearables/Helpers/WearablePolymorphicBehaviour.cs @@ -219,14 +219,16 @@ private static void CreatePromise( } else { + AssetBundleManifestVersion abManifest = wearable.DTO.AssetBundleManifestVersionOrFailed; + // An index is added to the promise to know to which slot of the WearableAssets it belongs to var promise = AssetBundlePromise.Create(world, GetAssetBundleIntention.FromHash( - AssetBundleManifestVersion.GetCdnRequestHash(wearable.DTO.assetBundleManifestVersion, hash), + abManifest.GetCdnRequestHash(hash), + abManifest, expectedObjectType, permittedSources: intention.PermittedSources, customEmbeddedSubDirectory: customStreamingSubdirectory, - assetBundleManifestVersion: wearable.DTO.assetBundleManifestVersion, parentEntityID: wearable.DTO.id, cancellationTokenSource: intention.CancellationTokenSource), partitionComponent); diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs index e8c50cb27ef..478c326d4b1 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/GLTFContainer/Asset/Systems/PrepareGltfAssetLoadingSystem.cs @@ -69,9 +69,15 @@ private void Prepare(in Entity entity, ref GetGltfContainerAssetIntention intent if (loadRawGltf) World.Add(entity, GetGLTFIntention.Create(intention.Name, intention.Hash)); else + { + AssetBundleManifestVersion abManifest = sceneData.SceneEntityDefinition.AssetBundleManifestVersionOrFailed; + World.Add(entity, GetAssetBundleIntention.Create(typeof(GameObject), - AssetBundleManifestVersion.GetCdnRequestHash(sceneData.SceneEntityDefinition.assetBundleManifestVersion, intention.Hash), - intention.Name)); + abManifest.GetCdnRequestHash(intention.Hash), + intention.Name, + abManifest, + sceneData.SceneEntityDefinition.id ?? string.Empty)); + } } public struct Options diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs index 0e8a5603213..d8169fa8c81 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs @@ -14,7 +14,8 @@ public struct GetAssetBundleIntention : ILoadingIntention, IEquatable @@ -37,10 +38,9 @@ public struct GetAssetBundleIntention : ILoadingIntention, IEquatable public CancellationTokenSource CancellationTokenSource => CommonArguments.CancellationTokenSource; - public static GetAssetBundleIntention Create(Type? expectedAssetType, string hash, string name, AssetSource permittedSources = AssetSource.ALL, + public static GetAssetBundleIntention Create(Type? expectedAssetType, string hash, string name, AssetBundleManifestVersion assetBundleManifestVersion, string parentEntityID, AssetSource permittedSources = AssetSource.ALL, URLSubdirectory customEmbeddedSubDirectory = default) => - new (expectedAssetType, hash: hash, name: name, permittedSources: permittedSources, customEmbeddedSubDirectory: customEmbeddedSubDirectory); + new (expectedAssetType, assetBundleManifestVersion, hash: hash, name: name, parentEntityID: parentEntityID, permittedSources: permittedSources, customEmbeddedSubDirectory: customEmbeddedSubDirectory); - public static GetAssetBundleIntention FromHash(string hash, Type? expectedAssetType = null, AssetSource permittedSources = AssetSource.ALL, + public static GetAssetBundleIntention FromHash(string hash, AssetBundleManifestVersion assetBundleManifestVersion, Type? expectedAssetType = null, AssetSource permittedSources = AssetSource.ALL, URLSubdirectory customEmbeddedSubDirectory = default, CancellationTokenSource cancellationTokenSource = null, - AssetBundleManifestVersion? assetBundleManifestVersion = null, string parentEntityID = "", bool isDependency = false, bool lookForDependencies = false) => - new (expectedAssetType, hash: hash, assetBundleVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, permittedSources: permittedSources, customEmbeddedSubDirectory: customEmbeddedSubDirectory, isDependency: isDependency, lookForDependencies: lookForDependencies, cancellationTokenSource: cancellationTokenSource); + string parentEntityID = "", bool isDependency = false, bool lookForDependencies = false) => + new (expectedAssetType, assetBundleManifestVersion, hash: hash, parentEntityID: parentEntityID, permittedSources: permittedSources, customEmbeddedSubDirectory: customEmbeddedSubDirectory, isDependency: isDependency, lookForDependencies: lookForDependencies, cancellationTokenSource: cancellationTokenSource); public override bool Equals(object obj) => obj is GetAssetBundleIntention other && Equals(other); diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs index 577bcf8d10b..598b2c300f0 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/LoadAssetBundleSystem.cs @@ -59,7 +59,7 @@ private async UniTask LoadDependenciesAsync(GetAssetBundleInt URLSubdirectory customEmbeddedSubdirectory = parentIntent.CommonArguments.CustomEmbeddedSubDirectory; - return await UniTask.WhenAll(assetBundleMetadata.dependencies.Select(hash => WaitForDependencyAsync(hash, parentIntent.AssetBundleManifestVersion!, parentIntent.ParentEntityID, customEmbeddedSubdirectory, partition, ct))); + return await UniTask.WhenAll(assetBundleMetadata.dependencies.Select(hash => WaitForDependencyAsync(hash, parentIntent.AssetBundleManifestVersion, parentIntent.ParentEntityID, customEmbeddedSubdirectory, partition, ct))); } protected override async UniTask> FlowInternalAsync(GetAssetBundleIntention intention, StreamableLoadingState state, IPartitionComponent partition, CancellationToken ct) @@ -144,7 +144,7 @@ protected override async UniTask> FlowI // if the type was not specified don't load any assets StreamableLoadingResult result = await CreateAssetBundleDataAsync(assetBundle, intention.ExpectedObjectType, mainAsset, loadingMutex, dependencies, GetReportData(), - intention.AssetBundleManifestVersion == null ? "" : intention.AssetBundleManifestVersion.GetAssetBundleManifestVersion(), + intention.AssetBundleManifestVersion.GetAssetBundleManifestVersion(), source, intention.IsDependency, intention.LookForDependencies, ct); return result; } @@ -220,9 +220,7 @@ private async UniTask WaitForDependencyAsync( IPartitionComponent partition, CancellationToken ct) { // Inherit partition from the parent promise - // we don't know the type of the dependency - // Dependency hashes from AB metadata are digest-less — resolve them so URL and cache identity match the parent's scheme. - var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(assetBundleManifestVersion.ResolveCdnRequestHash(hash), assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); + var assetBundlePromise = AssetPromise.Create(World, GetAssetBundleIntention.FromHash(hash, assetBundleManifestVersion: assetBundleManifestVersion, parentEntityID: parentEntityID, customEmbeddedSubDirectory: customEmbeddedSubdirectory, isDependency : true), partition); try { diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystem.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystem.cs index 16934586a5f..3be94871e17 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystem.cs @@ -5,7 +5,6 @@ using DCL.Diagnostics; using ECS.Groups; using ECS.StreamableLoading.Common.Components; -using SceneRunner.Scene; namespace ECS.StreamableLoading.AssetBundles { @@ -16,12 +15,7 @@ namespace ECS.StreamableLoading.AssetBundles [LogCategory(ReportCategory.ASSET_BUNDLES)] public partial class PrepareAssetBundleLoadingParametersSystem : PrepareAssetBundleLoadingParametersSystemBase { - private readonly ISceneData sceneData; - - internal PrepareAssetBundleLoadingParametersSystem(World world, ISceneData sceneData, URLDomain streamingAssetURL, URLDomain assetBundlesURL) : base(world, streamingAssetURL, assetBundlesURL) - { - this.sceneData = sceneData; - } + internal PrepareAssetBundleLoadingParametersSystem(World world, URLDomain streamingAssetURL, URLDomain assetBundlesURL) : base(world, streamingAssetURL, assetBundlesURL) { } protected override void Update(float t) { @@ -33,9 +27,6 @@ protected override void Update(float t) // If loading is not started yet and there is no result private new void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntention assetBundleIntention, ref StreamableLoadingState state) { - assetBundleIntention.AssetBundleManifestVersion = sceneData.SceneEntityDefinition.assetBundleManifestVersion; - assetBundleIntention.ParentEntityID = sceneData.SceneEntityDefinition.id; - base.PrepareCommonArguments(in entity, ref assetBundleIntention, ref state); } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index 209d33d1bed..de612ebbdc8 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -51,7 +51,7 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent // Second priority if (EnumUtils.HasFlag(assetBundleIntention.CommonArguments.PermittedSources, AssetSource.WEB)) { - if (assetBundleIntention.AssetBundleManifestVersion == null || assetBundleIntention.AssetBundleManifestVersion.assetBundleManifestRequestFailed) + if (assetBundleIntention.AssetBundleManifestVersion.assetBundleManifestRequestFailed) { World.Add(entity, new StreamableLoadingResult (GetReportCategory(), CreateException(new ArgumentException($"Manifest version must be provided to load {assetBundleIntention.Name} from `WEB` source")))); diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index b6c9fc669ad..b21a8542fde 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -17,6 +17,8 @@ public class DepsDigestCacheKeyShould private const string HASH_B = "bafkreice3qpeyeb4ni7fnlt6bijs57zrbuw7cwmbymzcndyllzho3hbgaa"; private const string HASH_LEGACY = "bafybeih4xx65yycsf2vx6sari7myjho6rugqox4ocd2tzjhfam73g2trru"; + private static readonly AssetBundleManifestVersion ANY_MANIFEST = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + private static AssetBundleManifestVersion CreateV49Manifest(params string[] files) { var manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); @@ -62,13 +64,10 @@ public void ComposeCdnRequestHashFromBareHash() AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(AssetBundleManifestVersion.GetCdnRequestHash(manifest, HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); - Assert.That(AssetBundleManifestVersion.GetCdnRequestHash(manifest, HASH_B), Is.EqualTo($"{HASH_B}{platform}"), + Assert.That(manifest.GetCdnRequestHash(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); - - AssetBundleManifestVersion? nullManifest = null; - Assert.That(AssetBundleManifestVersion.GetCdnRequestHash(nullManifest, HASH_A), Is.EqualTo($"{HASH_A}{platform}")); } [Test] @@ -87,17 +86,21 @@ public void ReportDepsDigestsOnlyWhenMapWasInjected() } [Test] - public void ComposeCacheKey_FallsBackToBareHashWhenManifestIsNull() + public void ProvideFailedManifestWhenDefinitionHasNone() { - AssetBundleManifestVersion? manifest = null; - Assert.That(AssetBundleManifestVersion.ComposeCacheKey(manifest, HASH_A), Is.EqualTo(HASH_A)); + // AB intentions require a manifest: definitions without one hand out the failed sentinel, + // which the loading pipeline already treats as a dead end. + var definition = new SceneEntityDefinition(); + + Assert.That(definition.assetBundleManifestVersion, Is.Null); + Assert.That(definition.AssetBundleManifestVersionOrFailed.assetBundleManifestRequestFailed, Is.True); } [Test] public void ComposeCacheKey_FallsBackToBareHashWhenNoDigest() { AssetBundleManifestVersion manifest = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - Assert.That(AssetBundleManifestVersion.ComposeCacheKey(manifest, HASH_A), Is.EqualTo(HASH_A)); + Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo(HASH_A)); } [Test] @@ -106,7 +109,7 @@ public void ComposeCacheKey_ReturnsVerbatimFileNameWhenPresent() string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(AssetBundleManifestVersion.ComposeCacheKey(manifest, HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(manifest.ComposeCacheKey(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); } [Test] @@ -147,8 +150,8 @@ public void PreferDigestEntriesOverContentCasingEntries() public void TreatIntentionsWithDifferentDigestBearingHashesAsDistinct() { // The digest is part of the Hash itself, so two dependency closures of the same bare hash never collide. - var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); - var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac"); + var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac", ANY_MANIFEST); + var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac", ANY_MANIFEST); Assert.That(a.Equals(b), Is.False); Assert.That(a.GetHashCode(), Is.Not.EqualTo(b.GetHashCode())); @@ -157,8 +160,8 @@ public void TreatIntentionsWithDifferentDigestBearingHashesAsDistinct() [Test] public void TreatIntentionsWithSameHashAsEqual() { - var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); - var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); + var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac", ANY_MANIFEST); + var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac", ANY_MANIFEST); Assert.That(a.Equals(b), Is.True); Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); @@ -167,8 +170,8 @@ public void TreatIntentionsWithSameHashAsEqual() [Test] public void ProduceDistinctDiskFilenamesForDifferentDigestBearingHashes() { - var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac"); - var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac"); + var a = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_A}_mac", ANY_MANIFEST); + var b = GetAssetBundleIntention.FromHash($"{HASH_A}_{DIGEST_B}_mac", ANY_MANIFEST); using var keyA = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in a); using var keyB = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in b); @@ -180,8 +183,8 @@ public void ProduceDistinctDiskFilenamesForDifferentDigestBearingHashes() public void PreserveLegacyDiskFilenameForDigestLessHashes() { // A digest-less hash must keep its pre-scheme on-disk file name so existing cached entries keep hitting. - var legacy = GetAssetBundleIntention.FromHash(HASH_LEGACY); - var alsoLegacy = GetAssetBundleIntention.FromHash(HASH_LEGACY); + var legacy = GetAssetBundleIntention.FromHash(HASH_LEGACY, ANY_MANIFEST); + var alsoLegacy = GetAssetBundleIntention.FromHash(HASH_LEGACY, ANY_MANIFEST); using var keyA = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in legacy); using var keyB = GetAssetBundleIntention.DiskHashCompute.INSTANCE.ComputeHash(in alsoLegacy); diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/LoadAssetBundlePartialSystemShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/LoadAssetBundlePartialSystemShould.cs index 29419f65ac2..0a1d106b358 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/LoadAssetBundlePartialSystemShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/LoadAssetBundlePartialSystemShould.cs @@ -1,4 +1,5 @@ using AssetManagement; +using DCL.Ipfs; using Cysharp.Threading.Tasks; using DCL.DebugUtilities.UIBindings; using DCL.Multiplayer.Connections.DecentralandUrls; @@ -137,7 +138,7 @@ private ABPromise NewABPromiseRemoteAsset(int index) private ABPromise NewABPromise() { - var intention = GetAssetBundleIntention.FromHash("bafkreid3xecd44iujaz5qekbdrt5orqdqj3wivg5zc5mya3zkorjhyrkda", typeof(GameObject), permittedSources: AssetSource.WEB); + var intention = GetAssetBundleIntention.FromHash("bafkreid3xecd44iujaz5qekbdrt5orqdqj3wivg5zc5mya3zkorjhyrkda", AssetBundleManifestVersion.CreateManualManifest(), typeof(GameObject), permittedSources: AssetSource.WEB); var partition = PartitionComponent.TOP_PRIORITY; var assetPromise = ABPromise.Create(world, intention, partition); world.Get(assetPromise.Entity).SetAllowed(Substitute.For()); diff --git a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs index f8ff42dfa58..163711c2caa 100644 --- a/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/ResolveISSLODSystem.cs @@ -70,7 +70,7 @@ private void ResolveInitialSceneStateLODDescriptor(ref SceneLODInfo sceneLODInfo /// private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IReadOnlyList assets, SceneDefinitionComponent sceneDefinition, ISSDescriptor issDescriptor) { - AssetBundleManifestVersion? manifest = sceneDefinition.Definition.assetBundleManifestVersion; + AssetBundleManifestVersion manifest = sceneDefinition.Definition.AssetBundleManifestVersionOrFailed; for (var i = 0; i < assets.Count; i++) { @@ -79,7 +79,7 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead // The GLTF container cache is keyed by the canonical CDN file name (see AssetBundleManifestVersion.ComposeCacheKey). // Looking up by bare hash misses any bridged entry the SDK runtime left behind, so the LOD spawns a // second instance of an asset that's already resident — that's the visible "double" overlap. - string cacheKey = AssetBundleManifestVersion.ComposeCacheKey(manifest, entry.hash); + string cacheKey = manifest.ComposeCacheKey(entry.hash); if (gltfCache.TryGet(cacheKey, out var asset)) { @@ -92,7 +92,7 @@ private void SpawnAssetPromises(InitialSceneStateLOD initialSceneStateLOD, IRead } // Resolving through the manifest lands this promise in the same AssetBundleCache slot as the SDK runtime — a diverging Hash races two loads of the same bundle ("asset bundle already loaded"). - var intent = GetAssetBundleIntention.FromHash(AssetBundleManifestVersion.GetCdnRequestHash(manifest, entry.hash), + var intent = GetAssetBundleIntention.FromHash(manifest.GetCdnRequestHash(entry.hash), assetBundleManifestVersion: manifest, parentEntityID: sceneDefinition.Definition.id); diff --git a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs index 797e748ff19..91f1b685a4d 100644 --- a/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs +++ b/Explorer/Assets/DCL/LOD/Systems/UpdateSceneLODInfoSystem.cs @@ -89,11 +89,11 @@ private void StartLODPromise(ref SceneLODInfo sceneLODInfo, ref PartitionCompone AssetBundleManifestVersion lodManifest = AssetBundleManifestVersion.CreateForLOD($"LOD/{level.ToString()}", "dummyDate"); var assetBundleIntention = GetAssetBundleIntention.FromHash( - AssetBundleManifestVersion.GetCdnRequestHash(lodManifest, $"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), + lodManifest.GetCdnRequestHash($"{sceneDefinitionComponent.Definition.id.ToLower()}_{level.ToString()}"), + lodManifest, typeof(GameObject), permittedSources: AssetSource.ALL, customEmbeddedSubDirectory: LODUtils.LOD_EMBEDDED_SUBDIRECTORIES, - assetBundleManifestVersion: lodManifest, lookForDependencies: true ); diff --git a/Explorer/Assets/DCL/LOD/Tests/PlayMode/InstantiateSceneLODInfoSystemShould.cs b/Explorer/Assets/DCL/LOD/Tests/PlayMode/InstantiateSceneLODInfoSystemShould.cs index 864c81bcc10..4f66edad571 100644 --- a/Explorer/Assets/DCL/LOD/Tests/PlayMode/InstantiateSceneLODInfoSystemShould.cs +++ b/Explorer/Assets/DCL/LOD/Tests/PlayMode/InstantiateSceneLODInfoSystemShould.cs @@ -136,7 +136,7 @@ public void ResolveFailedPromise() private Promise GenerateFailedPromise() { var promise = Promise.Create(world, - GetAssetBundleIntention.FromHash("Cube", typeof(GameObject)), + GetAssetBundleIntention.FromHash("Cube", AssetBundleManifestVersion.CreateForLOD("LOD/0", "dummyDate"), typeof(GameObject)), new PartitionComponent()); world.Add(promise.Entity, @@ -148,7 +148,7 @@ private Promise GenerateFailedPromise() private (AssetBundleData, Promise) GenerateSuccessfullPromise() { var promise = Promise.Create(world, - GetAssetBundleIntention.FromHash("Cube", typeof(GameObject)), + GetAssetBundleIntention.FromHash("Cube", AssetBundleManifestVersion.CreateForLOD("LOD/0", "dummyDate"), typeof(GameObject)), new PartitionComponent()); var fakeAssetBundleData = new AssetBundleData(null, new []{GameObject.CreatePrimitive(PrimitiveType.Cube)}, diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index f9d6afffa29..c6e29b7f9f2 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -94,12 +94,12 @@ public bool HasDepsDigests() => hasDepsDigests; /// Builds the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. - public static string GetCdnRequestHash(AssetBundleManifestVersion? manifest, string bareHash) => - manifest?.ResolveCdnRequestHash(bareHash) ?? $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; + public string GetCdnRequestHash(string bareHash) => + ResolveCdnRequestHash($"{bareHash}{PlatformUtils.GetCurrentPlatform()}"); /// Composes the upper-layer cache key (GLTF container, etc.): the canonical CDN file name when known, otherwise the bare hash. - public static string ComposeCacheKey(AssetBundleManifestVersion? manifest, string hash) => - manifest != null && manifest.TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; + public string ComposeCacheKey(string hash) => + TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; /// Resolves a platform-suffixed hash to the canonical CDN file name — digest-bearing and correctly cased when known (case-insensitive lookup) — or returns the input unchanged when unlisted. public string ResolveCdnRequestHash(string hash) => diff --git a/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs b/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs index 03dc1a8ab87..6f6e44b9c2e 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs @@ -39,6 +39,10 @@ public class TrimmedEntityDefinitionBase [JsonProperty("versions")] public AssetBundleManifestVersion? assetBundleManifestVersion; + /// The manifest version, or a failed sentinel when none was resolved — AB intentions require a manifest, and the sentinel is the same dead end the pipeline already handles. + [JsonIgnore] + public AssetBundleManifestVersion AssetBundleManifestVersionOrFailed => assetBundleManifestVersion ?? AssetBundleManifestVersion.CreateFailed(); + [JsonProperty("status")] public AssetBundleRegistryEnum assetBundleRegistryEnum; } diff --git a/Explorer/Assets/DCL/PluginSystem/World/AssetBundlesPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/AssetBundlesPlugin.cs index 4ea8651e942..26df1adaf01 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/AssetBundlesPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/AssetBundlesPlugin.cs @@ -62,7 +62,7 @@ public AssetBundlesPlugin(IReportsHandlingSettings reportsHandlingSettings, Cach public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in ECSWorldInstanceSharedDependencies sharedDependencies, in SystemsDependencies systemsDependencies, in PersistentEntities persistentEntities, List finalizeWorldSystems, List sceneIsCurrentListeners) { // Asset Bundles - PrepareAssetBundleLoadingParametersSystem.InjectToWorld(ref builder, sharedDependencies.SceneData, STREAMING_ASSETS_URL, assetBundleURL); + PrepareAssetBundleLoadingParametersSystem.InjectToWorld(ref builder, STREAMING_ASSETS_URL, assetBundleURL); bool byteWeightedProgress = FeaturesRegistry.Instance.IsEnabled(FeatureId.BYTE_WEIGHTED_LOADING_PROGRESS); diff --git a/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs index e6feb978b6c..00d2479fc17 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/GltfContainerPlugin.cs @@ -123,7 +123,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder, // when the scene's manifest knows the hash, the key becomes the canonical CDN file name, else the bare hash. var sceneData = sharedDependencies.SceneData; LoadGltfContainerSystem.InjectToWorld(ref builder, buffer, sceneData, sharedDependencies.EntityCollidersSceneCache, - hash => AssetBundleManifestVersion.ComposeCacheKey(sceneData.SceneEntityDefinition.assetBundleManifestVersion, hash)); + hash => sceneData.SceneEntityDefinition.AssetBundleManifestVersionOrFailed.ComposeCacheKey(hash)); FinalizeGltfContainerLoadingSystem.InjectToWorld(ref builder, persistentEntities.SceneRoot, globalDeps.FrameTimeBudget, sharedDependencies.EntityCollidersSceneCache, sharedDependencies.SceneData, buffer); From 25a21852fcfbcf9a45a1126f3c34937baac6dad9 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:27:19 -0300 Subject: [PATCH 11/24] refactor: move cache-hash computation into AssetBundleManifestVersion The manifest owns every input of the decision (deps map presence, version, buildDate), so ComputeCacheHash lives there now and the prepare system just asks for it. ComputeHashV49/ComputeHashLegacy become private implementation details, and the tests exercise the real dispatch through manifests instead of calling the hash builders directly. Co-Authored-By: Claude Fable 5 --- ...eAssetBundleLoadingParametersSystemBase.cs | 35 +--------- .../EditModeTests/DepsDigestCacheKeyShould.cs | 65 +++++++++---------- .../AssetBundleManifestVersion.cs | 33 ++++++++++ 3 files changed, 65 insertions(+), 68 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index de612ebbdc8..2b3b88f2297 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -5,8 +5,6 @@ using ECS.StreamableLoading.Common.Components; using System; using System.Linq; -using DCL.Platforms; -using UnityEngine; using Utility; namespace ECS.StreamableLoading.AssetBundles @@ -68,12 +66,7 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifestVersion.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()); assetBundleIntention.CommonArguments = ca; - // Scene manifests carry the deps map and key on version+hash (the digest travels inside the hash); wearables/emotes never carry it and keep buildDate keying, as their bundles are republished in place. - assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifestVersion.HasDepsDigests() - ? ComputeHashV49(assetBundleIntention.Hash, - assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()) - : ComputeHashLegacy(assetBundleIntention.Hash, - assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestBuildDate()); + assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifestVersion.ComputeCacheHash(assetBundleIntention.Hash); } } @@ -85,32 +78,6 @@ private URLAddress GetStreamingAssetsUrl(string hash, URLSubdirectory customSubd ? streamingAssetURL.Append(URLPath.FromString(hash)) : streamingAssetURL.Append(customSubdirectory).Append(URLPath.FromString(hash)); - public static unsafe Hash128 ComputeHashLegacy(string hash, string buildDate) - { - // Byte-identical to the pre-v49 cache key so existing Unity-AB-cache entries keep hitting after upgrade. - // The lack of a delimiter is a known theoretical collision risk (e.g. buildDate ending in 'X' vs. hash - // starting with 'X') — accepted here, will be addressed when v49 adoption lets us retire this path. - Span hashBuilder = stackalloc char[buildDate.Length + hash.Length]; - buildDate.AsSpan().CopyTo(hashBuilder); - hash.AsSpan().CopyTo(hashBuilder[buildDate.Length..]); - - fixed (char* ptr = hashBuilder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * hashBuilder.Length)); } - } - - public static unsafe Hash128 ComputeHashV49(string hash, string version) - { - // The digest embedded in the hash replaces buildDate-based invalidation, keeping the cache shareable across CDN republishes; the delimiter prevents version/hash boundary collisions. - ReadOnlySpan hashSpan = hash.AsSpan(); - ReadOnlySpan versionSpan = version.AsSpan(); - - Span builder = stackalloc char[versionSpan.Length + 1 + hashSpan.Length]; - versionSpan.CopyTo(builder); - builder[versionSpan.Length] = '|'; - hashSpan.CopyTo(builder[(versionSpan.Length + 1)..]); - - fixed (char* ptr = builder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * builder.Length)); } - } - private URLAddress GetAssetBundleURL(bool hasSceneIDInPath, string hash, string sceneID, string assetBundleManifestVersion) { if (hasSceneIDInPath) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index b21a8542fde..acc036b5a32 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -220,70 +220,67 @@ public void GltfIntentionsWithDifferentCacheKeysAreDistinct() } [Test] - public void V49HashIsStableForSameInputs() + public void ComputeStableCacheHashForSameInputs() { - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); + AssetBundleManifestVersion sceneManifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); + Assert.That(sceneManifest.ComputeCacheHash($"{HASH_A}_{DIGEST_A}_mac"), Is.EqualTo(sceneManifest.ComputeCacheHash($"{HASH_A}_{DIGEST_A}_mac"))); - Assert.That(a, Is.EqualTo(b)); + AssetBundleManifestVersion legacyManifest = AssetBundleManifestVersion.CreateFromFallback("v48", "2026-05-01"); + Assert.That(legacyManifest.ComputeCacheHash(HASH_LEGACY), Is.EqualTo(legacyManifest.ComputeCacheHash(HASH_LEGACY))); } [Test] - public void V49HashDiffersWhenDigestBearingHashDiffers() + public void ComputeDifferentCacheHashWhenDigestBearingHashDiffers() { // The digest travels inside the hash, so two dependency closures produce different Unity-cache keys. - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_B}_mac", "v49"); + AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac", $"{HASH_A}_{DIGEST_B}_mac"); - Assert.That(a, Is.Not.EqualTo(b)); + Assert.That(manifest.ComputeCacheHash($"{HASH_A}_{DIGEST_A}_mac"), Is.Not.EqualTo(manifest.ComputeCacheHash($"{HASH_A}_{DIGEST_B}_mac"))); } [Test] - public void V49HashDiffersWhenVersionDiffers() + public void ComputeDifferentCacheHashWhenVersionDiffers() { - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v49"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49($"{HASH_A}_{DIGEST_A}_mac", "v50"); + AssetBundleManifestVersion v49 = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); - Assert.That(a, Is.Not.EqualTo(b)); + var v50 = AssetBundleManifestVersion.CreateFromFallback("v50", "2026-05-01"); + v50.InjectDepsDigests(new[] { $"{HASH_A}_{DIGEST_A}_mac" }); + + Assert.That(v49.ComputeCacheHash($"{HASH_A}_{DIGEST_A}_mac"), Is.Not.EqualTo(v50.ComputeCacheHash($"{HASH_A}_{DIGEST_A}_mac"))); } [Test] - public void V49DelimiterPreventsBoundaryCollisions() + public void ComputeDifferentCacheHashAcrossVersionHashBoundary() { // Without the delimiter, (version="v4", hash="9foo") and (version="v49", hash="foo") would produce the same byte stream. - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("9foo", "v4"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49("foo", "v49"); - - Assert.That(a, Is.Not.EqualTo(b)); - } + var v4 = AssetBundleManifestVersion.CreateFromFallback("v4", "2026-05-01"); + v4.InjectDepsDigests(new[] { $"9foo_{DIGEST_A}_mac" }); - [Test] - public void LegacyHashIsStableForSameInputs() - { - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); + var v49 = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + v49.InjectDepsDigests(new[] { $"foo_{DIGEST_A}_mac" }); - Assert.That(a, Is.EqualTo(b)); + Assert.That(v4.ComputeCacheHash("9foo"), Is.Not.EqualTo(v49.ComputeCacheHash("foo"))); } [Test] - public void LegacyHashChangesWithBuildDate() + public void ComputeCacheHashFromBuildDateWithoutDepsMap() { - // Pre-v49 ABs have no per-file freshness signal — buildDate must contribute to the key to flush stale entries. - Hash128 a = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-01"); - Hash128 b = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_LEGACY, "2026-05-02"); + // Manifests without a deps map (wearables/emotes, pre-v49 scenes) key on buildDate — a republish must flush their cache. + var a = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + var b = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-02"); - Assert.That(a, Is.Not.EqualTo(b)); + Assert.That(a.ComputeCacheHash(HASH_LEGACY), Is.Not.EqualTo(b.ComputeCacheHash(HASH_LEGACY))); } [Test] - public void V49AndLegacyDoNotCollideForSameHash() + public void ComputeDistinctCacheHashesAcrossKeyingSchemes() { - // A digest-less v49 hash must not collide with the legacy key even when buildDate equals the version string. - Hash128 legacy = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashLegacy(HASH_A, "v49"); - Hash128 v49 = PrepareAssetBundleLoadingParametersSystemBase.ComputeHashV49(HASH_A, "v49"); + // A mapped and an unmapped manifest must not produce the same key for the same hash, even when the + // legacy buildDate happens to equal the v49 version string. + AssetBundleManifestVersion mapped = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); + var unmapped = AssetBundleManifestVersion.CreateFromFallback("v49", "v49"); - Assert.That(legacy, Is.Not.EqualTo(v49)); + Assert.That(mapped.ComputeCacheHash(HASH_A), Is.Not.EqualTo(unmapped.ComputeCacheHash(HASH_A))); } [Test] diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index c6e29b7f9f2..53896b3f63f 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -2,6 +2,7 @@ using DCL.Utility; using System; using System.Collections.Generic; +using UnityEngine; using Utility; // ReSharper disable once CheckNamespace @@ -101,10 +102,42 @@ public string GetCdnRequestHash(string bareHash) => public string ComposeCacheKey(string hash) => TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; + /// Computes the Unity-cache key for a CDN request hash: scene manifests (deps map present) key on version+hash — the digest travels inside the hash — while wearables/emotes keep buildDate keying, as their bundles are republished in place. + public Hash128 ComputeCacheHash(string hash) => + HasDepsDigests() + ? ComputeHashV49(hash, GetAssetBundleManifestVersion() ?? string.Empty) + : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate() ?? string.Empty); + /// Resolves a platform-suffixed hash to the canonical CDN file name — digest-bearing and correctly cased when known (case-insensitive lookup) — or returns the input unchanged when unlisted. public string ResolveCdnRequestHash(string hash) => TryResolveCdnRequestHash(hash, out string fileName) ? fileName : hash; + private static unsafe Hash128 ComputeHashV49(string hash, string version) + { + // The digest embedded in the hash replaces buildDate-based invalidation, keeping the cache shareable across CDN republishes; the delimiter prevents version/hash boundary collisions. + ReadOnlySpan hashSpan = hash.AsSpan(); + ReadOnlySpan versionSpan = version.AsSpan(); + + Span builder = stackalloc char[versionSpan.Length + 1 + hashSpan.Length]; + versionSpan.CopyTo(builder); + builder[versionSpan.Length] = '|'; + hashSpan.CopyTo(builder[(versionSpan.Length + 1)..]); + + fixed (char* ptr = builder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * builder.Length)); } + } + + private static unsafe Hash128 ComputeHashLegacy(string hash, string buildDate) + { + // Byte-identical to the pre-v49 cache key so existing Unity-AB-cache entries keep hitting after upgrade. + // The lack of a delimiter is a known theoretical collision risk (e.g. buildDate ending in 'X' vs. hash + // starting with 'X') — accepted here, will be addressed when v49 adoption lets us retire this path. + Span hashBuilder = stackalloc char[buildDate.Length + hash.Length]; + buildDate.AsSpan().CopyTo(hashBuilder); + hash.AsSpan().CopyTo(hashBuilder[buildDate.Length..]); + + fixed (char* ptr = hashBuilder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * hashBuilder.Length)); } + } + /// Same resolution as , reporting whether the manifest knows the file. bool TryResolveCdnRequestHash(string hash, out string fileName) { From 7475faffa21c02a6880d41f0e5772eade0f609d7 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:28:04 -0300 Subject: [PATCH 12/24] AssetBundleManifest renaming --- .../AssetBundles/GetAssetBundleIntention.cs | 8 ++++---- .../AssetBundles/LoadAssetBundleSystem.cs | 4 ++-- .../PrepareAssetBundleLoadingParametersSystemBase.cs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs index d8169fa8c81..c97707a11a5 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs @@ -15,7 +15,7 @@ public struct GetAssetBundleIntention : ILoadingIntention, IEquatable @@ -38,7 +38,7 @@ public struct GetAssetBundleIntention : ILoadingIntention, IEquatable LoadDependenciesAsync(GetAssetBundleInt URLSubdirectory customEmbeddedSubdirectory = parentIntent.CommonArguments.CustomEmbeddedSubDirectory; - return await UniTask.WhenAll(assetBundleMetadata.dependencies.Select(hash => WaitForDependencyAsync(hash, parentIntent.AssetBundleManifestVersion, parentIntent.ParentEntityID, customEmbeddedSubdirectory, partition, ct))); + return await UniTask.WhenAll(assetBundleMetadata.dependencies.Select(hash => WaitForDependencyAsync(hash, parentIntent.AssetBundleManifest, parentIntent.ParentEntityID, customEmbeddedSubdirectory, partition, ct))); } protected override async UniTask> FlowInternalAsync(GetAssetBundleIntention intention, StreamableLoadingState state, IPartitionComponent partition, CancellationToken ct) @@ -144,7 +144,7 @@ protected override async UniTask> FlowI // if the type was not specified don't load any assets StreamableLoadingResult result = await CreateAssetBundleDataAsync(assetBundle, intention.ExpectedObjectType, mainAsset, loadingMutex, dependencies, GetReportData(), - intention.AssetBundleManifestVersion.GetAssetBundleManifestVersion(), + intention.AssetBundleManifest.GetAssetBundleManifestVersion(), source, intention.IsDependency, intention.LookForDependencies, ct); return result; } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index 2b3b88f2297..55e0567f485 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -49,7 +49,7 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent // Second priority if (EnumUtils.HasFlag(assetBundleIntention.CommonArguments.PermittedSources, AssetSource.WEB)) { - if (assetBundleIntention.AssetBundleManifestVersion.assetBundleManifestRequestFailed) + if (assetBundleIntention.AssetBundleManifest.assetBundleManifestRequestFailed) { World.Add(entity, new StreamableLoadingResult (GetReportCategory(), CreateException(new ArgumentException($"Manifest version must be provided to load {assetBundleIntention.Name} from `WEB` source")))); @@ -63,10 +63,10 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.CurrentSource = AssetSource.WEB; // Hash was already resolved to the canonical CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash/ResolveCdnRequestHash. - ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifestVersion.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifestVersion.GetAssetBundleManifestVersion()); + ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifest.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifest.GetAssetBundleManifestVersion()); assetBundleIntention.CommonArguments = ca; - assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifestVersion.ComputeCacheHash(assetBundleIntention.Hash); + assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifest.ComputeCacheHash(assetBundleIntention.Hash); } } From 037a8117e4d1522f28ffe153b312fadc7ba31cb4 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:41:52 -0300 Subject: [PATCH 13/24] refactor: key the cdnFiles map by bare hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InjectDepsDigests no longer reassembles the platform-suffixed key from file-name parts — the map now translates bare hash to canonical CDN file name directly. GetCdnRequestHash and ComposeCacheKey become plain lookups, and ResolveCdnRequestHash (suffixed-input variant) is deleted along with its platform-suffix stripping; the suffix is only appended in the fallback for files the manifest doesn't know. Co-Authored-By: Claude Fable 5 --- ...eAssetBundleLoadingParametersSystemBase.cs | 2 +- .../EditModeTests/DepsDigestCacheKeyShould.cs | 36 ++++++----------- .../AssetBundleManifestVersion.cs | 39 ++++++++----------- 3 files changed, 29 insertions(+), 48 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index 55e0567f485..62c5a82e3a6 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -62,7 +62,7 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.Timeout = StreamableLoadingDefaults.TIMEOUT; ca.CurrentSource = AssetSource.WEB; - // Hash was already resolved to the canonical CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash/ResolveCdnRequestHash. + // Hash was already translated to the canonical CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash. ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifest.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifest.GetAssetBundleManifestVersion()); assetBundleIntention.CommonArguments = ca; diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index acc036b5a32..f4e3527bef4 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -27,49 +27,35 @@ private static AssetBundleManifestVersion CreateV49Manifest(params string[] file } [Test] - public void ResolveCdnRequestHashToVerbatimManifestEntry() + public void TranslateBareHashToCdnRequestHash() { - // The map is keyed by the *current* platform-suffixed hash, so entries are built with it to stay platform-agnostic. string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest( $"{HASH_LEGACY}{platform}", $"{HASH_A}_{DIGEST_A}{platform}"); - Assert.That(manifest.ResolveCdnRequestHash($"{HASH_A}{platform}"), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); + Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); - Assert.That(manifest.ResolveCdnRequestHash($"{HASH_LEGACY}{platform}"), Is.EqualTo($"{HASH_LEGACY}{platform}"), - "Files listed without a digest must fall back to the input hash"); + Assert.That(manifest.GetCdnRequestHash(HASH_LEGACY), Is.EqualTo($"{HASH_LEGACY}{platform}"), + "Files listed without a digest must fall back to the platform-suffixed bare hash"); - Assert.That(manifest.ResolveCdnRequestHash($"{HASH_B}{platform}"), Is.EqualTo($"{HASH_B}{platform}"), - "Hashes absent from the manifest must fall back to the input hash"); + Assert.That(manifest.GetCdnRequestHash(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), + "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); } [Test] - public void ResolveCdnRequestHashCaseInsensitivelyToManifestCasing() + public void TranslateBareHashCaseInsensitivelyToManifestCasing() { string platform = PlatformUtils.GetCurrentPlatform(); AssetBundleManifestVersion manifest = CreateV49Manifest($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}"); // The lookup is case-insensitive and returns the manifest's casing — the name that exists on the case-sensitive CDN. - Assert.That(manifest.ResolveCdnRequestHash($"QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV{platform}"), + Assert.That(manifest.GetCdnRequestHash("QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV"), Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv_{DIGEST_A}{platform}")); } - [Test] - public void ComposeCdnRequestHashFromBareHash() - { - string platform = PlatformUtils.GetCurrentPlatform(); - - AssetBundleManifestVersion manifest = CreateV49Manifest($"{HASH_A}_{DIGEST_A}{platform}"); - - Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); - - Assert.That(manifest.GetCdnRequestHash(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), - "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); - } - [Test] public void ReportDepsDigestsOnlyWhenMapWasInjected() { @@ -123,7 +109,7 @@ public void ResolveQmContentCasingThroughTheSameMap() Assert.That(manifest.HasDepsDigests(), Is.False, "Content casing entries must not switch cache keying to the v49 scheme"); - Assert.That(manifest.ResolveCdnRequestHash($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv{platform}"), + Assert.That(manifest.GetCdnRequestHash("QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV"), Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv{platform}").IgnoreCase); } @@ -138,12 +124,12 @@ public void PreferDigestEntriesOverContentCasingEntries() var contentFirst = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); contentFirst.InjectContent(QM_ENTITY_ID, new[] { new ContentDefinition { file = "model.glb", hash = QM_HASH } }); contentFirst.InjectDepsDigests(new[] { $"{QM_HASH}_{DIGEST_A}{platform}" }); - Assert.That(contentFirst.ResolveCdnRequestHash($"{QM_HASH}{platform}"), Is.EqualTo($"{QM_HASH}_{DIGEST_A}{platform}")); + Assert.That(contentFirst.GetCdnRequestHash(QM_HASH), Is.EqualTo($"{QM_HASH}_{DIGEST_A}{platform}")); var digestsFirst = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); digestsFirst.InjectDepsDigests(new[] { $"{QM_HASH}_{DIGEST_A}{platform}" }); digestsFirst.InjectContent(QM_ENTITY_ID, new[] { new ContentDefinition { file = "model.glb", hash = QM_HASH } }); - Assert.That(digestsFirst.ResolveCdnRequestHash($"{QM_HASH}{platform}"), Is.EqualTo($"{QM_HASH}_{DIGEST_A}{platform}")); + Assert.That(digestsFirst.GetCdnRequestHash(QM_HASH), Is.EqualTo($"{QM_HASH}_{DIGEST_A}{platform}")); } [Test] diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 53896b3f63f..483773e608d 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -34,13 +34,13 @@ public class AssetBundleManifestVersion public bool IsLSDAsset; public AssetBundleManifestVersionPerPlatform? assets; - //Digest-less platform-suffixed hash → canonical CDN file name; fed by InjectDepsDigests (digest-bearing names) and InjectContent (Qm casing fixes). + //Bare hash → canonical CDN file name; fed by InjectDepsDigests (digest-bearing names) and InjectContent (Qm casing fixes). private Dictionary? cdnFiles; private bool hasDepsDigests; public bool HasHashInPath() { - HasHashInPathValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_REQUIRES_HASH; + HasHashInPathValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_REQUIRES_HASH && version < ASSET_BUNDLE_VERSION_SUPPORTS_DEPS_DIGEST ; return HasHashInPathValue.Value; } @@ -72,7 +72,7 @@ private static bool TryParseVersionNumber(string? version, out int parsed) return int.TryParse(version.AsSpan(1), out parsed); } - /// Stores, keyed by the digest-less platform-suffixed hash, the verbatim digest-bearing file name (<hash>_<depsDigest>_<platform>) from the manifest's files[]; legacy 2-part names are skipped. + /// Stores, keyed by the bare hash, the verbatim digest-bearing file name (<hash>_<depsDigest>_<platform>) from the manifest's files[]; legacy 2-part names are skipped. public void InjectDepsDigests(string[]? files) { if (files == null || files.Length == 0) return; @@ -85,7 +85,7 @@ public void InjectDepsDigests(string[]? files) if (parts.Length < 3) continue; cdnFiles ??= new Dictionary(new UrlHashComparer()); - cdnFiles[$"{parts[0]}_{parts[2]}"] = file; + cdnFiles[parts[0]] = file; hasDepsDigests = true; } } @@ -94,23 +94,19 @@ public void InjectDepsDigests(string[]? files) public bool HasDepsDigests() => hasDepsDigests; - /// Builds the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. + /// Translates a bare hash to the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. public string GetCdnRequestHash(string bareHash) => - ResolveCdnRequestHash($"{bareHash}{PlatformUtils.GetCurrentPlatform()}"); + TryGetCdnFileName(bareHash, out string fileName) ? fileName : $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; /// Composes the upper-layer cache key (GLTF container, etc.): the canonical CDN file name when known, otherwise the bare hash. public string ComposeCacheKey(string hash) => - TryResolveCdnRequestHash($"{hash}{PlatformUtils.GetCurrentPlatform()}", out string fileName) ? fileName : hash; + TryGetCdnFileName(hash, out string fileName) ? fileName : hash; /// Computes the Unity-cache key for a CDN request hash: scene manifests (deps map present) key on version+hash — the digest travels inside the hash — while wearables/emotes keep buildDate keying, as their bundles are republished in place. public Hash128 ComputeCacheHash(string hash) => HasDepsDigests() - ? ComputeHashV49(hash, GetAssetBundleManifestVersion() ?? string.Empty) - : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate() ?? string.Empty); - - /// Resolves a platform-suffixed hash to the canonical CDN file name — digest-bearing and correctly cased when known (case-insensitive lookup) — or returns the input unchanged when unlisted. - public string ResolveCdnRequestHash(string hash) => - TryResolveCdnRequestHash(hash, out string fileName) ? fileName : hash; + ? ComputeHashV49(hash, GetAssetBundleManifestVersion()!) + : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate()!); private static unsafe Hash128 ComputeHashV49(string hash, string version) { @@ -138,21 +134,20 @@ private static unsafe Hash128 ComputeHashLegacy(string hash, string buildDate) fixed (char* ptr = hashBuilder) { return Hash128.Compute(ptr, (uint)(sizeof(char) * hashBuilder.Length)); } } - /// Same resolution as , reporting whether the manifest knows the file. - bool TryResolveCdnRequestHash(string hash, out string fileName) + private bool TryGetCdnFileName(string bareHash, out string fileName) { - if (cdnFiles != null && cdnFiles.TryGetValue(hash, out fileName!)) + if (cdnFiles != null && cdnFiles.TryGetValue(bareHash, out fileName!)) return true; fileName = string.Empty; return false; } - public string? GetAssetBundleManifestVersion() => - IPlatform.DEFAULT.Is(IPlatform.Kind.Windows) ? assets?.windows!.version : assets?.mac!.version; + public string GetAssetBundleManifestVersion() => + IPlatform.DEFAULT.Is(IPlatform.Kind.Windows) ? assets?.windows!.version! : assets?.mac!.version!; - public string? GetAssetBundleManifestBuildDate() => - IPlatform.DEFAULT.Is(IPlatform.Kind.Windows) ? assets?.windows!.buildDate : assets?.mac!.buildDate; + private string GetAssetBundleManifestBuildDate() => + IPlatform.DEFAULT.Is(IPlatform.Kind.Windows) ? assets?.windows!.buildDate! : assets?.mac!.buildDate!; public bool IsEmpty() => assets?.IsEmpty() ?? true; @@ -249,8 +244,8 @@ public void InjectContent(string entityID, ContentDefinition[] entityDefinitionC // TryAdd so digest-bearing entries from InjectDepsDigests always win, regardless of injection order. for (var i = 0; i < entityDefinitionContent.Length; i++) { - string fileName = (lowerCase ? entityDefinitionContent[i].hash.ToLowerInvariant() : entityDefinitionContent[i].hash) + platformSuffix; - cdnFiles.TryAdd(fileName, fileName); + string hash = entityDefinitionContent[i].hash; + cdnFiles.TryAdd(hash, (lowerCase ? hash.ToLowerInvariant() : hash) + platformSuffix); } } } From 5d49d2a715531af5e76da12c620edb00099cc732 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Mon, 20 Jul 2026 17:46:41 -0300 Subject: [PATCH 14/24] fix: keep the sceneID path segment for v49+ asset bundle URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the org and zone CDNs only serve the nested {version}/{sceneID}/{file} path for v49 — the flat path 404s — so v49+ must keep HasHashInPath until the converter publishes a flat layout. Co-Authored-By: Claude Fable 5 --- .../Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 483773e608d..a371a52b2f8 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -40,7 +40,7 @@ public class AssetBundleManifestVersion public bool HasHashInPath() { - HasHashInPathValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_REQUIRES_HASH && version < ASSET_BUNDLE_VERSION_SUPPORTS_DEPS_DIGEST ; + HasHashInPathValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_REQUIRES_HASH; return HasHashInPathValue.Value; } From 166c9a3d2121b16e4062b5c91d3157c761b2567b Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 08:57:32 -0300 Subject: [PATCH 15/24] feat: request digest-mapped bundles from the canonical assets/ prefix With ASSET_REUSE the converter uploads every bundle to {version}/assets/{file} (no entity segment); the entity path only survives as a Cloudflare Worker rewrite. Digest-mapped manifests now request the canonical path directly, skipping the rewrite. Manifests without the map (wearables/emotes, pre-v49 scenes, failed digest fetches) carry digest-less hashes that only resolve through the Worker's entity path, so they keep the legacy URL shapes. Verified against the production CDN: both digest-bearing and 2-part files serve under /v49/assets/, and the entity segment is ignored by the rewrite (any value returns the same object). Co-Authored-By: Claude Fable 5 --- ...eAssetBundleLoadingParametersSystemBase.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index 62c5a82e3a6..29bfe4d0a3a 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -1,6 +1,7 @@ using Arch.Core; using AssetManagement; using CommunicationData.URLHelpers; +using DCL.Ipfs; using ECS.Abstract; using ECS.StreamableLoading.Common.Components; using System; @@ -63,7 +64,7 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.CurrentSource = AssetSource.WEB; // Hash was already translated to the canonical CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash. - ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifest.HasHashInPath(), assetBundleIntention.Hash, assetBundleIntention.ParentEntityID, assetBundleIntention.AssetBundleManifest.GetAssetBundleManifestVersion()); + ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifest, assetBundleIntention.Hash, assetBundleIntention.ParentEntityID); assetBundleIntention.CommonArguments = ca; assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifest.ComputeCacheHash(assetBundleIntention.Hash); @@ -78,12 +79,20 @@ private URLAddress GetStreamingAssetsUrl(string hash, URLSubdirectory customSubd ? streamingAssetURL.Append(URLPath.FromString(hash)) : streamingAssetURL.Append(customSubdirectory).Append(URLPath.FromString(hash)); - private URLAddress GetAssetBundleURL(bool hasSceneIDInPath, string hash, string sceneID, string assetBundleManifestVersion) + private URLAddress GetAssetBundleURL(AssetBundleManifestVersion manifest, string hash, string sceneID) { - if (hasSceneIDInPath) - return assetBundlesURL.Append(new URLPath($"{assetBundleManifestVersion}/{sceneID}/{hash}")); + string version = manifest.GetAssetBundleManifestVersion(); - return assetBundlesURL.Append(new URLPath($"{assetBundleManifestVersion}/{hash}")); + // Digest-mapped bundles live under the canonical assets/ prefix (no entity segment) — requesting it + // directly skips the edge rewrite. Manifests without the map (wearables/emotes, pre-v49 scenes) carry + // digest-less hashes that only resolve through the entity path, so they keep the legacy shapes. + if (manifest.HasDepsDigests()) + return assetBundlesURL.Append(new URLPath($"{version}/assets/{hash}")); + + if (manifest.HasHashInPath()) + return assetBundlesURL.Append(new URLPath($"{version}/{sceneID}/{hash}")); + + return assetBundlesURL.Append(new URLPath($"{version}/{hash}")); } } From 4a2d7ff9beced58af6b3e03623f58e2d4d670a93 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 10:58:14 -0300 Subject: [PATCH 16/24] refactor: rename HasDepsDigests to HasCanonicalAssets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag signals what the converter actually guarantees: only scenes fetch files[], and only scene bundles upload to the canonical assets/ prefix (asset-reuse is gated on entity.type === 'scene'). The name now says so, and the flag fires for any injected files[] — a reuse-converted scene can legitimately list only 2-part names and still live under assets/. Co-Authored-By: Claude Fable 5 --- ...eAssetBundleLoadingParametersSystemBase.cs | 8 ++++---- .../EditModeTests/DepsDigestCacheKeyShould.cs | 19 ++++++++++--------- .../AssetBundleManifestVersion.cs | 14 +++++++------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index 29bfe4d0a3a..2223fb2a90f 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -83,10 +83,10 @@ private URLAddress GetAssetBundleURL(AssetBundleManifestVersion manifest, string { string version = manifest.GetAssetBundleManifestVersion(); - // Digest-mapped bundles live under the canonical assets/ prefix (no entity segment) — requesting it - // directly skips the edge rewrite. Manifests without the map (wearables/emotes, pre-v49 scenes) carry - // digest-less hashes that only resolve through the entity path, so they keep the legacy shapes. - if (manifest.HasDepsDigests()) + // Canonical-assets bundles live under the assets/ prefix (no entity segment) — requesting it directly + // skips the edge rewrite. Entity-scoped bundles (wearables/emotes, pre-v49 scenes) only resolve through + // the entity path, so they keep the legacy shapes. + if (manifest.HasCanonicalAssets()) return assetBundlesURL.Append(new URLPath($"{version}/assets/{hash}")); if (manifest.HasHashInPath()) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index f4e3527bef4..62acaf02846 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -57,18 +57,19 @@ public void TranslateBareHashCaseInsensitivelyToManifestCasing() } [Test] - public void ReportDepsDigestsOnlyWhenMapWasInjected() + public void ReportCanonicalAssetsOnlyWhenFilesWereInjected() { - // The cache-key dispatch hinges on this: only scene manifests receive files[]; wearables/emotes must keep buildDate keying. - var withoutMap = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - Assert.That(withoutMap.HasDepsDigests(), Is.False); + // The URL shape and cache-key dispatch hinge on this: only scenes fetch files[], and only scene bundles + // are stored under the canonical assets/ prefix; wearables/emotes must keep entity-path URLs and buildDate keying. + var withoutFiles = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); + Assert.That(withoutFiles.HasCanonicalAssets(), Is.False); - AssetBundleManifestVersion withMap = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); - Assert.That(withMap.HasDepsDigests(), Is.True); + AssetBundleManifestVersion withDigestFiles = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); + Assert.That(withDigestFiles.HasCanonicalAssets(), Is.True); - // A manifest whose files[] contained only legacy 2-part names carries no map either. + // Any injected files[] counts — a reuse-converted scene can legitimately list only 2-part names. AssetBundleManifestVersion onlyLegacyFiles = CreateV49Manifest($"{HASH_LEGACY}_mac"); - Assert.That(onlyLegacyFiles.HasDepsDigests(), Is.False); + Assert.That(onlyLegacyFiles.HasCanonicalAssets(), Is.True); } [Test] @@ -107,7 +108,7 @@ public void ResolveQmContentCasingThroughTheSameMap() manifest.InjectContent("Qmf7DaJZRygoayfNn5Jq6QAykrhFpQUr2us2VFvjREiajk", new[] { new ContentDefinition { file = "model.glb", hash = "QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV" } }); - Assert.That(manifest.HasDepsDigests(), Is.False, "Content casing entries must not switch cache keying to the v49 scheme"); + Assert.That(manifest.HasCanonicalAssets(), Is.False, "Content casing entries must not switch the URL shape or cache keying to the canonical scheme"); Assert.That(manifest.GetCdnRequestHash("QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV"), Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv{platform}").IgnoreCase); diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index a371a52b2f8..468011cec0e 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -36,7 +36,7 @@ public class AssetBundleManifestVersion //Bare hash → canonical CDN file name; fed by InjectDepsDigests (digest-bearing names) and InjectContent (Qm casing fixes). private Dictionary? cdnFiles; - private bool hasDepsDigests; + private bool hasCanonicalAssets; public bool HasHashInPath() { @@ -76,6 +76,7 @@ private static bool TryParseVersionNumber(string? version, out int parsed) public void InjectDepsDigests(string[]? files) { if (files == null || files.Length == 0) return; + hasCanonicalAssets = true; foreach (string file in files) { @@ -86,13 +87,12 @@ public void InjectDepsDigests(string[]? files) cdnFiles ??= new Dictionary(new UrlHashComparer()); cdnFiles[parts[0]] = file; - hasDepsDigests = true; } } - /// True when digest-bearing files were injected — only scene manifests fetch files[]; wearables/emotes never do and keep buildDate cache keying. - public bool HasDepsDigests() => - hasDepsDigests; + /// True when the manifest's files[] were injected — only scenes fetch them, and only scene bundles are stored under the canonical assets/ prefix. Wearables/emotes stay entity-scoped and keep buildDate cache keying. + public bool HasCanonicalAssets() => + hasCanonicalAssets; /// Translates a bare hash to the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. public string GetCdnRequestHash(string bareHash) => @@ -102,9 +102,9 @@ public string GetCdnRequestHash(string bareHash) => public string ComposeCacheKey(string hash) => TryGetCdnFileName(hash, out string fileName) ? fileName : hash; - /// Computes the Unity-cache key for a CDN request hash: scene manifests (deps map present) key on version+hash — the digest travels inside the hash — while wearables/emotes keep buildDate keying, as their bundles are republished in place. + /// Computes the Unity-cache key for a CDN request hash: canonical-assets manifests key on version+hash — the digest travels inside the hash — while wearables/emotes keep buildDate keying, as their bundles are republished in place. public Hash128 ComputeCacheHash(string hash) => - HasDepsDigests() + HasCanonicalAssets() ? ComputeHashV49(hash, GetAssetBundleManifestVersion()!) : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate()!); From af6169bb5f46114d898cd0c7bcdcf2a76956934d Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 11:05:07 -0300 Subject: [PATCH 17/24] refactor: treat v49+ files[] as the complete canonical listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With asset reuse permanently on, a v49+ manifest's files[] are exactly the names bundles live under on the assets/ prefix — so InjectDepsDigests now maps every platform-suffixed entry (2-part textures/bins included), giving all scene files verbatim manifest names and casing. The injection also self-gates on v49+: hybrid/world flows inject files[] for whatever version they fetched, and a pre-v49 manifest flagged as canonical would 404 under assets/ where its entity-scoped bundles don't exist. Co-Authored-By: Claude Fable 5 --- .../EditModeTests/DepsDigestCacheKeyShould.cs | 25 +++++++++++++------ .../AssetBundleManifestVersion.cs | 13 +++++++--- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 62acaf02846..bc1dc9f14ca 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -38,7 +38,7 @@ public void TranslateBareHashToCdnRequestHash() Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}_{DIGEST_A}{platform}")); Assert.That(manifest.GetCdnRequestHash(HASH_LEGACY), Is.EqualTo($"{HASH_LEGACY}{platform}"), - "Files listed without a digest must fall back to the platform-suffixed bare hash"); + "Files listed without a digest resolve to their verbatim 2-part manifest name"); Assert.That(manifest.GetCdnRequestHash(HASH_B), Is.EqualTo($"{HASH_B}{platform}"), "Hashes absent from the manifest must fall back to the platform-suffixed bare hash"); @@ -239,14 +239,25 @@ public void ComputeDifferentCacheHashWhenVersionDiffers() [Test] public void ComputeDifferentCacheHashAcrossVersionHashBoundary() { - // Without the delimiter, (version="v4", hash="9foo") and (version="v49", hash="foo") would produce the same byte stream. - var v4 = AssetBundleManifestVersion.CreateFromFallback("v4", "2026-05-01"); - v4.InjectDepsDigests(new[] { $"9foo_{DIGEST_A}_mac" }); + // Without the delimiter, (version="v49", hash="0foo") and (version="v490", hash="foo") would produce the same byte stream. + AssetBundleManifestVersion v49 = CreateV49Manifest($"0foo_{DIGEST_A}_mac"); - var v49 = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - v49.InjectDepsDigests(new[] { $"foo_{DIGEST_A}_mac" }); + var v490 = AssetBundleManifestVersion.CreateFromFallback("v490", "2026-05-01"); + v490.InjectDepsDigests(new[] { $"foo_{DIGEST_A}_mac" }); - Assert.That(v4.ComputeCacheHash("9foo"), Is.Not.EqualTo(v49.ComputeCacheHash("foo"))); + Assert.That(v49.ComputeCacheHash("0foo"), Is.Not.EqualTo(v490.ComputeCacheHash("foo"))); + } + + [Test] + public void IgnoreInjectedFilesOnPreV49Manifests() + { + // Hybrid/world flows inject files[] for whatever version they fetched — pre-v49 bundles are + // entity-scoped, so they must not flag canonical assets nor resolve to manifest names. + var manifest = AssetBundleManifestVersion.CreateFromFallback("v48", "2026-05-01"); + manifest.InjectDepsDigests(new[] { $"{HASH_A}_{DIGEST_A}_mac" }); + + Assert.That(manifest.HasCanonicalAssets(), Is.False); + Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}{PlatformUtils.GetCurrentPlatform()}")); } [Test] diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 468011cec0e..959258d6661 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -72,18 +72,23 @@ private static bool TryParseVersionNumber(string? version, out int parsed) return int.TryParse(version.AsSpan(1), out parsed); } - /// Stores, keyed by the bare hash, the verbatim digest-bearing file name (<hash>_<depsDigest>_<platform>) from the manifest's files[]; legacy 2-part names are skipped. + /// + /// Stores the manifest's files[] — the verbatim canonical names bundles live under on the CDN's + /// assets/ prefix — keyed by the bare hash. Pre-v49 manifests are ignored: their bundles are + /// entity-scoped and their URLs must keep the legacy shapes. + /// public void InjectDepsDigests(string[]? files) { - if (files == null || files.Length == 0) return; + if (!SupportsDepsDigests() || files == null || files.Length == 0) return; hasCanonicalAssets = true; foreach (string file in files) { if (string.IsNullOrEmpty(file)) continue; - string[] parts = file.Split(FILE_NAME_SEPARATOR, 3); - if (parts.Length < 3) continue; + // Non-suffixed entries (build logs, folders) carry no hash to key by. + string[] parts = file.Split(FILE_NAME_SEPARATOR, 2); + if (parts.Length < 2) continue; cdnFiles ??= new Dictionary(new UrlHashComparer()); cdnFiles[parts[0]] = file; From ebcc05e94bcda27724d1ed3c14d5883afe32df6c Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 11:08:09 -0300 Subject: [PATCH 18/24] refactor: clarify the digest loader's version check as a network short-circuit InjectDepsDigests self-gates on v49+ (it has a second, ungated caller in the hybrid flow), so the loader's check only exists to skip a wasted manifest download for pre-v49 scenes. Its null-check goes through the OrFailed accessor instead. Co-Authored-By: Claude Fable 5 --- .../AssetBundles/SceneAssetBundleDigestsLoader.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs index aa576d3d1f2..301b1283205 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs @@ -22,10 +22,10 @@ public static class SceneAssetBundleDigestsLoader { public static async UniTask EnsureDepsDigestsAsync(World world, EntityDefinitionBase entityDefinition, IPartitionComponent partition, CancellationToken ct) { - AssetBundleManifestVersion? manifestVersion = entityDefinition.assetBundleManifestVersion; + AssetBundleManifestVersion manifestVersion = entityDefinition.AssetBundleManifestVersionOrFailed; - // Pre-v49 manifests have no deps digest, and CreateFailed/CreateManualManifest entries report version < v49 as well — short-circuit those. - if (manifestVersion == null || !manifestVersion.SupportsDepsDigests()) + // Network short-circuit only — InjectDepsDigests self-gates on v49+, but fetching the manifest JSON for a pre-v49 scene would be a wasted round-trip. + if (!manifestVersion.SupportsDepsDigests()) return; //Needed to use the UnityEngine.Time.realtimeSinceStartup on the intention creation From a3c1fa98802a71fb18323aac5c62ddf87c4aa07e Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 11:10:38 -0300 Subject: [PATCH 19/24] refactor: gate InjectDepsDigests at the call sites The v49+ check lives with the callers: the digests loader already gates (it also skips the manifest download), and the hybrid flow now gates its injection too. InjectDepsDigests just stores what it is given. Co-Authored-By: Claude Fable 5 --- .../AssetBundles/SceneAssetBundleDigestsLoader.cs | 2 +- .../Tests/EditModeTests/DepsDigestCacheKeyShould.cs | 12 ------------ .../Systems/LoadHybridSceneSystemLogic.cs | 5 ++++- .../NetworkDefinitions/AssetBundleManifestVersion.cs | 6 +++--- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs index 301b1283205..51f9fcd451a 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/SceneAssetBundleDigestsLoader.cs @@ -24,7 +24,7 @@ public static async UniTask EnsureDepsDigestsAsync(World world, EntityDefinition { AssetBundleManifestVersion manifestVersion = entityDefinition.AssetBundleManifestVersionOrFailed; - // Network short-circuit only — InjectDepsDigests self-gates on v49+, but fetching the manifest JSON for a pre-v49 scene would be a wasted round-trip. + // Pre-v49 manifests have no canonical assets/ layout — skip both the manifest download and the injection. if (!manifestVersion.SupportsDepsDigests()) return; diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index bc1dc9f14ca..2532bae3ebc 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -248,18 +248,6 @@ public void ComputeDifferentCacheHashAcrossVersionHashBoundary() Assert.That(v49.ComputeCacheHash("0foo"), Is.Not.EqualTo(v490.ComputeCacheHash("foo"))); } - [Test] - public void IgnoreInjectedFilesOnPreV49Manifests() - { - // Hybrid/world flows inject files[] for whatever version they fetched — pre-v49 bundles are - // entity-scoped, so they must not flag canonical assets nor resolve to manifest names. - var manifest = AssetBundleManifestVersion.CreateFromFallback("v48", "2026-05-01"); - manifest.InjectDepsDigests(new[] { $"{HASH_A}_{DIGEST_A}_mac" }); - - Assert.That(manifest.HasCanonicalAssets(), Is.False); - Assert.That(manifest.GetCdnRequestHash(HASH_A), Is.EqualTo($"{HASH_A}{PlatformUtils.GetCurrentPlatform()}")); - } - [Test] public void ComputeCacheHashFromBuildDateWithoutDepsMap() { diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadHybridSceneSystemLogic.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadHybridSceneSystemLogic.cs index 62605e63b38..b4710ddeff7 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadHybridSceneSystemLogic.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadHybridSceneSystemLogic.cs @@ -82,7 +82,10 @@ private async UniTask FetchRemoteAssetBundleVersionAsync(SceneEntityDefinition d definition.assetBundleManifestVersion = AssetBundleManifestVersion.CreateFromFallback(manifest.Version, manifest.Date); definition.assetBundleManifestVersion.InjectContent(sceneId, definition.content); - definition.assetBundleManifestVersion.InjectDepsDigests(manifest.files); + + // Pre-v49 bundles are entity-scoped — injecting their files[] would wrongly flag canonical assets. + if (definition.assetBundleManifestVersion.SupportsDepsDigests()) + definition.assetBundleManifestVersion.InjectDepsDigests(manifest.files); } catch (OperationCanceledException) { } catch (Exception e) { ReportHub.LogException(e, ReportCategory.SCENE_LOADING); } diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 959258d6661..8d2177c70e9 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -74,12 +74,12 @@ private static bool TryParseVersionNumber(string? version, out int parsed) /// /// Stores the manifest's files[] — the verbatim canonical names bundles live under on the CDN's - /// assets/ prefix — keyed by the bare hash. Pre-v49 manifests are ignored: their bundles are - /// entity-scoped and their URLs must keep the legacy shapes. + /// assets/ prefix — keyed by the bare hash. Callers gate on : + /// pre-v49 bundles are entity-scoped and must not be flagged canonical. /// public void InjectDepsDigests(string[]? files) { - if (!SupportsDepsDigests() || files == null || files.Length == 0) return; + if (files == null || files.Length == 0) return; hasCanonicalAssets = true; foreach (string file in files) From bafeeab9374e5b81dcb5ee2660c3e83b7927996d Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 11:30:16 -0300 Subject: [PATCH 20/24] Remove wrong Resharper disable --- .../Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 8d2177c70e9..9edf6d171e4 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -5,7 +5,6 @@ using UnityEngine; using Utility; -// ReSharper disable once CheckNamespace namespace DCL.Ipfs { public class AssetBundleManifestVersion From 077fdefadd7c9e859a432caca9656b170eb7dca9 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Tue, 21 Jul 2026 17:05:54 -0300 Subject: [PATCH 21/24] fix: address review comments - cache the failed-manifest sentinel instead of allocating per access - drop redundant null-forgiving operators in ComputeCacheHash - justify the remaining ! operators on the platform-info getters - reword the TryAdd comment to describe the line, not its callers Co-Authored-By: Claude Fable 5 --- .../DCL/NetworkDefinitions/AssetBundleManifestVersion.cs | 8 +++++--- .../Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 9edf6d171e4..efb0a61c2c6 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -109,8 +109,8 @@ public string ComposeCacheKey(string hash) => /// Computes the Unity-cache key for a CDN request hash: canonical-assets manifests key on version+hash — the digest travels inside the hash — while wearables/emotes keep buildDate keying, as their bundles are republished in place. public Hash128 ComputeCacheHash(string hash) => HasCanonicalAssets() - ? ComputeHashV49(hash, GetAssetBundleManifestVersion()!) - : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate()!); + ? ComputeHashV49(hash, GetAssetBundleManifestVersion()) + : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate()); private static unsafe Hash128 ComputeHashV49(string hash, string version) { @@ -147,9 +147,11 @@ private bool TryGetCdnFileName(string bareHash, out string fileName) return false; } + //! safe: every factory (CreateFromFallback, CreateFailed, CreateManualManifest, CreateForLOD) sets the current platform's info, and deserialized manifests carry both platforms. public string GetAssetBundleManifestVersion() => IPlatform.DEFAULT.Is(IPlatform.Kind.Windows) ? assets?.windows!.version! : assets?.mac!.version!; + //! safe: same factory invariant as GetAssetBundleManifestVersion. private string GetAssetBundleManifestBuildDate() => IPlatform.DEFAULT.Is(IPlatform.Kind.Windows) ? assets?.windows!.buildDate! : assets?.mac!.buildDate!; @@ -245,7 +247,7 @@ public void InjectContent(string entityID, ContentDefinition[] entityDefinitionC string platformSuffix = PlatformUtils.GetCurrentPlatform(); bool lowerCase = IPlatform.DEFAULT.Is(IPlatform.Kind.Mac); - // TryAdd so digest-bearing entries from InjectDepsDigests always win, regardless of injection order. + // TryAdd keeps the first entry for each key; a digest-bearing name already stored by InjectDepsDigests is never overwritten. for (var i = 0; i < entityDefinitionContent.Length; i++) { string hash = entityDefinitionContent[i].hash; diff --git a/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs b/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs index 6f6e44b9c2e..561a6332b4f 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs @@ -39,9 +39,12 @@ public class TrimmedEntityDefinitionBase [JsonProperty("versions")] public AssetBundleManifestVersion? assetBundleManifestVersion; + //Cached: CreateFailed() allocates three objects and this property is read from per-entity-load paths. + private static readonly AssetBundleManifestVersion FAILED_MANIFEST = AssetBundleManifestVersion.CreateFailed(); + /// The manifest version, or a failed sentinel when none was resolved — AB intentions require a manifest, and the sentinel is the same dead end the pipeline already handles. [JsonIgnore] - public AssetBundleManifestVersion AssetBundleManifestVersionOrFailed => assetBundleManifestVersion ?? AssetBundleManifestVersion.CreateFailed(); + public AssetBundleManifestVersion AssetBundleManifestVersionOrFailed => assetBundleManifestVersion ?? FAILED_MANIFEST; [JsonProperty("status")] public AssetBundleRegistryEnum assetBundleRegistryEnum; From d6dde5a0a396187aa02aa31a1affeefb7d1d369b Mon Sep 17 00:00:00 2001 From: Juan Ignacio Molteni Date: Thu, 23 Jul 2026 11:12:27 -0300 Subject: [PATCH 22/24] Update Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs Co-authored-by: Muna <44584806+decentraland-bot@users.noreply.github.com> Signed-off-by: Juan Ignacio Molteni --- .../StreamableLoading/AssetBundles/GetAssetBundleIntention.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs index c97707a11a5..bae6187edc3 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs @@ -3,7 +3,6 @@ using DCL.Ipfs; using ECS.StreamableLoading.Cache.Disk.Cacheables; using ECS.StreamableLoading.Common.Components; -using SceneRunner.Scene; using System; using System.Threading; using UnityEngine; From 869a81fb4010c68040275ceb6de556eba210d477 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Thu, 23 Jul 2026 11:58:19 -0300 Subject: [PATCH 23/24] refactor: back the intention manifest with a failed-sentinel fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default-initialized structs zero reference fields regardless of annotations, so the non-nullable AssetBundleManifest field could still observe null through default(GetAssetBundleIntention). It becomes a private nullable backing field with a readonly property falling back to the shared AssetBundleManifestVersion.FAILED sentinel — the same dead end PrepareCommonArguments already reports — making the annotation truthful for every observer. The sentinel is safe to share because injections no-op on failed manifests (they serve no file names). Every consumer now uses it (fallback helper, ISS descriptor intention, OrFailed accessor), so CreateFailed becomes a private detail of the sentinel. Co-Authored-By: Claude Fable 5 --- .../AssetBundles/AssetBundleManifestFallbackHelper.cs | 2 +- .../AssetBundles/GetAssetBundleIntention.cs | 11 +++++++---- .../InitialSceneState/GetISSDescriptorIntention.cs | 2 +- .../NetworkDefinitions/AssetBundleManifestVersion.cs | 8 +++++++- .../DCL/NetworkDefinitions/EntityDefinitionBase.cs | 7 ++----- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/AssetBundleManifestFallbackHelper.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/AssetBundleManifestFallbackHelper.cs index ffbe01b0264..3438091a332 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/AssetBundleManifestFallbackHelper.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/AssetBundleManifestFallbackHelper.cs @@ -58,7 +58,7 @@ private static async UniTask CheckAssetBundleManifestFallbackInternalAsync(World else { assetBundleManifest.TryLogException(); - entityDefinition.assetBundleManifestVersion = AssetBundleManifestVersion.CreateFailed(); + entityDefinition.assetBundleManifestVersion = AssetBundleManifestVersion.FAILED; } } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs index bae6187edc3..294b43f755a 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/GetAssetBundleIntention.cs @@ -13,10 +13,11 @@ public struct GetAssetBundleIntention : ILoadingIntention, IEquatable /// If the expected object type is null we don't know which asset will be loaded. /// It's valid for dependencies for which we need to load the asset bundle itself only @@ -55,7 +56,7 @@ private GetAssetBundleIntention(Type? expectedObjectType, AssetBundleManifestVer cacheHash = null; ParentEntityID = parentEntityID; - AssetBundleManifest = assetBundle; + assetBundleManifest = assetBundle; IsDependency = isDependency; LookForDependencies = lookForDependencies; } @@ -63,9 +64,11 @@ private GetAssetBundleIntention(Type? expectedObjectType, AssetBundleManifestVer internal GetAssetBundleIntention(CommonLoadingArguments commonArguments) : this() { CommonArguments = commonArguments; - AssetBundleManifest = AssetBundleManifestVersion.CreateManualManifest(); } + /// An AB can never be requested without a manifest: every factory sets one, and default-initialized structs (which never flow into loading) observe the failed sentinel. + public readonly AssetBundleManifestVersion AssetBundleManifest => assetBundleManifest ?? AssetBundleManifestVersion.FAILED; + // Hash alone identifies the bundle: v49+ hashes carry the deps digest inside the file name, so two dependency closures never share a Hash. public bool Equals(GetAssetBundleIntention other) => StringComparer.OrdinalIgnoreCase.Equals(Hash, other.Hash); diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/GetISSDescriptorIntention.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/GetISSDescriptorIntention.cs index 60c73dcc003..500ade1bb08 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/GetISSDescriptorIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/InitialSceneState/GetISSDescriptorIntention.cs @@ -34,7 +34,7 @@ public GetISSDescriptorIntention(string sceneId, AssetBundleManifestVersion mani // Manifest is required: callers reach this only after the scene definition (and therefore its manifest) // has been loaded. A valid manifest should be available at this point public static GetISSDescriptorIntention For(SceneEntityDefinition definition) => - new (definition.id, definition.assetBundleManifestVersion ?? AssetBundleManifestVersion.CreateFailed()); + new (definition.id, definition.assetBundleManifestVersion ?? AssetBundleManifestVersion.FAILED); public bool Equals(GetISSDescriptorIntention other) => SceneId == other.SceneId; diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index efb0a61c2c6..00abf8a6a4d 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -23,6 +23,9 @@ public class AssetBundleManifestVersion public static readonly int AB_MIN_SUPPORTED_VERSION_WINDOWS = 15; public static readonly int AB_MIN_SUPPORTED_VERSION_MAC = 16; + //Shared sentinel for paths that require a manifest but have none; injections no-op on failed manifests, so the instance stays immutable. + public static readonly AssetBundleManifestVersion FAILED = CreateFailed(); + private static readonly char[] FILE_NAME_SEPARATOR = { '_' }; private bool? HasHashInPathValue; @@ -158,7 +161,7 @@ private string GetAssetBundleManifestBuildDate() => public bool IsEmpty() => assets?.IsEmpty() ?? true; - public static AssetBundleManifestVersion CreateFailed() + private static AssetBundleManifestVersion CreateFailed() { //All AB requests will fail when this occurs; its a dead end var failedAssets = new AssetBundleManifestVersionPerPlatform(); @@ -232,6 +235,9 @@ public static AssetBundleManifestVersion CreateForLOD(string assetBundleManifest public void InjectContent(string entityID, ContentDefinition[] entityDefinitionContent) { + // A failed manifest serves no file names — and it can be the shared FAILED sentinel, which must never be mutated. + if (assetBundleManifestRequestFailed) return; + // TODO (JUANI): hack, for older Qm. Doesnt happen with bafk because they are all lowercase // This has a long due capitalization problem. The hash in Mac which is requested should always be lower case, since the output files are lowercase and the // request to S3 is case sensitive. diff --git a/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs b/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs index 561a6332b4f..57a0ba07c9c 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/EntityDefinitionBase.cs @@ -39,12 +39,9 @@ public class TrimmedEntityDefinitionBase [JsonProperty("versions")] public AssetBundleManifestVersion? assetBundleManifestVersion; - //Cached: CreateFailed() allocates three objects and this property is read from per-entity-load paths. - private static readonly AssetBundleManifestVersion FAILED_MANIFEST = AssetBundleManifestVersion.CreateFailed(); - - /// The manifest version, or a failed sentinel when none was resolved — AB intentions require a manifest, and the sentinel is the same dead end the pipeline already handles. + /// The manifest version, or the failed sentinel when none was resolved — AB intentions require a manifest, and the sentinel is the same dead end the pipeline already handles. [JsonIgnore] - public AssetBundleManifestVersion AssetBundleManifestVersionOrFailed => assetBundleManifestVersion ?? FAILED_MANIFEST; + public AssetBundleManifestVersion AssetBundleManifestVersionOrFailed => assetBundleManifestVersion ?? AssetBundleManifestVersion.FAILED; [JsonProperty("status")] public AssetBundleRegistryEnum assetBundleRegistryEnum; From 883607479343a906f9ba8a98856365a68b5ff204 Mon Sep 17 00:00:00 2001 From: Juan Molteni Date: Thu, 23 Jul 2026 12:24:11 -0300 Subject: [PATCH 24/24] refactor: move CDN path building into AssetBundleManifestVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest owns all three URL shapes now (GetCdnRequestPath), so the prepare system just appends the relative path to the domain. That makes HasHashInPath private and dissolves HasCanonicalAssets into a private hasReusableAssets field — renamed because "canonical" was converter jargon; what the flag really tracks is that the bundles went through asset reuse, which is why they live under the shared assets/ prefix and cache-key on version+hash. Tests assert the path shapes behaviorally instead of poking the flags. Co-Authored-By: Claude Fable 5 --- ...eAssetBundleLoadingParametersSystemBase.cs | 21 ++--------- .../EditModeTests/DepsDigestCacheKeyShould.cs | 20 ++++++----- .../AssetBundleManifestVersion.cs | 36 ++++++++++++------- 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs index 2223fb2a90f..f423fb8a4f1 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/PrepareAssetBundleLoadingParametersSystemBase.cs @@ -1,7 +1,6 @@ using Arch.Core; using AssetManagement; using CommunicationData.URLHelpers; -using DCL.Ipfs; using ECS.Abstract; using ECS.StreamableLoading.Common.Components; using System; @@ -63,8 +62,8 @@ protected void PrepareCommonArguments(in Entity entity, ref GetAssetBundleIntent ca.Timeout = StreamableLoadingDefaults.TIMEOUT; ca.CurrentSource = AssetSource.WEB; - // Hash was already translated to the canonical CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash. - ca.URL = GetAssetBundleURL(assetBundleIntention.AssetBundleManifest, assetBundleIntention.Hash, assetBundleIntention.ParentEntityID); + // Hash was already translated to the CDN file name (digest and Qm casing) at intention creation via GetCdnRequestHash. + ca.URL = assetBundlesURL.Append(new URLPath(assetBundleIntention.AssetBundleManifest.GetCdnRequestPath(assetBundleIntention.Hash, assetBundleIntention.ParentEntityID))); assetBundleIntention.CommonArguments = ca; assetBundleIntention.cacheHash = assetBundleIntention.AssetBundleManifest.ComputeCacheHash(assetBundleIntention.Hash); @@ -79,21 +78,5 @@ private URLAddress GetStreamingAssetsUrl(string hash, URLSubdirectory customSubd ? streamingAssetURL.Append(URLPath.FromString(hash)) : streamingAssetURL.Append(customSubdirectory).Append(URLPath.FromString(hash)); - private URLAddress GetAssetBundleURL(AssetBundleManifestVersion manifest, string hash, string sceneID) - { - string version = manifest.GetAssetBundleManifestVersion(); - - // Canonical-assets bundles live under the assets/ prefix (no entity segment) — requesting it directly - // skips the edge rewrite. Entity-scoped bundles (wearables/emotes, pre-v49 scenes) only resolve through - // the entity path, so they keep the legacy shapes. - if (manifest.HasCanonicalAssets()) - return assetBundlesURL.Append(new URLPath($"{version}/assets/{hash}")); - - if (manifest.HasHashInPath()) - return assetBundlesURL.Append(new URLPath($"{version}/{sceneID}/{hash}")); - - return assetBundlesURL.Append(new URLPath($"{version}/{hash}")); - } - } } diff --git a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs index 2532bae3ebc..dfca17d5017 100644 --- a/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/ECS/Unity/StreamableLoading/AssetBundles/Tests/EditModeTests/DepsDigestCacheKeyShould.cs @@ -57,19 +57,21 @@ public void TranslateBareHashCaseInsensitivelyToManifestCasing() } [Test] - public void ReportCanonicalAssetsOnlyWhenFilesWereInjected() + public void RouteReusableAssetsThroughTheSharedAssetsPath() { - // The URL shape and cache-key dispatch hinge on this: only scenes fetch files[], and only scene bundles - // are stored under the canonical assets/ prefix; wearables/emotes must keep entity-path URLs and buildDate keying. + const string SCENE_ID = "sceneId"; + + // Only scenes fetch files[], and only reuse-converted bundles live under the shared assets/ prefix; + // manifests without injected files keep the entity path. var withoutFiles = AssetBundleManifestVersion.CreateFromFallback("v49", "2026-05-01"); - Assert.That(withoutFiles.HasCanonicalAssets(), Is.False); + Assert.That(withoutFiles.GetCdnRequestPath(HASH_A, SCENE_ID), Is.EqualTo($"v49/{SCENE_ID}/{HASH_A}")); AssetBundleManifestVersion withDigestFiles = CreateV49Manifest($"{HASH_A}_{DIGEST_A}_mac"); - Assert.That(withDigestFiles.HasCanonicalAssets(), Is.True); + Assert.That(withDigestFiles.GetCdnRequestPath(HASH_A, SCENE_ID), Is.EqualTo($"v49/assets/{HASH_A}")); // Any injected files[] counts — a reuse-converted scene can legitimately list only 2-part names. AssetBundleManifestVersion onlyLegacyFiles = CreateV49Manifest($"{HASH_LEGACY}_mac"); - Assert.That(onlyLegacyFiles.HasCanonicalAssets(), Is.True); + Assert.That(onlyLegacyFiles.GetCdnRequestPath(HASH_LEGACY, SCENE_ID), Is.EqualTo($"v49/assets/{HASH_LEGACY}")); } [Test] @@ -108,7 +110,8 @@ public void ResolveQmContentCasingThroughTheSameMap() manifest.InjectContent("Qmf7DaJZRygoayfNn5Jq6QAykrhFpQUr2us2VFvjREiajk", new[] { new ContentDefinition { file = "model.glb", hash = "QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV" } }); - Assert.That(manifest.HasCanonicalAssets(), Is.False, "Content casing entries must not switch the URL shape or cache keying to the canonical scheme"); + Assert.That(manifest.GetCdnRequestPath(HASH_A, "sceneId"), Is.EqualTo($"v35/sceneId/{HASH_A}"), + "Content casing entries must not switch the URL shape or cache keying to the reusable scheme"); Assert.That(manifest.GetCdnRequestHash("QmaBrb8WisG9b4Szzt6ACHgaJdyULTEjpzmTwDi4RCEtZV"), Is.EqualTo($"qmabrb8wisg9b4szzt6achgajdyultejpzmtwdi4rcetzv{platform}").IgnoreCase); @@ -275,12 +278,11 @@ public void VersionPredicates_DoNotThrowOnNonVNVersions() //Check that when handling a LOD it doesn't throw var lodManifest = AssetBundleManifestVersion.CreateForLOD("LOD/0", "dummyDate"); - Assert.That(lodManifest.HasHashInPath(), Is.False); + Assert.That(lodManifest.GetCdnRequestPath(HASH_A, "sceneId"), Is.EqualTo($"LOD/0/{HASH_A}"), "Unparseable versions must keep the flat legacy path"); Assert.That(lodManifest.SupportsDepsDigests(), Is.False); var manualManifest = AssetBundleManifestVersion.CreateManualManifest(); - Assert.That(manualManifest.HasHashInPath(), Is.False); Assert.That(manualManifest.SupportsDepsDigests(), Is.False); var wrongString = AssetBundleManifestVersion.CreateFromFallback("v", "dummyDate"); diff --git a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs index 00abf8a6a4d..e2dba0a30e5 100644 --- a/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs +++ b/Explorer/Assets/DCL/NetworkDefinitions/AssetBundleManifestVersion.cs @@ -36,11 +36,13 @@ public class AssetBundleManifestVersion public bool IsLSDAsset; public AssetBundleManifestVersionPerPlatform? assets; - //Bare hash → canonical CDN file name; fed by InjectDepsDigests (digest-bearing names) and InjectContent (Qm casing fixes). + //Bare hash → CDN file name; fed by InjectDepsDigests (digest-bearing names) and InjectContent (Qm casing fixes). private Dictionary? cdnFiles; - private bool hasCanonicalAssets; - public bool HasHashInPath() + //Set when the manifest's files[] were injected — only scenes fetch them. Reusable bundles live under the shared assets/ prefix and cache-key on version+hash; wearables/emotes stay entity-scoped and keep buildDate keying. + private bool hasReusableAssets; + + private bool HasHashInPath() { HasHashInPathValue ??= TryParseVersionNumber(GetAssetBundleManifestVersion(), out int version) && version >= ASSET_BUNDLE_VERSION_REQUIRES_HASH; return HasHashInPathValue.Value; @@ -75,14 +77,14 @@ private static bool TryParseVersionNumber(string? version, out int parsed) } /// - /// Stores the manifest's files[] — the verbatim canonical names bundles live under on the CDN's + /// Stores the manifest's files[] — the verbatim names bundles live under on the CDN's shared /// assets/ prefix — keyed by the bare hash. Callers gate on : - /// pre-v49 bundles are entity-scoped and must not be flagged canonical. + /// pre-v49 bundles are entity-scoped and must not be flagged reusable. /// public void InjectDepsDigests(string[]? files) { if (files == null || files.Length == 0) return; - hasCanonicalAssets = true; + hasReusableAssets = true; foreach (string file in files) { @@ -97,10 +99,6 @@ public void InjectDepsDigests(string[]? files) } } - /// True when the manifest's files[] were injected — only scenes fetch them, and only scene bundles are stored under the canonical assets/ prefix. Wearables/emotes stay entity-scoped and keep buildDate cache keying. - public bool HasCanonicalAssets() => - hasCanonicalAssets; - /// Translates a bare hash to the hash requested from the CDN: the canonical manifest file name when known (digest-bearing, correctly cased), otherwise the platform-suffixed bare hash. public string GetCdnRequestHash(string bareHash) => TryGetCdnFileName(bareHash, out string fileName) ? fileName : $"{bareHash}{PlatformUtils.GetCurrentPlatform()}"; @@ -109,12 +107,26 @@ public string GetCdnRequestHash(string bareHash) => public string ComposeCacheKey(string hash) => TryGetCdnFileName(hash, out string fileName) ? fileName : hash; - /// Computes the Unity-cache key for a CDN request hash: canonical-assets manifests key on version+hash — the digest travels inside the hash — while wearables/emotes keep buildDate keying, as their bundles are republished in place. + /// Computes the Unity-cache key for a CDN request hash: reusable bundles key on version+hash — the digest travels inside the hash, so the cache is shareable across republishes — while wearables/emotes keep buildDate keying, as their bundles are republished in place. public Hash128 ComputeCacheHash(string hash) => - HasCanonicalAssets() + hasReusableAssets ? ComputeHashV49(hash, GetAssetBundleManifestVersion()) : ComputeHashLegacy(hash, GetAssetBundleManifestBuildDate()); + /// Builds the CDN-relative path for a request hash: reusable bundles live under the shared assets/ prefix (no entity segment), entity-scoped bundles (wearables/emotes, pre-v49 scenes) keep the legacy shapes. + public string GetCdnRequestPath(string hash, string sceneID) + { + string version = GetAssetBundleManifestVersion(); + + if (hasReusableAssets) + return $"{version}/assets/{hash}"; + + if (HasHashInPath()) + return $"{version}/{sceneID}/{hash}"; + + return $"{version}/{hash}"; + } + private static unsafe Hash128 ComputeHashV49(string hash, string version) { // The digest embedded in the hash replaces buildDate-based invalidation, keeping the cache shareable across CDN republishes; the delimiter prevents version/hash boundary collisions.