diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs b/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs
index 394eb1ccb8..bb0316bb99 100644
--- a/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs
+++ b/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs
@@ -53,6 +53,15 @@ namespace BrewMonster.Scripts
base.Initialize();
_isInitialized = false;
_initializationTcs = new UniTaskCompletionSource();
+ StartAddressablesInitAfterBootstrapGate().Forget();
+ }
+
+ ///
+ /// Waits for (version HTTP + optional URL rewrite) when that gate is active.
+ ///
+ async UniTaskVoid StartAddressablesInitAfterBootstrapGate()
+ {
+ await GameContentBootstrap.WaitForPreAddressablesSetupIfAnyAsync();
Addressables.InitializeAsync().Completed += OnInitializeComplete;
}
diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs
new file mode 100644
index 0000000000..20b29d3fd7
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using BrewMonster;
+using Cysharp.Threading.Tasks;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.AddressableAssets.ResourceLocators;
+using UnityEngine.ResourceManagement.AsyncOperations;
+
+namespace BrewMonster.Scripts
+{
+ ///
+ /// Remote catalog / bundle flows: CheckForCatalogUpdates, UpdateCatalogs, optional download size and dependencies.
+ /// Matches the “catalog new → bundle new / cache” behaviour described in project docs (Addressables overview).
+ ///
+ public static class AddressablesCatalogUpdater
+ {
+ ///
+ /// Result of checking the remote catalog hash list (may be empty when nothing changed).
+ ///
+ public readonly struct CatalogCheckResult
+ {
+ public CatalogCheckResult(bool success, IReadOnlyList catalogsWithUpdates, Exception error)
+ {
+ Success = success;
+ CatalogsWithUpdates = catalogsWithUpdates ?? Array.Empty();
+ Error = error;
+ }
+
+ public bool Success { get; }
+ public IReadOnlyList CatalogsWithUpdates { get; }
+ public bool HasUpdates => CatalogsWithUpdates.Count > 0;
+ public Exception Error { get; }
+ }
+
+ ///
+ /// Result after applying .
+ ///
+ public readonly struct CatalogApplyResult
+ {
+ public CatalogApplyResult(bool success, IReadOnlyList locators, Exception error)
+ {
+ Success = success;
+ Locators = locators ?? Array.Empty();
+ Error = error;
+ }
+
+ public bool Success { get; }
+ public IReadOnlyList Locators { get; }
+ public Exception Error { get; }
+ }
+
+ ///
+ /// Ensures Addressables finished initial catalog load before checking remote updates.
+ ///
+ public static async UniTask EnsureInitializedAsync()
+ {
+ await Addressables.InitializeAsync().ToUniTask();
+ }
+
+ ///
+ /// Calls — lightweight compared to full bundle downloads.
+ ///
+ /// Pass through to Addressables (default true).
+ public static async UniTask CheckForCatalogUpdatesAsync(bool autoReleaseHandle = true)
+ {
+ try
+ {
+ await EnsureInitializedAsync();
+ var handle = Addressables.CheckForCatalogUpdates(autoReleaseHandle);
+ await handle.ToUniTask();
+ if (handle.Status != AsyncOperationStatus.Succeeded)
+ {
+ var err = handle.OperationException;
+ BMLogger.LogError($"AddressablesCatalogUpdater: CheckForCatalogUpdates failed: {err?.Message}");
+ return new CatalogCheckResult(false, null, err);
+ }
+
+ var list = handle.Result;
+ var ids = list != null ? (IReadOnlyList)list : Array.Empty();
+ return new CatalogCheckResult(true, ids, null);
+ }
+ catch (Exception e)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: CheckForCatalogUpdates exception: {e.Message}");
+ return new CatalogCheckResult(false, null, e);
+ }
+ }
+
+ ///
+ /// Downloads new catalog JSON and refreshes locators. Pass from
+ /// or null to use Addressables’ internal list.
+ ///
+ /// When true, removes unreferenced bundles after update (see Addressables docs).
+ public static async UniTask UpdateCatalogsAsync(
+ IEnumerable catalogIds = null,
+ bool autoCleanBundleCache = false,
+ bool autoReleaseHandle = true)
+ {
+ try
+ {
+ await EnsureInitializedAsync();
+ AsyncOperationHandle> handle;
+ if (autoCleanBundleCache)
+ handle = Addressables.UpdateCatalogs(true, catalogIds, autoReleaseHandle);
+ else
+ handle = Addressables.UpdateCatalogs(catalogIds, autoReleaseHandle);
+
+ await handle.ToUniTask();
+ if (handle.Status != AsyncOperationStatus.Succeeded)
+ {
+ var err = handle.OperationException;
+ BMLogger.LogError($"AddressablesCatalogUpdater: UpdateCatalogs failed: {err?.Message}");
+ return new CatalogApplyResult(false, null, err);
+ }
+
+ var locators = handle.Result;
+ var read = locators != null ? (IReadOnlyList)locators : Array.Empty();
+ return new CatalogApplyResult(true, read, null);
+ }
+ catch (Exception e)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: UpdateCatalogs exception: {e.Message}");
+ return new CatalogApplyResult(false, null, e);
+ }
+ }
+
+ ///
+ /// Check remote catalog, then apply updates if any catalog ids were returned.
+ ///
+ public static async UniTask CheckAndUpdateCatalogsIfNeededAsync(bool autoCleanBundleCache = false)
+ {
+ var check = await CheckForCatalogUpdatesAsync(true);
+ if (!check.Success)
+ return new CatalogApplyResult(false, null, check.Error);
+ if (!check.HasUpdates)
+ return new CatalogApplyResult(true, Array.Empty(), null);
+
+ return await UpdateCatalogsAsync(check.CatalogsWithUpdates, autoCleanBundleCache, true);
+ }
+
+ ///
+ /// Bytes that would be downloaded for given the current catalog and local cache (0 if fully cached).
+ ///
+ public static async UniTask GetDownloadSizeBytesAsync(object key)
+ {
+ try
+ {
+ await EnsureInitializedAsync();
+ var handle = Addressables.GetDownloadSizeAsync(key);
+ await handle.ToUniTask();
+ if (handle.Status != AsyncOperationStatus.Succeeded)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: GetDownloadSizeAsync failed: {handle.OperationException?.Message}");
+ return -1;
+ }
+ return handle.Result;
+ }
+ catch (Exception e)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: GetDownloadSizeBytesAsync exception: {e.Message}");
+ return -1;
+ }
+ }
+
+ ///
+ /// Ensures dependencies for exist on disk; uses cache when CRC/catalog match (no redundant full download).
+ ///
+ public static async UniTask DownloadDependenciesAsync(object key, bool autoReleaseHandle = true)
+ {
+ try
+ {
+ await EnsureInitializedAsync();
+ var handle = Addressables.DownloadDependenciesAsync(key, autoReleaseHandle);
+ await handle.ToUniTask();
+ if (handle.Status != AsyncOperationStatus.Succeeded)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: DownloadDependenciesAsync failed: {handle.OperationException?.Message}");
+ return false;
+ }
+ return true;
+ }
+ catch (Exception e)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: DownloadDependenciesAsync exception: {e.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Removes bundles not referenced by the given catalogs (optional maintenance / “clear old cache”).
+ ///
+ public static async UniTask CleanBundleCacheAsync(IEnumerable catalogIds = null)
+ {
+ try
+ {
+ await EnsureInitializedAsync();
+ var handle = Addressables.CleanBundleCache(catalogIds);
+ await handle.ToUniTask();
+ if (handle.Status != AsyncOperationStatus.Succeeded)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: CleanBundleCache failed: {handle.OperationException?.Message}");
+ return false;
+ }
+ return handle.Result;
+ }
+ catch (Exception e)
+ {
+ BMLogger.LogError($"AddressablesCatalogUpdater: CleanBundleCacheAsync exception: {e.Message}");
+ return false;
+ }
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs.meta b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs.meta
new file mode 100644
index 0000000000..cf10f310a6
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8e0ec4bd4b83dbd488a581d5288a5e52
\ No newline at end of file
diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs
new file mode 100644
index 0000000000..04187a6b4d
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs
@@ -0,0 +1,80 @@
+using System;
+using BrewMonster;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.ResourceManagement.ResourceLocations;
+
+namespace BrewMonster.Scripts
+{
+ ///
+ /// Runtime rewrite of bundle/catalog URLs via
+ /// (e.g. swap CDN host or path prefix). Must run before the first successful remote load — ideally before
+ /// if all remote ids should be rewritten.
+ ///
+ public static class AddressablesRuntimeUrlRewriter
+ {
+ static Func s_previousTransform;
+ static string s_fromPrefix;
+ static string s_toPrefix;
+
+ ///
+ /// When true, the installed rewriter calls the previous first.
+ ///
+ public static bool ChainPreviousTransform { get; set; } = true;
+
+ ///
+ /// Replaces the start of each location’s when it begins with
+ /// (e.g. build-time profile URL) by (runtime CDN).
+ ///
+ public static void InstallPrefixRewrite(string fromPrefix, string toPrefix)
+ {
+ if (string.IsNullOrEmpty(fromPrefix))
+ {
+ BMLogger.LogError("AddressablesRuntimeUrlRewriter: fromPrefix is null or empty.");
+ return;
+ }
+
+ s_fromPrefix = fromPrefix;
+ s_toPrefix = toPrefix ?? string.Empty;
+
+ if (ChainPreviousTransform)
+ s_previousTransform = Addressables.InternalIdTransformFunc;
+
+ Addressables.InternalIdTransformFunc = Transform;
+ BMLogger.Log($"AddressablesRuntimeUrlRewriter: Installed prefix rewrite (from length={fromPrefix.Length}, to length={s_toPrefix.Length}).");
+ }
+
+ ///
+ /// Removes this package’s rewrite and restores the delegate that was present at install time (if any).
+ ///
+ public static void ClearInstalledRewrite()
+ {
+ if (Addressables.InternalIdTransformFunc == Transform)
+ {
+ Addressables.InternalIdTransformFunc = s_previousTransform;
+ s_previousTransform = null;
+ s_fromPrefix = null;
+ s_toPrefix = null;
+ BMLogger.Log("AddressablesRuntimeUrlRewriter: Cleared installed rewrite.");
+ }
+ }
+
+ static string Transform(IResourceLocation location)
+ {
+ string id = location != null ? location.InternalId : null;
+ if (string.IsNullOrEmpty(id))
+ return id;
+
+ if (ChainPreviousTransform && s_previousTransform != null)
+ id = s_previousTransform(location);
+
+ if (!string.IsNullOrEmpty(s_fromPrefix) &&
+ id.StartsWith(s_fromPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return s_toPrefix + id.Substring(s_fromPrefix.Length);
+ }
+
+ return id;
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs.meta b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs.meta
new file mode 100644
index 0000000000..5792b8b9e7
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 57d28e0e103bc3743a57b12f74be99ae
\ No newline at end of file
diff --git a/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs
new file mode 100644
index 0000000000..7289244734
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs
@@ -0,0 +1,244 @@
+using System;
+using BrewMonster;
+using Cysharp.Threading.Tasks;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace BrewMonster.Scripts
+{
+ ///
+ /// Bootstrap: gọi server lấy contentVersion + (tuỳ chọn) assetsBaseUrl, gắn URL rewrite trước khi Addressables init,
+ /// rồi nếu lần đầu hoặc contentVersion khác đã lưu thì cập nhật catalog và tải toàn bộ entry gắn label.
+ /// First run / version bump: call server for contentVersion + optional assetsBaseUrl, apply URL rewrite before Addressables init,
+ /// then if first launch or stored version differs, update catalogs and download all entries under the configured label.
+ ///
+ [DefaultExecutionOrder(-2000)]
+ public class GameContentBootstrap : MonoBehaviour
+ {
+ const string PrefsFirstSyncDone = "PW_GameContent_FirstRemoteSyncDone";
+ const string PrefsLastContentVersion = "PW_GameContent_LastContentVersion";
+
+ ///
+ /// When non-null, waits on this before .
+ ///
+ static UniTaskCompletionSource s_addressablesInitGate;
+
+ [Header("Version API")]
+ [Tooltip("GET JSON: { \"contentVersion\": \"12\", \"assetsBaseUrl\": \"https://cdn/.../\" }")]
+ [SerializeField] string _versionEndpointUrl;
+
+ [SerializeField] int _requestTimeoutSeconds = 20;
+
+ [Header("Addressables")]
+ [Tooltip("If true, blocks Addressables init until version HTTP + optional URL rewrite finish (recommended).")]
+ [SerializeField] bool _holdAddressablesInitUntilVersionChecked = true;
+
+ [Tooltip("Prefix baked into remote InternalIds at build (Remote.LoadPath). When server sends assetsBaseUrl, replace this prefix.")]
+ [SerializeField] string _bakedRemoteUrlPrefixForRewrite;
+
+ [Tooltip("Label applied in Addressables Groups to every remote asset that must download on first run / content update.")]
+ [SerializeField] string _remoteBulkDownloadLabel = "RemoteContent";
+
+ [Header("Editor")]
+ [SerializeField] bool _skipRemoteCallInEditor;
+
+ [SerializeField] string _editorFakeContentVersion = "editor";
+
+ public event Action Finished;
+
+ public static UniTask WaitForPreAddressablesSetupIfAnyAsync()
+ {
+ if (s_addressablesInitGate == null)
+ return UniTask.CompletedTask;
+ return s_addressablesInitGate.Task;
+ }
+
+ void Awake()
+ {
+ if (_holdAddressablesInitUntilVersionChecked)
+ s_addressablesInitGate ??= new UniTaskCompletionSource();
+ }
+
+ void Start()
+ {
+ RunAsync().Forget();
+ }
+
+ void OnDestroy()
+ {
+ ReleaseGateIfPending();
+ }
+
+ void OnApplicationQuit()
+ {
+ ReleaseGateIfPending();
+ }
+
+ static void ReleaseGateIfPending()
+ {
+ if (s_addressablesInitGate != null)
+ {
+ s_addressablesInitGate.TrySetResult();
+ s_addressablesInitGate = null;
+ }
+ }
+
+ async UniTaskVoid RunAsync()
+ {
+ var result = await RunInternalAsync();
+ Finished?.Invoke(result);
+ }
+
+ public async UniTask RunInternalAsync()
+ {
+ try
+ {
+ var dto = await FetchVersionDtoAsync();
+ if (!dto.Ok)
+ {
+ AllowAddressablesInit();
+ return new BootstrapResult(false, false, dto.Error, null);
+ }
+
+ if (!string.IsNullOrWhiteSpace(dto.AssetsBaseUrl) &&
+ !string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite))
+ {
+ AddressablesRuntimeUrlRewriter.InstallPrefixRewrite(
+ _bakedRemoteUrlPrefixForRewrite,
+ dto.AssetsBaseUrl);
+ }
+
+ AllowAddressablesInit();
+
+ await AddressablesCatalogUpdater.EnsureInitializedAsync();
+
+ bool firstRun = PlayerPrefs.GetInt(PrefsFirstSyncDone, 0) == 0;
+ string localVersion = PlayerPrefs.GetString(PrefsLastContentVersion, string.Empty);
+ bool needContentWork =
+ firstRun ||
+ !string.Equals(localVersion, dto.ContentVersion ?? string.Empty, StringComparison.Ordinal);
+
+ if (!needContentWork)
+ {
+ BMLogger.Log("[Cuong] GameContentBootstrap: contentVersion unchanged, skip catalog/bulk download.");
+ return new BootstrapResult(true, false, null, dto.ContentVersion);
+ }
+
+ var catalog = await AddressablesCatalogUpdater.CheckAndUpdateCatalogsIfNeededAsync(false);
+ if (!catalog.Success)
+ return new BootstrapResult(false, true, catalog.Error?.Message ?? "Catalog update failed", dto.ContentVersion);
+
+ if (string.IsNullOrWhiteSpace(_remoteBulkDownloadLabel))
+ {
+ BMLogger.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel is empty; skipping bulk download.");
+ return new BootstrapResult(false, true, "remoteBulkDownloadLabel empty", dto.ContentVersion);
+ }
+
+ bool dl = await AddressablesCatalogUpdater.DownloadDependenciesAsync(_remoteBulkDownloadLabel.Trim(), true);
+ if (!dl)
+ return new BootstrapResult(false, true, "DownloadDependencies failed", dto.ContentVersion);
+
+ PlayerPrefs.SetString(PrefsLastContentVersion, dto.ContentVersion);
+ PlayerPrefs.SetInt(PrefsFirstSyncDone, 1);
+ PlayerPrefs.Save();
+
+ BMLogger.Log($"[Cuong] GameContentBootstrap: content sync OK, version={dto.ContentVersion}");
+ return new BootstrapResult(true, true, null, dto.ContentVersion);
+ }
+ catch (Exception e)
+ {
+ BMLogger.LogError($"[Cuong] GameContentBootstrap: {e.Message}");
+ AllowAddressablesInit();
+ return new BootstrapResult(false, true, e.Message, null);
+ }
+ }
+
+ static void AllowAddressablesInit()
+ {
+ if (s_addressablesInitGate != null)
+ {
+ s_addressablesInitGate.TrySetResult();
+ s_addressablesInitGate = null;
+ }
+ }
+
+ async UniTask FetchVersionDtoAsync()
+ {
+#if UNITY_EDITOR
+ if (_skipRemoteCallInEditor)
+ {
+ return new VersionDtoResult(true, _editorFakeContentVersion?.Trim() ?? "editor", null, null);
+ }
+#endif
+ if (string.IsNullOrWhiteSpace(_versionEndpointUrl))
+ {
+ BMLogger.LogWarning("[Cuong] GameContentBootstrap: _versionEndpointUrl empty; using empty contentVersion (no remote check).");
+ return new VersionDtoResult(true, string.Empty, null, null);
+ }
+
+ using var req = UnityWebRequest.Get(_versionEndpointUrl);
+ req.timeout = Mathf.Max(5, _requestTimeoutSeconds);
+ await req.SendWebRequest().ToUniTask();
+
+ if (req.result != UnityWebRequest.Result.Success)
+ return new VersionDtoResult(false, null, null, req.error);
+
+ string json = req.downloadHandler?.text;
+ if (string.IsNullOrWhiteSpace(json))
+ return new VersionDtoResult(false, null, null, "Empty response body");
+
+ try
+ {
+ var parsed = JsonUtility.FromJson(json);
+ if (parsed == null || string.IsNullOrWhiteSpace(parsed.contentVersion))
+ return new VersionDtoResult(false, null, null, "JSON missing contentVersion");
+
+ return new VersionDtoResult(true, parsed.contentVersion.Trim(), parsed.assetsBaseUrl?.Trim(), null);
+ }
+ catch (Exception e)
+ {
+ return new VersionDtoResult(false, null, null, $"JSON parse: {e.Message}");
+ }
+ }
+
+ readonly struct VersionDtoResult
+ {
+ public VersionDtoResult(bool ok, string contentVersion, string assetsBaseUrl, string error)
+ {
+ Ok = ok;
+ ContentVersion = contentVersion;
+ AssetsBaseUrl = assetsBaseUrl;
+ Error = error;
+ }
+
+ public bool Ok { get; }
+ public string ContentVersion { get; }
+ public string AssetsBaseUrl { get; }
+ public string Error { get; }
+ }
+
+ [Serializable]
+ class VersionResponseJson
+ {
+ public string contentVersion;
+ public string assetsBaseUrl;
+ }
+
+ public readonly struct BootstrapResult
+ {
+ public BootstrapResult(bool success, bool didContentWork, string errorMessage, string serverContentVersion)
+ {
+ Success = success;
+ DidContentWork = didContentWork;
+ ErrorMessage = errorMessage;
+ ServerContentVersion = serverContentVersion;
+ }
+
+ public bool Success { get; }
+ /// True if catalog/bulk download ran this session.
+ public bool DidContentWork { get; }
+ public string ErrorMessage { get; }
+ public string ServerContentVersion { get; }
+ }
+ }
+}
diff --git a/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs.meta b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs.meta
new file mode 100644
index 0000000000..54bcdf57c5
--- /dev/null
+++ b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2d93de6847f714b43830673032ac76d8
\ No newline at end of file