diff --git a/Distance.CustomCar/Assets.cs b/Distance.CustomCar/Assets.cs index 2f2d281..66c9e46 100644 --- a/Distance.CustomCar/Assets.cs +++ b/Distance.CustomCar/Assets.cs @@ -66,10 +66,7 @@ private object Load() { try { - var assetBundle = AssetBundleBridge.LoadFrom(FilePath); - Mod.Log.LogInfo($"Loaded asset bundle {FilePath}"); - - return assetBundle; + return AssetBundleBridge.LoadFrom(FilePath); } catch (Exception ex) { @@ -81,15 +78,18 @@ private object Load() internal static class AssetBundleBridge { - public static Type AssetBundleType => Kernel.FindTypeByFullName( - "UnityEngine.AssetBundle", - "UnityEngine" - ); - - private static MethodInfo LoadFromFile => AssetBundleType.GetMethod( - "LoadFromFile", - new[] { typeof(string) } - ); + private static Type _assetBundleType; + private static MethodInfo _loadFromFile; + + static AssetBundleBridge() + { + _assetBundleType = Kernel.FindTypeByFullName("UnityEngine.AssetBundle", "UnityEngine"); + _loadFromFile = _assetBundleType.GetMethod("LoadFromFile", new[] { typeof(string) }); + } + + public static Type AssetBundleType => _assetBundleType; + + private static MethodInfo LoadFromFile => _loadFromFile; public static object LoadFrom(string path) { diff --git a/Distance.CustomCar/Data/Car/CarBuilder.cs b/Distance.CustomCar/Data/Car/CarBuilder.cs index d905494..297356c 100644 --- a/Distance.CustomCar/Data/Car/CarBuilder.cs +++ b/Distance.CustomCar/Data/Car/CarBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using UnityEngine; namespace Distance.CustomCar.Data.Car @@ -14,6 +15,7 @@ public class CarBuilder public void CreateCars(CarInfos infos) { infos_ = infos; + Dictionary cars = LoadAssetsBundles(); List carsInfos = new List(); @@ -117,7 +119,6 @@ private void RegisterCars(List carsInfos) private Dictionary LoadAssetsBundles() { Dictionary assetsList = new Dictionary(); - DirectoryInfo assetsDirectory = GetLocalFolder("Assets"); DirectoryInfo globalCarsDirectory = new DirectoryInfo(Path.Combine(Resource.personalDistanceDirPath_, "CustomCars")); if (!globalCarsDirectory.Exists) @@ -135,146 +136,201 @@ private Dictionary LoadAssetsBundles() } } - /*try - { - foreach (FileInfo assetsFile in assetsDirectory.GetFiles("*", SearchOption.AllDirectories).Concat(globalCarsDirectory.GetFiles("*", SearchOption.AllDirectories)).OrderBy(x => x.Name)) - { - try - { - Assets assets = Assets.FromUnsafePath(assetsFile.FullName); - AssetBundle bundle = assets.Bundle as AssetBundle; - + PrefabIndex prefabIndex = new PrefabIndex(); + prefabIndex.Load(); - int foundPrefabCount = 0; + DirectoryInfo profileDirectory = new DirectoryInfo(Directory.GetParent(Path.GetDirectoryName(System.Reflection.Assembly.GetCallingAssembly().Location)).ToString()); - foreach (string assetName in from name in bundle.GetAllAssetNames() where name.EndsWith(".prefab", StringComparison.InvariantCultureIgnoreCase) select name) - { - GameObject carPrefab = bundle.LoadAsset(assetName); + List rawFiles = new List(); + foreach (FileInfo f in profileDirectory.GetFiles("*", SearchOption.AllDirectories).Concat(globalCarsDirectory.GetFiles("*", SearchOption.AllDirectories)).OrderBy(x => x.Name)) + { + if (f.Extension == "" && HasValidBundleSignature(f.FullName)) + rawFiles.Add(f); + } - string assetKey = $"{assetsFile.FullName} ({assetName})"; + List validFiles = rawFiles; + HashSet seenPaths = new HashSet(); - if (!assetsList.ContainsKey(assetKey)) - { - assetsList.Add(assetKey, carPrefab); - foundPrefabCount++; - } - } - - if (foundPrefabCount == 0) + int batchSize = Math.Max(4, Environment.ProcessorCount); + int totalBatches = (validFiles.Count + batchSize - 1) / batchSize; + for (int batchStart = 0; batchStart < validFiles.Count; batchStart += batchSize) + { + int batchEnd = Math.Min(batchStart + batchSize, validFiles.Count); + Mod.Log.LogInfo($"Batch {batchStart / batchSize + 1}/{totalBatches} ({batchEnd - batchStart} files)"); + BundleLoadResult[] batchResults = new BundleLoadResult[batchEnd - batchStart]; + int batchCount = batchResults.Length; + int loaded = 0; + using (ManualResetEvent batchDone = new ManualResetEvent(false)) + { + for (int i = 0; i < batchCount; i++) + { + int index = i; + FileInfo file = validFiles[batchStart + i]; + ThreadPool.QueueUserWorkItem(_ => { - Mod.Instance.Errors.Add($"Can't find a prefab in the asset bundle: {assetsFile.FullName}"); - Mod.Log.LogError($"Can't find a prefab in the asset bundle: {assetsFile.FullName}"); - } + batchResults[index] = LoadBundle(file); + if (Interlocked.Increment(ref loaded) == batchCount) + batchDone.Set(); + }); } - catch (Exception ex) + if (!batchDone.WaitOne(30000)) { - Mod.Instance.Errors.Add($"Could not load assets file: {assetsFile.FullName}"); - Mod.Log.LogError($"Could not load assets file: {assetsFile.FullName}"); - Mod.Instance.Errors.Add(ex); - Mod.Log.LogError(ex); + Mod.Log.LogWarning($"Bundle batch {(batchStart / batchSize) + 1} timed out after 30s — incomplete results will be skipped."); + for (int i = 0; i < batchCount; i++) + { + if (batchResults[i].FilePath == null) + { + batchResults[i] = new BundleLoadResult + { + FilePath = validFiles[batchStart + i].FullName, + Error = new TimeoutException("Bundle loading timed out after 30 seconds — the file may be corrupted") + }; + } + } } } - } - catch(Exception ex) - { - Mod.Log.LogWarning("No Assets folder in the custom car folder."); - }*/ - /*try - { - DirectoryInfo otherAssetsDirectory = new DirectoryInfo(Path.Combine(Directory.GetParent(Directory.GetParent(Path.GetDirectoryName(System.Reflection.Assembly.GetCallingAssembly().Location)).ToString()).ToString(), "Assets")); - - foreach (FileInfo assetsFile in otherAssetsDirectory.GetFiles("*", SearchOption.AllDirectories).Concat(globalCarsDirectory.GetFiles("*", SearchOption.AllDirectories)).OrderBy(x => x.Name)) + foreach (BundleLoadResult result in batchResults) { - try - { - Assets assets = Assets.FromUnsafePath(assetsFile.FullName); - AssetBundle bundle = assets.Bundle as AssetBundle; + string filePath = result.FilePath; + seenPaths.Add(filePath); - int foundPrefabCount = 0; + if (result.Bundle == null) + { + Mod.Instance.Errors.Add($"Could not load assets file: {filePath}"); + Mod.Log.LogError($"Could not load assets file: {filePath}"); + if (result.Error != null) + { + Mod.Instance.Errors.Add(result.Error); + Mod.Log.LogError(result.Error); + } + continue; + } - foreach (string assetName in from name in bundle.GetAllAssetNames() where name.EndsWith(".prefab", StringComparison.InvariantCultureIgnoreCase) select name) - { - GameObject carPrefab = bundle.LoadAsset(assetName); + AssetBundle bundle = result.Bundle; + string[] prefabNames; - string assetKey = $"{assetsFile.FullName} ({assetName})"; + if (prefabIndex.IsUpToDate(filePath)) + { + prefabNames = prefabIndex.GetPrefabNames(filePath); + if (prefabNames == null || prefabNames.Length == 0) + prefabNames = ScanBundleForPrefabs(bundle); + } + else + { + prefabNames = ScanBundleForPrefabs(bundle); + if (prefabNames.Length > 0) + Mod.Log.LogInfo($"Scanned: {Path.GetFileName(filePath)} ({prefabNames.Length} prefab(s))"); + } - if (!assetsList.ContainsKey(assetKey)) - { - assetsList.Add(assetKey, carPrefab); - foundPrefabCount++; - } - } + prefabIndex.SetPrefabNames(filePath, prefabNames); - if (foundPrefabCount == 0) + int foundPrefabCount = 0; + foreach (string assetName in prefabNames) + { + GameObject carPrefab = bundle.LoadAsset(assetName); + string assetKey = $"{filePath} ({assetName})"; + if (!assetsList.ContainsKey(assetKey)) { - Mod.Instance.Errors.Add($"Can't find a prefab in the asset bundle: {assetsFile.FullName}"); - Mod.Log.LogError($"Can't find a prefab in the asset bundle: {assetsFile.FullName}"); + assetsList.Add(assetKey, carPrefab); + foundPrefabCount++; } } - catch (Exception ex) - { - Mod.Instance.Errors.Add($"Could not load assets file: {assetsFile.FullName}"); - Mod.Log.LogError($"Could not load assets file: {assetsFile.FullName}"); - Mod.Instance.Errors.Add(ex); - Mod.Log.LogError(ex); + + if (foundPrefabCount == 0) + { + Mod.Instance.Errors.Add($"Can't find a prefab in the asset bundle: {filePath}"); + Mod.Log.LogError($"Can't find a prefab in the asset bundle: {filePath}"); } + + bundle.Unload(false); } } - catch (Exception ex) - { - Mod.Log.LogWarning($"No Assets folder in toplevel directory."); - }*/ - DirectoryInfo profileDirectory = new DirectoryInfo(Directory.GetParent(Path.GetDirectoryName(System.Reflection.Assembly.GetCallingAssembly().Location)).ToString()); + Mod.Log.LogInfo($"{assetsList.Count} prefab(s) loaded from {seenPaths.Count} file(s)"); + prefabIndex.RemoveStaleEntries(seenPaths); + prefabIndex.Save(); + return assetsList; + } - foreach (FileInfo assetsFile in profileDirectory.GetFiles("*", SearchOption.AllDirectories).Concat(globalCarsDirectory.GetFiles("*", SearchOption.AllDirectories)).OrderBy(x => x.Name)) + private static bool HasValidBundleSignature(string filePath) + { + try { - if (assetsFile.Extension == "") - { - try - { - Assets assets = Assets.FromUnsafePath(assetsFile.FullName); - AssetBundle bundle = assets.Bundle as AssetBundle; + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + if (stream.Length < 8) + return false; - int foundPrefabCount = 0; + byte[] header = new byte[8]; + if (stream.Read(header, 0, 8) != 8) + return false; - foreach (string assetName in from name in bundle.GetAllAssetNames() where name.EndsWith(".prefab", StringComparison.InvariantCultureIgnoreCase) select name) - { - GameObject carPrefab = bundle.LoadAsset(assetName); + if (header[0] == 'U' && header[1] == 'n' && header[2] == 'i' && header[3] == 't' && + header[4] == 'y' && header[5] == 'F' && header[6] == 'S') + return true; - string assetKey = $"{assetsFile.FullName} ({assetName})"; + if (header[0] == 'U' && header[1] == 'n' && header[2] == 'i' && header[3] == 't' && + header[4] == 'y' && header[5] == 'R' && header[6] == 'a' && header[7] == 'w') + return true; - if (!assetsList.ContainsKey(assetKey)) - { - assetsList.Add(assetKey, carPrefab); - foundPrefabCount++; - } - } + if (header[0] == 'U' && header[1] == 'n' && header[2] == 'i' && header[3] == 't' && + header[4] == 'y' && header[5] == 'W' && header[6] == 'e' && header[7] == 'b') + return true; - if (foundPrefabCount == 0) - { - Mod.Instance.Errors.Add($"Can't find a prefab in the asset bundle: {assetsFile.FullName}"); - Mod.Log.LogError($"Can't find a prefab in the asset bundle: {assetsFile.FullName}"); - } - } - catch (Exception ex) - { - Mod.Instance.Errors.Add($"Could not load assets file: {assetsFile.FullName}"); - Mod.Log.LogError($"Could not load assets file: {assetsFile.FullName}"); - Mod.Instance.Errors.Add(ex); - Mod.Log.LogError(ex); - } + return false; } } + catch + { + return false; + } + } - return assetsList; + private static BundleLoadResult LoadBundle(FileInfo file) + { + try + { + if (!HasValidBundleSignature(file.FullName)) + return new BundleLoadResult + { + FilePath = file.FullName, + Error = new InvalidDataException("File is not a valid Unity AssetBundle (missing UnityFS/UnityRaw/UnityWeb header)") + }; + + Assets assets = Assets.FromUnsafePath(file.FullName); + if (assets == null) + return new BundleLoadResult { FilePath = file.FullName }; + return new BundleLoadResult + { + FilePath = file.FullName, + Bundle = assets.Bundle as AssetBundle + }; + } + catch (Exception ex) + { + return new BundleLoadResult { FilePath = file.FullName, Error = ex }; + } + } + + private struct BundleLoadResult + { + public string FilePath; + public AssetBundle Bundle; + public Exception Error; } - public DirectoryInfo GetLocalFolder(string dir) + private static string[] ScanBundleForPrefabs(AssetBundle bundle) { - FileSystem files = new FileSystem(); - return new DirectoryInfo(Path.GetDirectoryName(Path.Combine(files.RootDirectory, dir + (dir.EndsWith($"{Path.DirectorySeparatorChar}") ? string.Empty : $"{Path.DirectorySeparatorChar}")))); + List prefabs = new List(); + foreach (string assetName in bundle.GetAllAssetNames()) + { + if (assetName.EndsWith(".prefab", StringComparison.InvariantCultureIgnoreCase)) + { + prefabs.Add(assetName); + } + } + return prefabs.ToArray(); } private CreateCarReturnInfos CreateCar(GameObject car) @@ -377,8 +433,10 @@ private void ReplaceMaterials(Renderer renderer) FillMaterialInfos(renderer, materialNames, materialProperties); - Material[] materials = renderer.materials.ToArray(); - for (int materialIndex = 0; materialIndex < renderer.materials.Length; materialIndex++) + Material[] originalMaterials = renderer.materials; + Material[] materials = new Material[originalMaterials.Length]; + Array.Copy(originalMaterials, materials, originalMaterials.Length); + for (int materialIndex = 0; materialIndex < originalMaterials.Length; materialIndex++) { if (!infos_.materials.TryGetValue(materialNames[materialIndex], out MaterialInfos materialInfo)) { @@ -395,22 +453,22 @@ private void ReplaceMaterials(Renderer renderer) Material material = UnityEngine.Object.Instantiate(materialInfo.material); if (materialInfo.diffuseIndex >= 0) { - material.SetTexture(materialInfo.diffuseIndex, renderer.materials[materialIndex].GetTexture("_MainTex")); + material.SetTexture(materialInfo.diffuseIndex, originalMaterials[materialIndex].GetTexture("_MainTex")); } if (materialInfo.normalIndex >= 0) { - material.SetTexture(materialInfo.normalIndex, renderer.materials[materialIndex].GetTexture("_BumpMap")); + material.SetTexture(materialInfo.normalIndex, originalMaterials[materialIndex].GetTexture("_BumpMap")); } if (materialInfo.emitIndex >= 0) { - material.SetTexture(materialInfo.emitIndex, renderer.materials[materialIndex].GetTexture("_EmissionMap")); + material.SetTexture(materialInfo.emitIndex, originalMaterials[materialIndex].GetTexture("_EmissionMap")); } foreach (MaterialPropertyExport property in materialProperties[materialIndex]) { - CopyMaterialProperty(renderer.materials[materialIndex], material, property); + CopyMaterialProperty(originalMaterials[materialIndex], material, property); } materials[materialIndex] = material; @@ -962,4 +1020,4 @@ private CarColors LoadDefaultColors(GameObject car) return infos_.defaultColors; } } -} \ No newline at end of file +} diff --git a/Distance.CustomCar/Data/Car/PrefabIndex.cs b/Distance.CustomCar/Data/Car/PrefabIndex.cs new file mode 100644 index 0000000..680c655 --- /dev/null +++ b/Distance.CustomCar/Data/Car/PrefabIndex.cs @@ -0,0 +1,139 @@ +using JsonFx.Json; +using JsonFx.Serialization; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Distance.CustomCar.Data.Car +{ + public class PrefabIndex + { + private static readonly string IndexPath; + + static PrefabIndex() + { + string modDir = Path.GetDirectoryName(typeof(PrefabIndex).Assembly.Location); + IndexPath = Path.Combine(modDir, Path.Combine("Settings", "prefab_index.json")); + } + + public Dictionary Files { get; set; } = new Dictionary(); + + public string[] GetPrefabNames(string filePath) + { + if (Files.TryGetValue(filePath, out PrefabIndexEntry entry) && entry.PrefabNames != null) + return entry.PrefabNames; + return null; + } + + public void SetPrefabNames(string filePath, string[] prefabNames) + { + Files[filePath] = new PrefabIndexEntry + { + LastWriteTime = GetLastWriteSafe(filePath), + PrefabNames = prefabNames ?? new string[0] + }; + } + + public bool IsUpToDate(string filePath) + { + if (!Files.TryGetValue(filePath, out PrefabIndexEntry entry)) + return false; + + return entry.LastWriteTime == GetLastWriteSafe(filePath); + } + + public void RemoveStaleEntries(HashSet validPaths) + { + List toRemove = new List(); + foreach (string path in Files.Keys) + { + if (!validPaths.Contains(path)) + toRemove.Add(path); + } + foreach (string path in toRemove) + Files.Remove(path); + } + + private static long GetLastWriteSafe(string path) + { + try + { + return File.GetLastWriteTimeUtc(path).Ticks; + } + catch + { + return 0; + } + } + + public void Load() + { + try + { + if (!File.Exists(IndexPath)) + return; + + string json = File.ReadAllText(IndexPath); + var reader = new JsonReader(); + var data = reader.Read>(json); + if (data == null) + return; + + Files.Clear(); + foreach (var kv in data) + { + if (kv.Value is Dictionary entry) + { + var prefabIndexEntry = new PrefabIndexEntry(); + if (entry.TryGetValue("LastWriteTime", out object lwt)) + prefabIndexEntry.LastWriteTime = Convert.ToInt64(lwt); + if (entry.TryGetValue("PrefabNames", out object names) && names is List nameList) + { + prefabIndexEntry.PrefabNames = nameList.ConvertAll(x => x?.ToString() ?? string.Empty).ToArray(); + } + Files[kv.Key] = prefabIndexEntry; + } + } + } + catch (Exception ex) + { + Mod.Log.LogWarning($"Failed to load prefab index: {ex.Message}"); + Files.Clear(); + } + } + + public void Save() + { + try + { + var data = new Dictionary(); + foreach (var kv in Files) + { + var entry = new Dictionary + { + ["LastWriteTime"] = kv.Value.LastWriteTime, + ["PrefabNames"] = new List(kv.Value.PrefabNames ?? new string[0]) + }; + data[kv.Key] = entry; + } + + string dir = Path.GetDirectoryName(IndexPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var writer = new JsonWriter(new DataWriterSettings { PrettyPrint = true }); + File.WriteAllText(IndexPath, writer.Write(data)); + } + catch (Exception ex) + { + Mod.Log.LogWarning($"Failed to save prefab index: {ex.Message}"); + } + } + } + + public class PrefabIndexEntry + { + public long LastWriteTime { get; set; } + public string[] PrefabNames { get; set; } = new string[0]; + } +} diff --git a/Distance.CustomCar/Distance.CustomCar.csproj b/Distance.CustomCar/Distance.CustomCar.csproj index 8259d54..0ccf188 100644 --- a/Distance.CustomCar/Distance.CustomCar.csproj +++ b/Distance.CustomCar/Distance.CustomCar.csproj @@ -62,6 +62,7 @@ + diff --git a/Distance.CustomCar/Extensions/System/Collections/Generic/DictionaryExtensions.cs b/Distance.CustomCar/Extensions/System/Collections/Generic/DictionaryExtensions.cs index bf90ce0..f2903cb 100644 --- a/Distance.CustomCar/Extensions/System/Collections/Generic/DictionaryExtensions.cs +++ b/Distance.CustomCar/Extensions/System/Collections/Generic/DictionaryExtensions.cs @@ -1,4 +1,5 @@ #pragma warning disable RCS1110 +using System; using System.Collections.Generic; public static class DictionaryExtensions @@ -52,4 +53,15 @@ public static T GetOrCreate(this Dictionary obj, string key, return obj.GetItem(key); } + + public static T GetOrCreate(this Dictionary obj, string key, Func factory) where T : class + { + if (!obj.ContainsKey(key)) + { + T value = factory(); + obj[key] = value; + return value; + } + return (T)obj[key]; + } } \ No newline at end of file diff --git a/Distance.CustomCar/ProfileCarColors.cs b/Distance.CustomCar/ProfileCarColors.cs index ba986bb..ea4ed0f 100644 --- a/Distance.CustomCar/ProfileCarColors.cs +++ b/Distance.CustomCar/ProfileCarColors.cs @@ -23,14 +23,12 @@ protected void Awake() protected Dictionary Profile(string profileName) { - Mod.Log.LogInfo("Assigning Profile Name..."); - return Config.GetOrCreate(profileName, new Dictionary()); + return Config.GetOrCreate(profileName, () => new Dictionary()); } protected Dictionary Vehicle(string profileName, string vehicleName) { - Mod.Log.LogInfo("Assigning Vehicle Name..."); - return Profile(profileName).GetOrCreate(vehicleName, new Dictionary()); + return Profile(profileName).GetOrCreate(vehicleName, () => new Dictionary()); } protected CarColors GetCarColors(string profileName, string vehicleName) @@ -50,7 +48,7 @@ protected CarColors GetCarColors(string profileName, string vehicleName) protected Color GetColor(Dictionary vehicle, string category, Color defaultColor) { //Mod.Log.LogWarning(e); - Dictionary color = vehicle.GetOrCreate(category, new Dictionary()); + Dictionary color = vehicle.GetOrCreate(category, () => new Dictionary()); float r = color.GetOrCreate("r", defaultColor.r); float g = color.GetOrCreate("g", defaultColor.g); diff --git a/Distance.CustomCar/Section.cs b/Distance.CustomCar/Section.cs index 383ebb6..4d4d7e3 100644 --- a/Distance.CustomCar/Section.cs +++ b/Distance.CustomCar/Section.cs @@ -79,6 +79,18 @@ public T GetOrCreate(string key, T defaultValue) return GetItem(key); } + public T GetOrCreate(string key, Func factory) where T : class + { + if (!ContainsKey(key)) + { + T value = factory(); + this[key] = value; + ValueChanged?.Invoke(this, new SettingsChangedEventArgs(key, null, this[key])); + return value; + } + return (T)this[key]; + } + public bool ContainsKey(string key) { try