#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEngine; namespace BrewMonster { /// /// Assign a parent folder, then register all assets under it into an Addressables group named after that folder. /// Addresses are relative to the parent folder; by default the file extension is removed from the key. /// Optional EditorPrefs: extra excluded extensions (never addressable), and saved address-tail presets with a per-run choice /// of which tail to append to every address (after stripping the asset’s real extension). /// Text files, extensionless files, and built-in excluded extensions (e.g. .pk) are skipped; existing addressable entries for those are removed. /// public class FolderToAddressablesWindow : EditorWindow { private const string MenuPath = "Tools/Addressable/Folder To Addressables…"; private const string EditorPrefsUserExcludedExtensionsKey = "BrewMonster.FolderToAddressables.UserExcludedExtensions"; private const string EditorPrefsAddressTailPresetsKey = "BrewMonster.FolderToAddressables.AddressTailPresets"; private const string EditorPrefsSelectedAddressTailKey = "BrewMonster.FolderToAddressables.SelectedAddressTail"; private const string EditorPrefsLegacyKeepExtensionInAddressKey = "BrewMonster.FolderToAddressables.KeepExtensionInAddress"; private static readonly HashSet TextExtensions = new HashSet(System.StringComparer.OrdinalIgnoreCase) { ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".ini", ".cfg", ".html", ".htm", }; /// Extensions we never mark as Addressable (project-specific binary / sidecar types). private static readonly HashSet NeverAddressableExtensions = new HashSet(System.StringComparer.OrdinalIgnoreCase) { ".pk", ".sfk", ".sw", }; private DefaultAsset _parentFolder; private bool _includeFolderName; private bool _overrideExisting; private readonly List _userExcludedExtensions = new List(); private readonly HashSet _userExcludedExtensionSet = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly List _addressTailPresets = new List(); private Vector2 _scrollExcluded; private Vector2 _scrollAddressTails; private Vector2 _scroll; private string _log = ""; /// Normalized tail to append to every address this run (e.g. ".gfx"), or null for none. private string _selectedAddressTail; [MenuItem(MenuPath)] public static void Open() { var w = GetWindow(false, "Folder To Addressables", true); w.minSize = new Vector2(420, 520); w.Show(); } private void OnEnable() { LoadUserExcludedExtensionsFromPrefs(); LoadAddressTailPresetsFromPrefs(); LoadSelectedAddressTailFromPrefs(); } private void OnGUI() { EditorGUILayout.Space(4); EditorGUILayout.LabelField("Folder To Addressables", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "Group name = last segment of the folder path. Two different paths with the same folder name share one group. " + "The asset’s real extension is stripped from the address; you can optionally append one chosen tail from the presets (e.g. gfx/a/b.gfx). " + "If nothing updates, ensure the target group is not Read Only in the Addressables Groups window. " + "Files with no extension (e.g. raw data), .pk, built-in excluded types, and extensions in Extra excluded are not made addressable.", MessageType.Info); EditorGUILayout.Space(4); EditorGUILayout.LabelField("Extra excluded extensions (EditorPrefs)", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "Optional tails such as .gfx or .wav. These files are skipped and removed from Addressables like .pk. " + "The list is stored in Editor preferences on this machine.", MessageType.None); _scrollExcluded = EditorGUILayout.BeginScrollView(_scrollExcluded, GUILayout.MaxHeight(120)); EditorGUI.BeginChangeCheck(); for (int i = 0; i < _userExcludedExtensions.Count; i++) { EditorGUILayout.BeginHorizontal(); _userExcludedExtensions[i] = EditorGUILayout.TextField(_userExcludedExtensions[i]); if (GUILayout.Button("Remove", GUILayout.Width(64))) { _userExcludedExtensions.RemoveAt(i); i--; SaveUserExcludedExtensionsToPrefs(); continue; } EditorGUILayout.EndHorizontal(); } if (EditorGUI.EndChangeCheck()) SaveUserExcludedExtensionsToPrefs(); EditorGUILayout.EndScrollView(); if (GUILayout.Button("Add excluded type")) { _userExcludedExtensions.Add(""); SaveUserExcludedExtensionsToPrefs(); } EditorGUILayout.Space(8); EditorGUILayout.LabelField("Address tail presets (EditorPrefs)", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "Add tails you reuse (e.g. .wav, .gfx). Then pick one below: that suffix is appended to every address for this run " + "after the real file extension is removed (e.g. …/a/b.png with Include folder name and tail .gfx → gfx/a/b.gfx).", MessageType.None); _scrollAddressTails = EditorGUILayout.BeginScrollView(_scrollAddressTails, GUILayout.MaxHeight(100)); EditorGUI.BeginChangeCheck(); for (int i = 0; i < _addressTailPresets.Count; i++) { EditorGUILayout.BeginHorizontal(); _addressTailPresets[i] = EditorGUILayout.TextField(_addressTailPresets[i]); if (GUILayout.Button("Remove", GUILayout.Width(64))) { _addressTailPresets.RemoveAt(i); i--; SaveAddressTailPresetsToPrefs(); NormalizeSelectedAddressTailAgainstPresets(); continue; } EditorGUILayout.EndHorizontal(); } if (EditorGUI.EndChangeCheck()) { SaveAddressTailPresetsToPrefs(); NormalizeSelectedAddressTailAgainstPresets(); } EditorGUILayout.EndScrollView(); if (GUILayout.Button("Add tail preset")) { _addressTailPresets.Add(""); SaveAddressTailPresetsToPrefs(); } List orderedTails = GetOrderedNormalizedAddressTailPresets(); NormalizeSelectedAddressTailAgainstPresets(); string[] tailPopupOptions = new string[orderedTails.Count + 1]; tailPopupOptions[0] = "None (no added tail)"; for (int i = 0; i < orderedTails.Count; i++) tailPopupOptions[i + 1] = orderedTails[i]; int tailPopupIndex = 0; if (!string.IsNullOrEmpty(_selectedAddressTail)) { for (int i = 0; i < orderedTails.Count; i++) { if (string.Equals(orderedTails[i], _selectedAddressTail, StringComparison.OrdinalIgnoreCase)) { tailPopupIndex = i + 1; break; } } } EditorGUI.BeginChangeCheck(); tailPopupIndex = EditorGUILayout.Popup( new GUIContent( "Address tail for this run", "Appended to every generated address. The asset’s on-disk extension is always stripped first."), tailPopupIndex, tailPopupOptions); if (EditorGUI.EndChangeCheck()) { _selectedAddressTail = tailPopupIndex <= 0 ? null : orderedTails[tailPopupIndex - 1]; SaveSelectedAddressTailToPrefs(); } EditorGUILayout.Space(6); var newFolder = (DefaultAsset)EditorGUILayout.ObjectField( "Parent folder", _parentFolder, typeof(DefaultAsset), false); if (newFolder != _parentFolder) { _parentFolder = newFolder; _log = ""; } string parentPath = _parentFolder != null ? AssetDatabase.GetAssetPath(_parentFolder) : null; bool validFolder = !string.IsNullOrEmpty(parentPath) && AssetDatabase.IsValidFolder(parentPath); if (_parentFolder != null && !validFolder) { EditorGUILayout.HelpBox("Assign a folder from the Project window (not a file).", MessageType.Warning); } string groupName = validFolder ? Path.GetFileName(parentPath.TrimEnd('/')) : ""; EditorGUILayout.LabelField("Addressables group", string.IsNullOrEmpty(groupName) ? "—" : groupName); _includeFolderName = EditorGUILayout.Toggle( new GUIContent( "Include folder name in address", "When on, the dropped folder name is prefixed to every address (e.g. gfx/a/b instead of a/b)."), _includeFolderName); _overrideExisting = EditorGUILayout.Toggle( new GUIContent( "Override existing addressables", "When on, all assets under the folder are moved to this group and addresses are set. When off, assets that already have an Addressables entry are skipped."), _overrideExisting); EditorGUILayout.Space(8); EditorGUI.BeginDisabledGroup(!validFolder); if (GUILayout.Button("Start", GUILayout.Height(28))) { Run(parentPath, groupName); } EditorGUI.EndDisabledGroup(); EditorGUILayout.Space(6); EditorGUILayout.LabelField("Log", EditorStyles.boldLabel); _scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.ExpandHeight(true)); EditorGUILayout.TextArea(string.IsNullOrEmpty(_log) ? "(empty)" : _log, EditorStyles.textArea, GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); } private void Run(string parentPath, string groupName) { _log = ""; RebuildUserExcludedExtensionSet(); NormalizeSelectedAddressTailAgainstPresets(); var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) { AppendLog("ERROR: Addressable settings not found. Install/configure the Addressables package."); EditorUtility.DisplayDialog("Folder To Addressables", "Addressable settings not found.", "OK"); return; } if (string.IsNullOrEmpty(groupName)) { AppendLog("ERROR: Could not derive group name from folder path."); return; } var group = GetOrCreateGroup(settings, groupName); if (group == null) { AppendLog($"ERROR: Could not find or create group: {groupName}"); EditorUtility.DisplayDialog("Folder To Addressables", $"Could not find or create group: {groupName}", "OK"); return; } if (group.ReadOnly) { AppendLog( "ERROR: The target Addressables group is Read Only. Unity will not apply CreateOrMoveEntry/SetAddress. " + "Open Window > Asset Management > Addressables > Groups, select the group, and disable Read Only in the Inspector."); EditorUtility.DisplayDialog( "Folder To Addressables", $"Group \"{groupName}\" is Read Only. Disable Read Only on that group, then run this tool again.", "OK"); return; } parentPath = parentPath.Replace('\\', '/').TrimEnd('/'); string parentPrefix = parentPath + "/"; int processed = 0; int skippedText = 0; int skippedNoExtension = 0; int skippedExcludedExtension = 0; int removedSkippedAddressable = 0; int skippedOther = 0; int skippedAlreadyAddressable = 0; int errors = 0; // --- Pass 1: Remove blocked entries already in this group --- // AssetDatabase.FindAssets does NOT return extensionless files, so they would never reach // the removal logic in the main loop. We clean them up here by iterating group.entries directly. // Snapshot entries first to avoid modifying the collection while iterating. if (group.entries != null) { var snapshot = new List(group.entries); foreach (var existing in snapshot) { if (existing == null) continue; string ap = existing.AssetPath.Replace('\\', '/'); if (!ap.StartsWith(parentPrefix, System.StringComparison.OrdinalIgnoreCase)) continue; if (AssetDatabase.IsValidFolder(ap)) continue; if (ShouldSkipExtensionless(ap) || ShouldSkipNeverAddressableExtension(ap)) { if (settings.RemoveAssetEntry(existing.guid, false)) { removedSkippedAddressable++; AppendLog($"REMOVED (blocked type): {ap}"); } } } } // --- Pass 2: Collect processable GUIDs --- // FindAssets returns recognized asset files. When overriding, also fold in group entries // so assets already in this group get their addresses refreshed. var guidsToProcess = new HashSet(System.StringComparer.OrdinalIgnoreCase); foreach (var g in AssetDatabase.FindAssets("", new[] { parentPath })) guidsToProcess.Add(g); if (_overrideExisting && group.entries != null) { foreach (var existing in group.entries) { if (existing == null) continue; string ap = existing.AssetPath; if (string.IsNullOrEmpty(ap)) continue; ap = ap.Replace('\\', '/'); if (AssetDatabase.IsValidFolder(ap)) continue; if (!ap.StartsWith(parentPrefix, System.StringComparison.OrdinalIgnoreCase)) continue; guidsToProcess.Add(existing.guid); } } foreach (string guid in guidsToProcess) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(assetPath)) { skippedOther++; continue; } assetPath = assetPath.Replace('\\', '/'); if (AssetDatabase.IsValidFolder(assetPath)) { skippedOther++; continue; } // Require path under parent folder (avoid matching sibling folders e.g. .../A vs .../AB). if (!assetPath.StartsWith(parentPrefix, System.StringComparison.OrdinalIgnoreCase)) { skippedOther++; continue; } if (ShouldSkipAsText(assetPath)) { skippedText++; continue; } if (ShouldSkipNeverAddressableExtension(assetPath)) { TryRemoveAddressableIfPresent(settings, guid, ref removedSkippedAddressable); skippedExcludedExtension++; continue; } if (ShouldSkipExtensionless(assetPath)) { TryRemoveAddressableIfPresent(settings, guid, ref removedSkippedAddressable); skippedNoExtension++; continue; } string relative = GetRelativeAddress(parentPath, assetPath); if (string.IsNullOrEmpty(relative)) { skippedOther++; continue; } if (!_overrideExisting && settings.FindAssetEntry(guid) != null) { skippedAlreadyAddressable++; continue; } try { var existingEntry = settings.FindAssetEntry(guid); if (existingEntry != null && existingEntry.parentGroup != null && existingEntry.parentGroup.ReadOnly) { AppendLog($"ERROR: Cannot move or edit (source group is Read Only): {assetPath}"); errors++; continue; } var entry = settings.CreateOrMoveEntry(guid, group, false, false); if (entry == null) { AppendLog($"ERROR: CreateOrMoveEntry failed: {assetPath}"); errors++; continue; } if (_includeFolderName) relative = groupName + "/" + relative; string baseAddress = ToAddressWithoutExtension(relative); string newAddress = string.IsNullOrEmpty(_selectedAddressTail) ? baseAddress : baseAddress + _selectedAddressTail; entry.SetAddress(newAddress, false); processed++; } catch (System.Exception ex) { AppendLog($"ERROR: {assetPath} — {ex.Message}"); errors++; } } if (processed > 0 || removedSkippedAddressable > 0) { settings.SetDirty(AddressableAssetSettings.ModificationEvent.EntryModified, null, true); EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); } AppendLog(""); AppendLog( $"Done. Processed: {processed}, removed (skipped types): {removedSkippedAddressable}, " + $"skipped (already addressable): {skippedAlreadyAddressable}, " + $"skipped (text): {skippedText}, skipped (excluded ext): {skippedExcludedExtension}, " + $"skipped (no extension): {skippedNoExtension}, skipped (other): {skippedOther}, errors: {errors}"); EditorUtility.DisplayDialog( "Folder To Addressables", $"Processed: {processed}\nRemoved addressable (skipped types): {removedSkippedAddressable}\n" + $"Skipped (already addressable): {skippedAlreadyAddressable}\n" + $"Skipped (text): {skippedText}\nSkipped (excluded ext, e.g. .pk): {skippedExcludedExtension}\n" + $"Skipped (no extension): {skippedNoExtension}\nSkipped (other): {skippedOther}\nErrors: {errors}\nGroup: {groupName}", "OK"); } private static bool ShouldSkipAsText(string assetPath) { var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath); if (mainType == typeof(TextAsset)) return true; string ext = Path.GetExtension(assetPath); return !string.IsNullOrEmpty(ext) && TextExtensions.Contains(ext); } private void LoadUserExcludedExtensionsFromPrefs() { _userExcludedExtensions.Clear(); string raw = EditorPrefs.GetString(EditorPrefsUserExcludedExtensionsKey, ""); if (string.IsNullOrEmpty(raw)) { RebuildUserExcludedExtensionSet(); return; } var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var part in raw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) { string n = NormalizeExtensionToken(part); if (n == null || !seen.Add(n)) continue; _userExcludedExtensions.Add(n); } RebuildUserExcludedExtensionSet(); } private void SaveUserExcludedExtensionsToPrefs() { var deduped = new HashSet(StringComparer.OrdinalIgnoreCase); var lines = new List(); foreach (string raw in _userExcludedExtensions) { string n = NormalizeExtensionToken(raw); if (n == null || !deduped.Add(n)) continue; lines.Add(n); } lines.Sort(StringComparer.OrdinalIgnoreCase); EditorPrefs.SetString(EditorPrefsUserExcludedExtensionsKey, string.Join("\n", lines)); RebuildUserExcludedExtensionSet(); } private void RebuildUserExcludedExtensionSet() { _userExcludedExtensionSet.Clear(); foreach (string raw in _userExcludedExtensions) { string n = NormalizeExtensionToken(raw); if (n != null) _userExcludedExtensionSet.Add(n); } } private void LoadAddressTailPresetsFromPrefs() { _addressTailPresets.Clear(); string raw = EditorPrefs.GetString(EditorPrefsAddressTailPresetsKey, ""); if (string.IsNullOrEmpty(raw)) { string legacy = EditorPrefs.GetString(EditorPrefsLegacyKeepExtensionInAddressKey, ""); if (!string.IsNullOrEmpty(legacy)) { EditorPrefs.SetString(EditorPrefsAddressTailPresetsKey, legacy); EditorPrefs.DeleteKey(EditorPrefsLegacyKeepExtensionInAddressKey); raw = legacy; } } if (string.IsNullOrEmpty(raw)) return; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var part in raw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) { string n = NormalizeExtensionToken(part); if (n == null || !seen.Add(n)) continue; _addressTailPresets.Add(n); } } private void SaveAddressTailPresetsToPrefs() { var deduped = new HashSet(StringComparer.OrdinalIgnoreCase); var lines = new List(); foreach (string raw in _addressTailPresets) { string n = NormalizeExtensionToken(raw); if (n == null || !deduped.Add(n)) continue; lines.Add(n); } lines.Sort(StringComparer.OrdinalIgnoreCase); EditorPrefs.SetString(EditorPrefsAddressTailPresetsKey, string.Join("\n", lines)); } private void LoadSelectedAddressTailFromPrefs() { string raw = EditorPrefs.GetString(EditorPrefsSelectedAddressTailKey, ""); _selectedAddressTail = string.IsNullOrWhiteSpace(raw) ? null : NormalizeExtensionToken(raw); NormalizeSelectedAddressTailAgainstPresets(); } private void SaveSelectedAddressTailToPrefs() { EditorPrefs.SetString(EditorPrefsSelectedAddressTailKey, _selectedAddressTail ?? ""); } private List GetOrderedNormalizedAddressTailPresets() { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var list = new List(); foreach (string raw in _addressTailPresets) { string n = NormalizeExtensionToken(raw); if (n == null || !seen.Add(n)) continue; list.Add(n); } return list; } private void NormalizeSelectedAddressTailAgainstPresets() { if (string.IsNullOrEmpty(_selectedAddressTail)) return; List ordered = GetOrderedNormalizedAddressTailPresets(); foreach (string t in ordered) { if (string.Equals(t, _selectedAddressTail, StringComparison.OrdinalIgnoreCase)) { _selectedAddressTail = t; return; } } _selectedAddressTail = null; SaveSelectedAddressTailToPrefs(); } private static string NormalizeExtensionToken(string s) { if (string.IsNullOrWhiteSpace(s)) return null; s = s.Trim(); if (s.Length == 0) return null; if (s[0] != '.') s = "." + s; return s; } private bool ShouldSkipNeverAddressableExtension(string assetPath) { string ext = Path.GetExtension(assetPath); if (string.IsNullOrEmpty(ext)) return false; if (NeverAddressableExtensions.Contains(ext)) return true; return _userExcludedExtensionSet.Contains(ext); } /// /// Skip generic/extensionless files (e.g. raw audio bytes) that show as plain assets in the Project window. /// private static bool ShouldSkipExtensionless(string assetPath) { return string.IsNullOrEmpty(Path.GetExtension(assetPath)); } private static void TryRemoveAddressableIfPresent(AddressableAssetSettings settings, string guid, ref int removedCount) { if (settings.FindAssetEntry(guid) != null && settings.RemoveAssetEntry(guid, false)) removedCount++; } private static string GetRelativeAddress(string parentPath, string assetPath) { assetPath = assetPath.Replace('\\', '/'); parentPath = parentPath.Replace('\\', '/').TrimEnd('/'); if (string.Equals(assetPath, parentPath, System.StringComparison.OrdinalIgnoreCase)) return null; string prefix = parentPath + "/"; if (!assetPath.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase)) return null; return assetPath.Substring(prefix.Length); } /// /// Address key: relative path with the final file extension removed (.png, .wav, .prefab, etc.). /// private static string ToAddressWithoutExtension(string relativePath) { if (string.IsNullOrEmpty(relativePath)) return relativePath; relativePath = relativePath.Replace('\\', '/'); int lastSlash = relativePath.LastIndexOf('/'); string filePart = lastSlash >= 0 ? relativePath.Substring(lastSlash + 1) : relativePath; string dirPart = lastSlash >= 0 ? relativePath.Substring(0, lastSlash) : ""; string fileNoExt = Path.GetFileNameWithoutExtension(filePart); if (string.IsNullOrEmpty(fileNoExt)) fileNoExt = filePart; if (string.IsNullOrEmpty(dirPart)) return fileNoExt; return dirPart + "/" + fileNoExt; } private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName) { if (settings == null || string.IsNullOrEmpty(groupName)) return null; AddressableAssetGroup group = settings.FindGroup(groupName); if (group != null) return group; try { group = settings.CreateGroup(groupName, false, false, true, null); if (group != null) { EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); } return group; } catch { return null; } } private void AppendLog(string line) { if (string.IsNullOrEmpty(_log)) _log = line; else _log += "\n" + line; Debug.Log($"[Folder To Addressables] {line}"); Repaint(); } } } #endif