diff --git a/KSPCommunityFixes/GlobalSuppressions.cs b/KSPCommunityFixes/GlobalSuppressions.cs index 2c49b00..431d157 100644 --- a/KSPCommunityFixes/GlobalSuppressions.cs +++ b/KSPCommunityFixes/GlobalSuppressions.cs @@ -9,6 +9,5 @@ [assembly: SuppressMessage("Style", "IDE0017:Simplify object initialization")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types")] [assembly: SuppressMessage("Style", "IDE0016:Use 'throw' expression", Justification = "", Scope = "member", Target = "~M:KSPCommunityFixes.BasePatch.PatchInfo.#ctor(KSPCommunityFixes.BasePatch.PatchMethodType,System.Reflection.MethodBase,KSPCommunityFixes.BasePatch,System.String,System.Int32)")] -[assembly: SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "", Scope = "type", Target = "~T:KSPCommunityFixes.TextureLoaderOptimizations.CachedTextureInfo")] [assembly: SuppressMessage("Style", "IDE0028:Simplify collection initialization", Justification = "", Scope = "member", Target = "~M:KSPCommunityFixes.LocalizationUtils.GenerateLocTemplate(System.String)")] diff --git a/KSPCommunityFixes/Internal/PatchSettings.cs b/KSPCommunityFixes/Internal/PatchSettings.cs index 700076c..554625f 100644 --- a/KSPCommunityFixes/Internal/PatchSettings.cs +++ b/KSPCommunityFixes/Internal/PatchSettings.cs @@ -38,9 +38,6 @@ protected override void ApplyPatches() if (disableMHPatch != null) entryCount++; - if (KSPCFFastLoader.IsPatchEnabled) - entryCount++; - // NoIVA is always enabled entryCount++; } @@ -107,21 +104,6 @@ static void GameplaySettingsScreen_DrawMiniSettings_Postfix(ref DialogGUIBase[] count++; } - if (KSPCFFastLoader.IsPatchEnabled) - { - DialogGUIToggle toggle = new DialogGUIToggle(KSPCFFastLoader.TextureCacheEnabled, - () => (KSPCFFastLoader.TextureCacheEnabled) - ? Localizer.Format("#autoLOC_900889") //"Enabled" - : Localizer.Format("#autoLOC_900890"), //"Disabled" - KSPCFFastLoader.OnToggleCacheFromSettings, 150f); - toggle.tooltipText = KSPCFFastLoader.LOC_SettingsTooltip; - - modifiedResult[count] = new DialogGUIHorizontalLayout(TextAnchor.MiddleLeft, - new DialogGUILabel(() => KSPCFFastLoader.LOC_SettingsTitle, 150f), - toggle, new DialogGUIFlexibleSpace()); - count++; - } - DialogGUISlider noIVAslider = new DialogGUISlider(NoIVA.PatchStateToFloat, 0f, 2f, true, 100f, 20f, NoIVA.SwitchPatchState); noIVAslider.tooltipText = NoIVA.LOC_SettingsTooltip; DialogGUILabel valueLabel = new DialogGUILabel(NoIVA.PatchStateTitle); diff --git a/KSPCommunityFixes/KSPCommunityFixes.csproj b/KSPCommunityFixes/KSPCommunityFixes.csproj index c9e4754..cf4b892 100644 --- a/KSPCommunityFixes/KSPCommunityFixes.csproj +++ b/KSPCommunityFixes/KSPCommunityFixes.csproj @@ -104,6 +104,8 @@ + + diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index c744a53..db07aab 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -18,10 +18,14 @@ using System.Reflection; using System.Reflection.Emit; using System.Runtime.InteropServices; +using System.Runtime.Serialization; using System.Threading; using KSPCommunityFixes.Library; using TMPro; using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.IO.LowLevel.Unsafe; +using Unity.Profiling; using UnityEngine; using UnityEngine.Experimental.Rendering; using UnityEngine.Networking; @@ -31,6 +35,9 @@ using Debug = UnityEngine.Debug; using UnityEngine.Profiling; using System.Threading.Tasks; +using KSP.UI; +using System.Security.Cryptography; +using UnityEngine.Rendering; namespace KSPCommunityFixes.Performance { @@ -124,12 +131,6 @@ void OnDestroy() [KSPAddon(KSPAddon.Startup.Instantly, true)] internal class KSPCFFastLoader : MonoBehaviour { - public static string LOC_SettingsTitle = "Texture caching optimization"; - public static string LOC_SettingsTooltip = - "Cache PNG textures on disk instead of converting them on every KSP launch." + - "\nSpeedup loading time but increase disk space usage." + - "\nChanges will take effect after relaunching KSP"; - public static string LOC_PopupL1 = "KSPCommunityFixes can cache converted PNG textures on disk to speed up loading time."; public static string LOC_F_PopupL2 = @@ -151,6 +152,10 @@ internal class KSPCFFastLoader : MonoBehaviour // min amount of files to try to keep in memory, regardless of maxBufferSize private const int minFileRead = 10; + // Max number of new texture load coroutines that will be spawned each frame. + // This should roughly limit the max frame time spent on loading textures. + private const int MaxTextureSpawnsPerFrame = 512; + private static Harmony persistentHarmony; private static string PersistentHarmonyID => typeof(KSPCFFastLoader).FullName; @@ -163,22 +168,14 @@ internal class KSPCFFastLoader : MonoBehaviour public static KSPCFFastLoader loader; public static bool IsPatchEnabled { get; private set; } - public static bool TextureCacheEnabled => textureCacheEnabled; + // Vestigial: kept so the popup can persist its choice across launches once it is repurposed. private static bool textureCacheEnabled; private static string ModPath => Path.GetDirectoryName(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); private static string ConfigPath => Path.Combine(ModPath, "PluginData", "PNGTextureCache.cfg"); private bool userOptInChoiceDone; - private const string textureCacheVersion = "V2"; private string configPath; - private string textureCachePath; - private string textureCacheDataPath; - private string textureProgressMarkerPath; - - private Dictionary textureCacheData; - private HashSet textureDataIds; - private bool cacheUpdated = false; internal static Dictionary modelsByUrl; internal static Dictionary modelsByDirectoryUrl; @@ -247,7 +244,6 @@ private void Awake() GameEvents.OnGameDatabaseLoaded.Add(OnGameDatabaseLoaded); configPath = ConfigPath; - textureCachePath = Path.Combine(ModPath, "PluginData", "TextureCache"); if (File.Exists(configPath)) { @@ -259,17 +255,6 @@ private void Awake() if (!config.TryGetValue(nameof(textureCacheEnabled), ref textureCacheEnabled)) userOptInChoiceDone = false; } - -#if DEBUG && !DEBUG_TEXTURE_CACHE - userOptInChoiceDone = true; - textureCacheEnabled = false; -#endif - } - - void Start() - { - if (IsPatchEnabled && !userOptInChoiceDone) - StartCoroutine(WaitForUserOptIn()); } /// @@ -458,10 +443,6 @@ static IEnumerator FastAssetLoader(List configFileTypes) KSPCFFastLoaderReport.wConfigTranslate.Stop(); yield return null; - gdb.progressTitle = "Waiting for PNGTextureCache opt-in..."; - while (!loader.userOptInChoiceDone) - yield return null; - // Start load asset bundles in the background while we load other assets. PreloadAssetBundleObjects(gdb); @@ -473,7 +454,7 @@ static IEnumerator FastAssetLoader(List configFileTypes) // Files loaded by our custom loaders List audioFiles = new List(1000); - List textureAssets = new List(10000); + List textureRequests = new List(10000); List modelAssets = new List(5000); // Files loaded by mod-defined loaders (ex : Shabby *.shab files) @@ -519,23 +500,23 @@ static IEnumerator FastAssetLoader(List configFileTypes) switch (file.fileExtension) { case "dds": - textureAssets.Add(new RawAsset(file, RawAsset.AssetType.TextureDDS)); + textureRequests.Add(new TextureLoadRequest(file, RawAsset.AssetType.TextureDDS)); break; case "jpg": case "jpeg": - textureAssets.Add(new RawAsset(file, RawAsset.AssetType.TextureJPG)); + textureRequests.Add(new TextureLoadRequest(file, RawAsset.AssetType.TextureJPG)); break; case "mbm": - textureAssets.Add(new RawAsset(file, RawAsset.AssetType.TextureMBM)); + textureRequests.Add(new TextureLoadRequest(file, RawAsset.AssetType.TextureMBM)); break; case "png": - textureAssets.Add(new RawAsset(file, RawAsset.AssetType.TexturePNG)); + textureRequests.Add(new TextureLoadRequest(file, RawAsset.AssetType.TexturePNG)); break; case "tga": - textureAssets.Add(new RawAsset(file, RawAsset.AssetType.TextureTGA)); + textureRequests.Add(new TextureLoadRequest(file, RawAsset.AssetType.TextureTGA)); break; case "truecolor": - textureAssets.Add(new RawAsset(file, RawAsset.AssetType.TextureTRUECOLOR)); + textureRequests.Add(new TextureLoadRequest(file, RawAsset.AssetType.TextureTRUECOLOR)); break; default: unsupportedTextureFiles.Add(file); @@ -567,17 +548,6 @@ static IEnumerator FastAssetLoader(List configFileTypes) } } - Thread textureCacheReaderThread; - if (textureCacheEnabled) - { - textureCacheReaderThread = new Thread(() => SetupTextureCacheThread(textureAssets)); - textureCacheReaderThread.Start(); - } - else - { - textureCacheReaderThread = null; - } - gdb.progressTitle = "Loading sound assets..."; KSPCFFastLoaderReport.wAudioLoading.Restart(); yield return null; @@ -668,6 +638,7 @@ static IEnumerator FastAssetLoader(List configFileTypes) // start texture loading gdb.progressFraction = 0.25f; KSPCFFastLoaderReport.wAudioLoading.Stop(); + SupportedFormatCache.Build(); KSPCFFastLoaderReport.wTextureLoading.Restart(); gdb.progressTitle = "Loading texture assets..."; yield return null; @@ -716,19 +687,9 @@ static IEnumerator FastAssetLoader(List configFileTypes) } } - if (textureCacheReaderThread != null) - { - while (textureCacheReaderThread.IsAlive) - yield return null; - } - // call our custom loader - yield return gdb.StartCoroutine(FilesLoader(textureAssets, allTextureFiles, "Loading texture asset")); - - // write texture cache json to disk - Thread writeTextureCacheThread = new Thread(() => loader.WriteTextureCache()); - writeTextureCacheThread.Start(); + yield return gdb.StartCoroutine(TextureDriverCoroutine(textureRequests, allTextureFiles)); // start model loading gdb.progressFraction = 0.75f; @@ -1029,7 +990,6 @@ public enum AssetType TextureJPG, TextureMBM, TexturePNG, - TexturePNGCached, TextureTGA, TextureTRUECOLOR, ModelMU, @@ -1042,7 +1002,6 @@ public enum AssetType "JPG texture", "MBM texture", "PNG texture", - "Cached PNG Texture", "TGA texture", "TRUECOLOR texture", "MU model", @@ -1057,13 +1016,10 @@ public enum Result } private UrlFile file; - private CachedTextureInfo cachedTextureInfo; private AssetType assetType; private bool useRentedBuffer; private byte[] buffer; private int dataLength; - private MemoryStream memoryStream; - private BinaryReader binaryReader; private Result result; private string resultMessage; @@ -1112,9 +1068,6 @@ public void ReadFromDiskWorkerThread() { switch (assetType) { - case AssetType.TextureDDS: - case AssetType.TextureMBM: - case AssetType.TextureTGA: case AssetType.ModelMU: case AssetType.ModelDAE: useRentedBuffer = true; @@ -1123,7 +1076,7 @@ public void ReadFromDiskWorkerThread() try { - string path = assetType == AssetType.TexturePNGCached ? cachedTextureInfo.FilePath : file.fullPath; + string path = file.fullPath; using (FileStream fileStream = System.IO.File.OpenRead(path)) { @@ -1184,54 +1137,7 @@ public void LoadAndDisposeMainThread() if (result == Result.Failed) return; - if (file.fileType == FileType.Texture) - { - TextureInfo textureInfo; - switch (assetType) - { - case AssetType.TextureDDS: - textureInfo = LoadDDS(); - break; - case AssetType.TextureJPG: - textureInfo = LoadJPG(); - break; - case AssetType.TextureMBM: - textureInfo = LoadMBM(); - break; - case AssetType.TexturePNG: - textureInfo = LoadPNG(); - break; - case AssetType.TexturePNGCached: - textureInfo = LoadPNGCached(); - break; - case AssetType.TextureTGA: - textureInfo = LoadTGA(); - break; - case AssetType.TextureTRUECOLOR: - textureInfo = LoadTRUECOLOR(); - break; - default: - SetError("Unknown texture format"); - return; - } - - if (result == Result.Failed || textureInfo == null || textureInfo.texture.IsNullOrDestroyed()) - { - result = Result.Failed; - if (string.IsNullOrEmpty(resultMessage)) - resultMessage = $"{TypeName} load error"; - } - else - { - textureInfo.name = file.url; - textureInfo.texture.name = file.url; - Instance.databaseTexture.Add(textureInfo); - texturesByUrl[file.url] = textureInfo; - KSPCFFastLoaderReport.texturesBytesLoaded += dataLength; - KSPCFFastLoaderReport.texturesLoaded++; - } - } - else if (file.fileType == FileType.Model) + if (file.fileType == FileType.Model) { GameObject model; switch (assetType) @@ -1284,514 +1190,1064 @@ public void LoadAndDisposeMainThread() public void Dispose() { - if (binaryReader != null) - binaryReader.Dispose(); - - if (memoryStream != null) - memoryStream.Dispose(); - if (useRentedBuffer) arrayPool.Return(buffer); } - public void CheckTextureCache() + private GameObject LoadMU() + { + return MuParser.Parse(file.parent.url, buffer, dataLength); + } + + private GameObject LoadDAE() { - CachedTextureInfo cachedTextureInfo = GetCachedTextureInfo(file); + // given that this is a quite obsolete thing and that it's mess to reimplement, just call the stock + // stuff and re-load the file - if (cachedTextureInfo == null) - return; + GameObject gameObject = new DatabaseLoaderModel_DAE.DAE().Load(file, new FileInfo(file.fullPath)); + if (gameObject.IsNotNullOrDestroyed()) + { + MeshFilter[] componentsInChildren = gameObject.GetComponentsInChildren(); + foreach (MeshFilter meshFilter in componentsInChildren) + { + if (meshFilter.gameObject.name == "node_collider") + { + meshFilter.gameObject.AddComponent().sharedMesh = meshFilter.mesh; + MeshRenderer component = meshFilter.gameObject.GetComponent(); + UnityEngine.Object.Destroy(meshFilter); + UnityEngine.Object.Destroy(component); + } + } + } - assetType = AssetType.TexturePNGCached; - this.cachedTextureInfo = cachedTextureInfo; + return gameObject; } - // see https://learn.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide - private enum DDSFourCC : uint + } + + #endregion + + #region Per-texture coroutine loader + + // Profiling markers for the work scheduled on background threads via Task.Run. + // Each marker.Auto() scope is opened inside the Task lambda so the timing + // appears under that thread in the Unity profiler. + private static readonly ProfilerMarker s_pmParseDDSHeader = new ProfilerMarker("KSPCF.Tex.ParseDDSHeader"); + private static readonly ProfilerMarker s_pmSwizzleNormalMap = new ProfilerMarker("KSPCF.Tex.SwizzleNormalMap"); + private static readonly ProfilerMarker s_pmFileSize = new ProfilerMarker("KSPCF.Tex.FileSize"); + private static readonly ProfilerMarker s_pmReadAllBytes = new ProfilerMarker("KSPCF.Tex.ReadAllBytes"); + private static readonly ProfilerMarker s_pmCompress = new ProfilerMarker("KSPCF.Tex.Compress"); + private static readonly ProfilerMarker s_pmGetRawDataDDS = new ProfilerMarker("KSPCF.Tex.LoadDDS.GetRawTextureData"); + private static readonly ProfilerMarker s_pmGetRawDataUWR = new ProfilerMarker("KSPCF.Tex.LoadUWR.GetRawTextureData"); + private static readonly ProfilerMarker s_pmGetRawDataTRUECOLOR = new ProfilerMarker("KSPCF.Tex.LoadTRUECOLOR.GetRawTextureData"); + private static readonly ProfilerMarker s_pmGetRawDataTGA = new ProfilerMarker("KSPCF.Tex.LoadTGA.GetRawTextureData"); + + // Result/error carrier for each texture file. Replaces RawAsset for textures. + private sealed class TextureLoadRequest + { + public enum State : byte { Pending, Ready, Failed } + + public UrlFile File; + public RawAsset.AssetType AssetType; + public long FileLength; + public volatile State Status; + public TextureInfo Result; + public string ErrorMessage; + public Exception Exception; + + public TextureLoadRequest(UrlFile file, RawAsset.AssetType assetType) { - DXT1 = 0x31545844, // "DXT1" - DXT2 = 0x32545844, // "DXT2" - DXT3 = 0x33545844, // "DXT3" - DXT4 = 0x34545844, // "DXT4" - DXT5 = 0x35545844, // "DXT5" - BC4U_ATI = 0x31495441, // "ATI1" (actually BC4U) - BC4U = 0x55344342, // "BC4U" - BC4S = 0x53344342, // "BC4S" - BC5U_ATI = 0x32495441, // "ATI2" (actually BC5U) - BC5U = 0x55354342, // "BC5U" - BC5S = 0x53354342, // "BC5S" - RGBG = 0x47424752, // "RGBG" - GRGB = 0x42475247, // "GRGB" - UYVY = 0x59565955, // "UYVY" - YUY2 = 0x32595559, // "YUY2" - DX10 = 0x30315844, // "DX10", actual DXGI format specified in DX10 header - R16G16B16A16_UNORM = 36, - R16G16B16A16_SNORM = 110, - R16_FLOAT = 111, - R16G16_FLOAT = 112, - R16G16B16A16_FLOAT = 113, - R32_FLOAT = 114, - R32G32_FLOAT = 115, - R32G32B32A32_FLOAT = 116, - CxV8U8 = 117, + File = file; + AssetType = assetType; + Status = State.Pending; } + } + + // Result of background DDS header parsing. + private struct DDSPreparedHeader + { + public int Width; + public int Height; + public bool MipChain; + public bool IsNormalMap; + public GraphicsFormat Format; + public long DataOffset; + public long FileLength; + } + + // Probes which GraphicsFormats are actually usable on the running GPU. + // Built once on the main thread before texture loading starts so that the + // background DDS header parser can produce a format and we can verify it + // against this set without needing main-thread access. + private static class SupportedFormatCache + { + private static HashSet supported; + + public static void Build() + { + supported = new HashSet(); + GraphicsFormat[] candidates = new[] + { + GraphicsFormat.RGBA_DXT1_UNorm, + GraphicsFormat.RGBA_DXT1_SRGB, + GraphicsFormat.RGBA_DXT5_UNorm, + GraphicsFormat.RGBA_DXT5_SRGB, + GraphicsFormat.R_BC4_UNorm, + GraphicsFormat.R_BC4_SNorm, + GraphicsFormat.RG_BC5_UNorm, + GraphicsFormat.RG_BC5_SNorm, + GraphicsFormat.RGBA_BC7_UNorm, + GraphicsFormat.RGBA_BC7_SRGB, + GraphicsFormat.RGB_BC6H_SFloat, + GraphicsFormat.RGB_BC6H_UFloat, + GraphicsFormat.R16G16B16A16_UNorm, + GraphicsFormat.R16G16B16A16_SNorm, + GraphicsFormat.R16G16B16A16_SFloat, + GraphicsFormat.R16_SFloat, + GraphicsFormat.R16G16_SFloat, + GraphicsFormat.R32_SFloat, + GraphicsFormat.R32G32_SFloat, + GraphicsFormat.R32G32B32A32_SFloat, + }; + foreach (GraphicsFormat fmt in candidates) + if (SystemInfo.IsFormatSupported(fmt, FormatUsage.Sample)) + supported.Add(fmt); + } + + public static bool IsSupported(GraphicsFormat fmt) => supported != null && supported.Contains(fmt); + } + + private static DDSPreparedHeader ParseDDSHeader(string path) + { + FileInfo fi = new FileInfo(path); + long fileLength = fi.Length; + if (fileLength < 128) + throw new IOException($"DDS file '{path}' is too small ({fileLength} bytes)"); + + using FileStream fs = File.OpenRead(path); + using BinaryReader br = new BinaryReader(fs); - private TextureInfo LoadDDS() + if (br.ReadUInt32() != DDSValues.uintMagic) + throw new IOException($"DDS: '{path}' is not a DDS format file"); + + DDSHeader hdr = new DDSHeader(br); + bool mipChain = (hdr.dwCaps & DDSPixelFormatCaps.MIPMAP) != 0; + bool isNormalMap = (hdr.ddspf.dwFlags & 0x80000u) != 0 || (hdr.ddspf.dwFlags & 0x80000000u) != 0; + + DDSHeaderDX10 dx10Header = default; + bool hasDx10 = (DDSFourCC)hdr.ddspf.dwFourCC == DDSFourCC.DX10; + if (hasDx10) { - memoryStream = new MemoryStream(buffer, 0, dataLength); - binaryReader = new BinaryReader(memoryStream); + if (fileLength < 148) + throw new IOException($"DDS file '{path}' has DX10 marker but is too small for DX10 header"); + dx10Header = new DDSHeaderDX10(br); + } - if (binaryReader.ReadUInt32() != DDSValues.uintMagic) - { - SetError("DDS: File is not a DDS format file!"); - return null; - } - DDSHeader dDSHeader = new DDSHeader(binaryReader); - bool mipChain = (dDSHeader.dwCaps & DDSPixelFormatCaps.MIPMAP) != 0; - bool isNormalMap = (dDSHeader.ddspf.dwFlags & 0x80000u) != 0 || (dDSHeader.ddspf.dwFlags & 0x80000000u) != 0; + GraphicsFormat fmt = MapDDSFormat(hdr, hasDx10, dx10Header, out string error); + if (fmt == GraphicsFormat.None || error != null) + throw new IOException($"DDS: {error ?? "unknown format"}"); - DDSFourCC ddsFourCC = (DDSFourCC)dDSHeader.ddspf.dwFourCC; - Texture2D texture2D = null; - GraphicsFormat graphicsFormat = GraphicsFormat.None; + long dataOffset = hasDx10 ? 148 : 128; + return new DDSPreparedHeader + { + Width = (int)hdr.dwWidth, + Height = (int)hdr.dwHeight, + MipChain = mipChain, + IsNormalMap = isNormalMap, + Format = fmt, + DataOffset = dataOffset, + FileLength = fileLength, + }; + } - switch (ddsFourCC) - { - case DDSFourCC.DXT1: - graphicsFormat = GraphicsFormatUtility.GetGraphicsFormat(TextureFormat.DXT1, true); - break; - case DDSFourCC.DXT5: - graphicsFormat = GraphicsFormatUtility.GetGraphicsFormat(TextureFormat.DXT5, true); - break; - case DDSFourCC.BC4U_ATI: - case DDSFourCC.BC4U: - graphicsFormat = GraphicsFormat.R_BC4_UNorm; - break; - case DDSFourCC.BC4S: - graphicsFormat = GraphicsFormat.R_BC4_SNorm; - break; - case DDSFourCC.BC5U_ATI: - case DDSFourCC.BC5U: - graphicsFormat = GraphicsFormat.RG_BC5_UNorm; - break; - case DDSFourCC.BC5S: - graphicsFormat = GraphicsFormat.RG_BC5_SNorm; - break; - case DDSFourCC.R16G16B16A16_UNORM: - graphicsFormat = GraphicsFormat.R16G16B16A16_UNorm; - break; - case DDSFourCC.R16G16B16A16_SNORM: - graphicsFormat = GraphicsFormat.R16G16B16A16_SNorm; - break; - case DDSFourCC.R16_FLOAT: - graphicsFormat = GraphicsFormat.R16_SFloat; - break; - case DDSFourCC.R16G16_FLOAT: - graphicsFormat = GraphicsFormat.R16G16_SFloat; - break; - case DDSFourCC.R16G16B16A16_FLOAT: - graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat; - break; - case DDSFourCC.R32_FLOAT: - graphicsFormat = GraphicsFormat.R32_SFloat; - break; - case DDSFourCC.R32G32_FLOAT: - graphicsFormat = GraphicsFormat.R32G32_SFloat; - break; - case DDSFourCC.R32G32B32A32_FLOAT: - graphicsFormat = GraphicsFormat.R32G32B32A32_SFloat; - break; - case DDSFourCC.DX10: - DDSHeaderDX10 dx10Header = new DDSHeaderDX10(binaryReader); - switch (dx10Header.dxgiFormat) - { - case DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM: - graphicsFormat = GraphicsFormat.RGBA_DXT1_UNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM_SRGB: - graphicsFormat = GraphicsFormat.RGBA_DXT1_SRGB; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM: - graphicsFormat = GraphicsFormat.RGBA_DXT5_UNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM_SRGB: - graphicsFormat = GraphicsFormat.RGBA_DXT5_SRGB; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC4_SNORM: - graphicsFormat = GraphicsFormat.R_BC4_SNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC4_UNORM: - graphicsFormat = GraphicsFormat.R_BC4_UNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC5_SNORM: - graphicsFormat = GraphicsFormat.RG_BC5_SNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC5_UNORM: - graphicsFormat = GraphicsFormat.RG_BC5_UNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC7_UNORM: - graphicsFormat = GraphicsFormat.RGBA_BC7_UNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC7_UNORM_SRGB: - graphicsFormat = GraphicsFormat.RGBA_BC7_SRGB; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC6H_SF16: - graphicsFormat = GraphicsFormat.RGB_BC6H_SFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_BC6H_UF16: - graphicsFormat = GraphicsFormat.RGB_BC6H_UFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_UNORM: - graphicsFormat = GraphicsFormat.R16G16B16A16_UNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_SNORM: - graphicsFormat = GraphicsFormat.R16G16B16A16_SNorm; - break; - case DXGI_FORMAT.DXGI_FORMAT_R16_FLOAT: - graphicsFormat = GraphicsFormat.R16_SFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_R16G16_FLOAT: - graphicsFormat = GraphicsFormat.R16G16_SFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_FLOAT: - graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_R32_FLOAT: - graphicsFormat = GraphicsFormat.R32_SFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_R32G32_FLOAT: - graphicsFormat = GraphicsFormat.R32G32_SFloat; - break; - case DXGI_FORMAT.DXGI_FORMAT_R32G32B32A32_FLOAT: - graphicsFormat = GraphicsFormat.R32G32B32A32_SFloat; - break; - default: - SetError($"DDS: The '{dx10Header.dxgiFormat}' DXT10 format isn't supported"); - break; - } - break; - case DDSFourCC.DXT2: - case DDSFourCC.DXT3: - case DDSFourCC.DXT4: - case DDSFourCC.RGBG: - case DDSFourCC.GRGB: - case DDSFourCC.UYVY: - case DDSFourCC.YUY2: - case DDSFourCC.CxV8U8: - SetError($"DDS: The '{ddsFourCC}' format isn't supported, use DXT1 for RGB textures or DXT5 for RGBA textures"); - break; - default: - SetError($"DDS: Unknown dwFourCC format '0x{ddsFourCC:X}'"); - break; - } + private enum DDSFourCC : uint + { + DXT1 = 0x31545844, + DXT2 = 0x32545844, + DXT3 = 0x33545844, + DXT4 = 0x34545844, + DXT5 = 0x35545844, + BC4U_ATI = 0x31495441, + BC4U = 0x55344342, + BC4S = 0x53344342, + BC5U_ATI = 0x32495441, + BC5U = 0x55354342, + BC5S = 0x53354342, + RGBG = 0x47424752, + GRGB = 0x42475247, + UYVY = 0x59565955, + YUY2 = 0x32595559, + DX10 = 0x30315844, + R16G16B16A16_UNORM = 36, + R16G16B16A16_SNORM = 110, + R16_FLOAT = 111, + R16G16_FLOAT = 112, + R16G16B16A16_FLOAT = 113, + R32_FLOAT = 114, + R32G32_FLOAT = 115, + R32G32B32A32_FLOAT = 116, + CxV8U8 = 117, + } - if (graphicsFormat != GraphicsFormat.None) - { - if (!SystemInfo.IsFormatSupported(graphicsFormat, FormatUsage.Sample)) + // Returns GraphicsFormat.None and sets error on failure. + private static GraphicsFormat MapDDSFormat(DDSHeader hdr, bool hasDx10, DDSHeaderDX10 dx10, out string error) + { + error = null; + DDSFourCC fourCC = (DDSFourCC)hdr.ddspf.dwFourCC; + switch (fourCC) + { + case DDSFourCC.DXT1: return GraphicsFormatUtility.GetGraphicsFormat(TextureFormat.DXT1, true); + case DDSFourCC.DXT5: return GraphicsFormatUtility.GetGraphicsFormat(TextureFormat.DXT5, true); + case DDSFourCC.BC4U_ATI: + case DDSFourCC.BC4U: return GraphicsFormat.R_BC4_UNorm; + case DDSFourCC.BC4S: return GraphicsFormat.R_BC4_SNorm; + case DDSFourCC.BC5U_ATI: + case DDSFourCC.BC5U: return GraphicsFormat.RG_BC5_UNorm; + case DDSFourCC.BC5S: return GraphicsFormat.RG_BC5_SNorm; + case DDSFourCC.R16G16B16A16_UNORM: return GraphicsFormat.R16G16B16A16_UNorm; + case DDSFourCC.R16G16B16A16_SNORM: return GraphicsFormat.R16G16B16A16_SNorm; + case DDSFourCC.R16_FLOAT: return GraphicsFormat.R16_SFloat; + case DDSFourCC.R16G16_FLOAT: return GraphicsFormat.R16G16_SFloat; + case DDSFourCC.R16G16B16A16_FLOAT: return GraphicsFormat.R16G16B16A16_SFloat; + case DDSFourCC.R32_FLOAT: return GraphicsFormat.R32_SFloat; + case DDSFourCC.R32G32_FLOAT: return GraphicsFormat.R32G32_SFloat; + case DDSFourCC.R32G32B32A32_FLOAT: return GraphicsFormat.R32G32B32A32_SFloat; + case DDSFourCC.DX10: + if (!hasDx10) { - if (SystemInfo.operatingSystemFamily == OperatingSystemFamily.MacOSX && - (graphicsFormat == GraphicsFormat.RGBA_BC7_UNorm - || graphicsFormat == GraphicsFormat.RGBA_BC7_SRGB - || graphicsFormat == GraphicsFormat.RGB_BC6H_SFloat - || graphicsFormat == GraphicsFormat.RGB_BC6H_UFloat)) - { - SetError($"DDS: The '{graphicsFormat}' format is not supported on MacOS"); - } - else - { - SetError($"DDS: The '{graphicsFormat}' format is not supported by your GPU or OS"); - } + error = "DX10 marker without DX10 header"; + return GraphicsFormat.None; } - else + switch (dx10.dxgiFormat) { - texture2D = new Texture2D((int)dDSHeader.dwWidth, (int)dDSHeader.dwHeight, graphicsFormat, mipChain ? TextureCreationFlags.MipChain : TextureCreationFlags.None); - if (texture2D.IsNullOrDestroyed()) - { - SetError($"DDS: Failed to load texture, unknown error"); - } - else - { - int position = (int)binaryReader.BaseStream.Position; - GCHandle pinnedHandle = GCHandle.Alloc(buffer, GCHandleType.Pinned); - try - { - IntPtr ptr = Marshal.UnsafeAddrOfPinnedArrayElement(buffer, position); - texture2D.LoadRawTextureData(ptr, dataLength - position); - } - finally - { - pinnedHandle.Free(); - } - - texture2D.Apply(updateMipmaps: false, makeNoLongerReadable: true); - } + case DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM: return GraphicsFormat.RGBA_DXT1_UNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM_SRGB: return GraphicsFormat.RGBA_DXT1_SRGB; + case DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM: return GraphicsFormat.RGBA_DXT5_UNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM_SRGB: return GraphicsFormat.RGBA_DXT5_SRGB; + case DXGI_FORMAT.DXGI_FORMAT_BC4_SNORM: return GraphicsFormat.R_BC4_SNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC4_UNORM: return GraphicsFormat.R_BC4_UNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC5_SNORM: return GraphicsFormat.RG_BC5_SNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC5_UNORM: return GraphicsFormat.RG_BC5_UNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC7_UNORM: return GraphicsFormat.RGBA_BC7_UNorm; + case DXGI_FORMAT.DXGI_FORMAT_BC7_UNORM_SRGB: return GraphicsFormat.RGBA_BC7_SRGB; + case DXGI_FORMAT.DXGI_FORMAT_BC6H_SF16: return GraphicsFormat.RGB_BC6H_SFloat; + case DXGI_FORMAT.DXGI_FORMAT_BC6H_UF16: return GraphicsFormat.RGB_BC6H_UFloat; + case DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_UNORM: return GraphicsFormat.R16G16B16A16_UNorm; + case DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_SNORM: return GraphicsFormat.R16G16B16A16_SNorm; + case DXGI_FORMAT.DXGI_FORMAT_R16_FLOAT: return GraphicsFormat.R16_SFloat; + case DXGI_FORMAT.DXGI_FORMAT_R16G16_FLOAT: return GraphicsFormat.R16G16_SFloat; + case DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_FLOAT: return GraphicsFormat.R16G16B16A16_SFloat; + case DXGI_FORMAT.DXGI_FORMAT_R32_FLOAT: return GraphicsFormat.R32_SFloat; + case DXGI_FORMAT.DXGI_FORMAT_R32G32_FLOAT: return GraphicsFormat.R32G32_SFloat; + case DXGI_FORMAT.DXGI_FORMAT_R32G32B32A32_FLOAT: return GraphicsFormat.R32G32B32A32_SFloat; + default: + error = $"DXT10 format '{dx10.dxgiFormat}' is not supported"; + return GraphicsFormat.None; } - } - - return new TextureInfo(file, texture2D, isNormalMap, false, true); + case DDSFourCC.DXT2: + case DDSFourCC.DXT3: + case DDSFourCC.DXT4: + case DDSFourCC.RGBG: + case DDSFourCC.GRGB: + case DDSFourCC.UYVY: + case DDSFourCC.YUY2: + case DDSFourCC.CxV8U8: + error = $"format '{fourCC}' is not supported, use DXT1 for RGB textures or DXT5 for RGBA textures"; + return GraphicsFormat.None; + default: + error = $"unknown dwFourCC format '0x{(uint)fourCC:X}'"; + return GraphicsFormat.None; } + } - private TextureInfo LoadJPG() - { - bool isNormal = file.name.EndsWith("NRM"); + // In-place channel-swizzle for RGBA32 normal maps. Operates on the texture's + // entire raw byte buffer, which means every populated mip level when the texture + // was created with a mip chain. + // + // Channel swizzle is per-pixel and the box-filter mip generator is linear, so + // swizzling pre-built mips matches what stock KSP produced by swizzling level-0 + // and regenerating from there (BitmapToCompressedNormalMapFast). + private static unsafe void SwizzleNormalMap(NativeArray data) + { + using var scope = s_pmSwizzleNormalMap.Auto(); - if (isNormal) - { - Texture2D tex = new Texture2D(1, 1, TextureFormat.RGB24, false); - if (!ImageConversion.LoadImage(tex, buffer, false)) - return null; + byte* p = (byte*)NativeArrayUnsafeUtility.GetUnsafePtr(data); + int len = data.Length; + // (r, g, b, a) -> (g, g, g, r) + for (int i = 0; i < len; i += 4) + { + byte r = p[i]; + byte g = p[i + 1]; + p[i] = g; + p[i + 1] = g; + p[i + 2] = g; + p[i + 3] = r; + } + } - Texture2D nrmTex = BitmapToCompressedNormalMapFast(tex); - return new TextureInfo(file, nrmTex, true, false, true); - } - else - { - Texture2D tex = new Texture2D(1, 1, TextureFormat.DXT1, false); - if (!ImageConversion.LoadImage(tex, buffer, false)) - return null; + // Legacy src->dst swizzle, kept for the rare TGA RGB24 path (where source and + // destination have different pixel sizes so an in-place transform is impossible). + // Walks src.Length end-to-end; the caller must size dst with a mip chain that + // matches src's so the constant 3:4 (or 4:4) byte-count ratio fills dst exactly. + private static unsafe void SwizzleNormalMap(NativeArray src, NativeArray dst, TextureFormat srcFormat) + { + using var scope = s_pmSwizzleNormalMap.Auto(); - return new TextureInfo(file, tex, false, true, true); - } - } + byte* s = (byte*)NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(src); + byte* d = (byte*)NativeArrayUnsafeUtility.GetUnsafePtr(dst); + int srcLen = src.Length; - private TextureInfo LoadMBM() + switch (srcFormat) { - memoryStream = new MemoryStream(buffer, 0, dataLength); - binaryReader = new BinaryReader(memoryStream); - Texture2D texture2D = MBMReader.ReadTexture2D(buffer, binaryReader, true, true, out bool isNormalMap); - return new TextureInfo(file, texture2D, isNormalMap, false, true); + case TextureFormat.RGBA32: + // (r, g, b, a) -> (g, g, g, r) + for (int i = 0; i < srcLen; i += 4) + { + byte r = s[i]; + byte g = s[i + 1]; + d[i] = g; d[i + 1] = g; d[i + 2] = g; d[i + 3] = r; + } + break; + case TextureFormat.ARGB32: + // (a, r, g, b) -> (g, g, g, r) + for (int i = 0; i < srcLen; i += 4) + { + byte r = s[i + 1]; + byte g = s[i + 2]; + d[i] = g; d[i + 1] = g; d[i + 2] = g; d[i + 3] = r; + } + break; + case TextureFormat.RGB24: + // (r, g, b) -> (g, g, g, r); 3-byte in, 4-byte out + { + int j = 0; + for (int i = 0; i < srcLen; i += 3) + { + byte r = s[i]; + byte g = s[i + 1]; + d[j] = g; d[j + 1] = g; d[j + 2] = g; d[j + 3] = r; + j += 4; + } + } + break; + default: + throw new InvalidOperationException($"SwizzleNormalMap: unsupported source format {srcFormat}"); } + } - private static string mipMapsPNGTexturePath = Path.DirectorySeparatorChar + "Flags" + Path.DirectorySeparatorChar; + // Returns the most informative exception from a faulted Task + private static Exception UnwrapFaultedTask(Task task, string fallbackMessage) + { + AggregateException ae = task.Exception; + if (ae != null && ae.InnerException != null) + return ae.InnerException; + if (ae != null) + return ae; + return new IOException(fallbackMessage); + } - private TextureInfo LoadPNG() + // Iterator methods can't contain unsafe blocks in C# 8, so the AsyncReadManager + // pointer setup goes through this static helper. + private static unsafe ReadHandle BeginAsyncRead(string path, NativeArray dst, long offset, long size) + { + ReadCommand cmd = new ReadCommand { - if (!GetPNGSize(buffer, out uint width, out uint height)) - { - SetError("Invalid PNG file"); - return null; - } + Buffer = NativeArrayUnsafeUtility.GetUnsafePtr(dst), + Offset = offset, + Size = size, + }; + return AsyncReadManager.Read(path, &cmd, 1); + } - bool isNormalMap = file.name.EndsWith("NRM"); - bool nonReadable = file.fullPath.Contains("@thumbs"); // KSPCF optimization : don't keep cargo icons in memory - bool hasMipMaps = file.fullPath.Contains(mipMapsPNGTexturePath); // only generate mipmaps for flags (stock behavior) - bool canCompress = hasMipMaps ? Numerics.IsPowerOfTwo(width) && Numerics.IsPowerOfTwo(height) : width % 4 == 0 && height % 4 == 0; + // Mirrors UnityEngine.Experimental.Rendering.TextureCreationFlags but with the + // additional flag values that exist in Unity's native code but aren't exposed in + // the public managed enum. DontInitializePixels skips the zero-fill that the + // normal Texture2D constructor performs — pointless work when we're about to + // overwrite the bytes via LoadRawTextureData / AsyncReadManager / LoadImage. + // Borrowed from KSPTextureLoader (../AsyncTextureLoad/src/KSPTextureLoader/TextureUtils.cs). + [Flags] + private enum InternalTextureCreationFlags + { + None = 0, + MipChain = 1 << 0, + DontInitializePixels = 1 << 2, + DontDestroyTexture = 1 << 3, + DontCreateSharedTextureData = 1 << 4, + APIShareable = 1 << 5, + Crunch = 1 << 6, + } - // don't initially compress normal textures, as we need to swizzle the raw data first - TextureFormat textureFormat; - if (isNormalMap) - { - textureFormat = TextureFormat.ARGB32; - } - else if (!canCompress) - { - textureFormat = TextureFormat.ARGB32; - SetWarning("Texture isn't eligible for DXT compression, width and height must be multiples of 4"); - } - else - { - textureFormat = TextureFormat.DXT5; - } + // Allocates a Texture2D without zeroing its pixel buffer. Equivalent to the + // standard Texture2D constructor except for the DontInitializePixels flag, + // which the public managed API doesn't expose for the TextureFormat overload. + private static Texture2D CreateUninitializedTexture2D( + int width, + int height, + TextureFormat format = TextureFormat.RGBA32, + bool mipChain = false, + bool linear = false, + InternalTextureCreationFlags flags = InternalTextureCreationFlags.None) + { + if (GraphicsFormatUtility.IsCrunchFormat(format)) + flags |= InternalTextureCreationFlags.Crunch; + int mipCount = !mipChain ? 1 : -1; + return CreateUninitializedTexture2D( + width, height, mipCount, + GraphicsFormatUtility.GetGraphicsFormat(format, isSRGB: !linear), + flags); + } - Texture2D texture = new Texture2D((int)width, (int)height, textureFormat, hasMipMaps); + private static Texture2D CreateUninitializedTexture2D( + int width, + int height, + int mipCount, + GraphicsFormat format, + InternalTextureCreationFlags flags = InternalTextureCreationFlags.None) + { + Texture2D tex = (Texture2D)FormatterServices.GetUninitializedObject(typeof(Texture2D)); + if (!tex.ValidateFormat(GraphicsFormatUtility.GetTextureFormat(format))) + return tex; - if ((isNormalMap || canCompress) && textureCacheEnabled) - { - if (!ImageConversion.LoadImage(texture, buffer, false)) - return null; + flags |= InternalTextureCreationFlags.DontInitializePixels; + if (mipCount != 1) + flags |= InternalTextureCreationFlags.MipChain; - if (isNormalMap) - texture = BitmapToCompressedNormalMapFast(texture, false); + Texture2D.Internal_Create( + tex, width, height, mipCount, format, + (TextureCreationFlags)flags, IntPtr.Zero); - if (texture.graphicsFormat == GraphicsFormat.RGBA_DXT5_UNorm) - { - SaveCachedTexture(file, texture, isNormalMap); + return tex; + } - if (isNormalMap || nonReadable) - texture.Apply(true, true); - } - } - else + // Wraps an inner format-specific coroutine with exception capture. + // C# does not allow yield inside a try/catch, so we manually drive MoveNext() and + // do the catch around just the MoveNext call. The driver detects completion via + // req.Status, so no other signaling is required here. + private static IEnumerator LoadTextureWrapperCoroutine(TextureLoadRequest req, IEnumerator inner) + { + while (true) + { + object current; + try { - if (!ImageConversion.LoadImage(texture, buffer, nonReadable)) - return null; + if (!inner.MoveNext()) + break; - if (isNormalMap) - texture = BitmapToCompressedNormalMapFast(texture); + current = inner.Current; + } + catch (Exception e) + { + req.Exception = e; + req.ErrorMessage = $"{e.GetType().Name}: {e.Message}"; + req.Status = TextureLoadRequest.State.Failed; + yield break; } - - return new TextureInfo(file, texture, isNormalMap, !nonReadable, true); + yield return current; } - private TextureInfo LoadPNGCached() - { - if (cachedTextureInfo.TryCreateTexture(buffer, out Texture2D texture)) - return new TextureInfo(file, texture, cachedTextureInfo.normal, cachedTextureInfo.readable, true); + if (req.Status != TextureLoadRequest.State.Pending) + yield break; - buffer = System.IO.File.ReadAllBytes(file.fullPath); - return LoadPNG(); + if (req.Result != null) + { + req.Status = TextureLoadRequest.State.Ready; } - - private TextureInfo LoadTGA() + else { - if (dataLength < 18) - { - SetError("TGA invalid length of only " + dataLength + "bytes"); - return null; - } - - TGAImage tgaImage = new TGAImage(); - TGAImage.header = new TGAHeader(buffer); - TGAImage.colorData = tgaImage.ReadImage(TGAImage.header, buffer); - if (TGAImage.colorData == null) - return null; + req.ErrorMessage ??= "Loader produced no result"; + req.Status = TextureLoadRequest.State.Failed; + } + } - Texture2D texture = tgaImage.CreateTexture(mipmap: true, linear: false, compress: true, compressHighQuality: false, allowRead: true); - if (texture.IsNullOrDestroyed()) - return null; + private static IEnumerator LoadDDSCoroutine(TextureLoadRequest req) + { + string path = req.File.fullPath; + Task hdrTask = Task.Run(() => + { + using var scope = s_pmParseDDSHeader.Auto(); + return ParseDDSHeader(path); + }); + while (!hdrTask.IsCompleted) + yield return null; + if (hdrTask.IsFaulted) + throw UnwrapFaultedTask(hdrTask, "DDS header parse failed"); + DDSPreparedHeader hdr = hdrTask.Result; + req.FileLength = hdr.FileLength; - bool isNormalMap = file.name.EndsWith("NRM"); - if (isNormalMap) - texture = BitmapToCompressedNormalMapFast(texture); + if (!SupportedFormatCache.IsSupported(hdr.Format)) + { + req.ErrorMessage = $"DDS: format '{hdr.Format}' is not supported by your GPU"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } - return new TextureInfo(file, texture, isNormalMap, !isNormalMap, true); + Texture2D tex = CreateUninitializedTexture2D( + hdr.Width, hdr.Height, + hdr.MipChain ? -1 : 1, + hdr.Format); + if (tex.IsNullOrDestroyed()) + { + req.ErrorMessage = "DDS: Texture2D allocation failed"; + req.Status = TextureLoadRequest.State.Failed; + yield break; } - private TextureInfo LoadTRUECOLOR() + // Wait until the texture is finished uploading so unity doesn't + // copy its internal buffer when we call GetRawTextureData + yield return WaitForGraphicsThread(); + + NativeArray dst; + using (s_pmGetRawDataDDS.Auto()) + dst = tex.GetRawTextureData(); + long expectedSize = dst.Length; + if (hdr.FileLength - hdr.DataOffset < expectedSize) { - bool isNormalMap = file.name.EndsWith("NRM"); + UnityEngine.Object.Destroy(tex); + req.ErrorMessage = $"DDS: file is too small for declared format (need {expectedSize} bytes after offset {hdr.DataOffset}, have {hdr.FileLength - hdr.DataOffset})"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } - Texture2D texture = new Texture2D(1, 1, TextureFormat.ARGB32, false); - if (!ImageConversion.LoadImage(texture, buffer, false)) - return null; + ReadHandle handle = BeginAsyncRead(path, dst, hdr.DataOffset, expectedSize); - if (isNormalMap) - texture = BitmapToCompressedNormalMapFast(texture); + while (handle.Status == ReadStatus.InProgress) + yield return null; - return new TextureInfo(file, texture, isNormalMap, !isNormalMap, false); - } + ReadStatus status = handle.Status; + handle.Dispose(); - private GameObject LoadMU() + if (status != ReadStatus.Complete) { - return MuParser.Parse(file.parent.url, buffer, dataLength); + UnityEngine.Object.Destroy(tex); + req.ErrorMessage = $"DDS: AsyncReadManager.Read failed (status={status})"; + req.Status = TextureLoadRequest.State.Failed; + yield break; } - private GameObject LoadDAE() + tex.Apply(updateMipmaps: false, makeNoLongerReadable: true); + req.Result = new TextureInfo(req.File, tex, hdr.IsNormalMap, false, true); + req.Status = TextureLoadRequest.State.Ready; + } + + private static IEnumerator LoadUWRCoroutine(TextureLoadRequest req) + { + string filePath = req.File.fullPath; + req.FileLength = new FileInfo(filePath).Length; + string url = "file:///" + filePath.Replace('\\', '/'); + + UnityWebRequest uwr = UnityWebRequestTexture.GetTexture(url, nonReadable: false); + try { - // given that this is a quite obsolete thing and that it's mess to reimplement, just call the stock - // stuff and re-load the file + yield return uwr.SendWebRequest(); - GameObject gameObject = new DatabaseLoaderModel_DAE.DAE().Load(file, new FileInfo(file.fullPath)); - if (gameObject.IsNotNullOrDestroyed()) + if (uwr.isNetworkError || uwr.isHttpError) { - MeshFilter[] componentsInChildren = gameObject.GetComponentsInChildren(); - foreach (MeshFilter meshFilter in componentsInChildren) + req.ErrorMessage = $"UWR: {uwr.error}"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + Texture2D src = DownloadHandlerTexture.GetContent(uwr); + if (src.IsNullOrDestroyed()) + { + req.ErrorMessage = "UWR: GetContent returned null"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + // Wait until the texture is finished uploading so unity doesn't + // copy its internal buffer when we operate on it. + yield return WaitForGraphicsThread(); + + bool isNormalMap = req.File.name.EndsWith("NRM"); + bool canCompress = src.width % 4 == 0 && src.height % 4 == 0; + + // UWR returns a Texture2D with a mipchain already populated, so for normal + // maps we swizzle every level of its CPU buffer in place — no dst alloc, + // no copy, no Apply(true). + if (isNormalMap) + { + src.wrapMode = TextureWrapMode.Repeat; + + NativeArray allLevels; + using (s_pmGetRawDataUWR.Auto()) + allLevels = src.GetRawTextureData(); + Task swizzleTask = Task.Run(() => { - if (meshFilter.gameObject.name == "node_collider") - { - meshFilter.gameObject.AddComponent().sharedMesh = meshFilter.mesh; - MeshRenderer component = meshFilter.gameObject.GetComponent(); - UnityEngine.Object.Destroy(meshFilter); - UnityEngine.Object.Destroy(component); - } + using (s_pmSwizzleNormalMap.Auto()) + SwizzleNormalMap(allLevels); + }); + while (!swizzleTask.IsCompleted) + yield return null; + if (swizzleTask.IsFaulted) + { + UnityEngine.Object.Destroy(src); + throw UnwrapFaultedTask(swizzleTask, "swizzle task faulted"); } } - return gameObject; + if (canCompress) + { + using (s_pmCompress.Auto()) + src.Compress(highQuality: !isNormalMap); + } + else if (!isNormalMap) + Debug.LogWarning($"Texture '{req.File.url}' isn't eligible for DXT compression, width and height must be multiples of 4"); + + src.Apply(updateMipmaps: false, makeNoLongerReadable: true); + + bool isCompressed = + src.graphicsFormat == GraphicsFormat.RGBA_DXT5_UNorm + || src.graphicsFormat == GraphicsFormat.RGBA_DXT5_SRGB + || src.graphicsFormat == GraphicsFormat.RGBA_DXT1_UNorm + || src.graphicsFormat == GraphicsFormat.RGBA_DXT1_SRGB; + req.Result = new TextureInfo(req.File, src, isNormalMap, isReadable: false, isCompressed: isCompressed); + req.Status = TextureLoadRequest.State.Ready; + } + finally + { + uwr.Dispose(); + } + } + + private static IEnumerator LoadTRUECOLORCoroutine(TextureLoadRequest req) + { + string path = req.File.fullPath; + Task sizeTask = Task.Run(() => + { + using (s_pmFileSize.Auto()) + return new FileInfo(path).Length; + }); + while (!sizeTask.IsCompleted) + yield return null; + if (sizeTask.IsFaulted) + throw UnwrapFaultedTask(sizeTask, "file size read failed"); + + long len = sizeTask.Result; + req.FileLength = len; + if (len <= 0 || len > int.MaxValue) + { + req.ErrorMessage = $"TRUECOLOR: invalid file length {len}"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + NativeArray data = new NativeArray((int)len, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + ReadHandle handle = BeginAsyncRead(path, data, 0, len); + while (handle.Status == ReadStatus.InProgress) + yield return null; + ReadStatus rs = handle.Status; + handle.Dispose(); + + if (rs != ReadStatus.Complete) + { + data.Dispose(); + req.ErrorMessage = $"TRUECOLOR: AsyncReadManager.Read failed (status={rs})"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + byte[] managed = data.ToArray(); + data.Dispose(); + + // Create as RGBA32 with mipchain when this is a normal map: LoadImage will + // populate every mip level for us, so we can swizzle the whole thing in place. + // Non-normals keep the existing single-mip readable behavior. + bool isNormalMap = req.File.name.EndsWith("NRM"); + Texture2D tex = CreateUninitializedTexture2D(2, 2, TextureFormat.RGBA32, mipChain: isNormalMap); + if (!tex.LoadImage(managed, markNonReadable: false)) + { + UnityEngine.Object.Destroy(tex); + req.ErrorMessage = "TRUECOLOR: ImageConversion.LoadImage failed"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + if (isNormalMap) + { + bool isPot = Numerics.IsPowerOfTwo(tex.width) && Numerics.IsPowerOfTwo(tex.height); + tex.wrapMode = TextureWrapMode.Repeat; + + // Wait until the texture is finished uploading so unity doesn't + // copy its internal buffer when we call GetRawTextureData + yield return WaitForGraphicsThread(); + + NativeArray allLevels; + using (s_pmGetRawDataTRUECOLOR.Auto()) + allLevels = tex.GetRawTextureData(); + Task swizzleTask = Task.Run(() => + { + using (s_pmSwizzleNormalMap.Auto()) + SwizzleNormalMap(allLevels); + }); + while (!swizzleTask.IsCompleted) + yield return null; + if (swizzleTask.IsFaulted) + { + UnityEngine.Object.Destroy(tex); + throw UnwrapFaultedTask(swizzleTask, "swizzle task faulted"); + } + + if (isPot) + { + using (s_pmCompress.Auto()) + tex.Compress(highQuality: false); + } + tex.Apply(updateMipmaps: false, makeNoLongerReadable: true); + + req.Result = new TextureInfo(req.File, tex, true, isReadable: false, isCompressed: isPot); + } + else + { + tex.Apply(updateMipmaps: false, makeNoLongerReadable: false); + req.Result = new TextureInfo(req.File, tex, false, isReadable: true, isCompressed: false); + } + req.Status = TextureLoadRequest.State.Ready; + } + + private static IEnumerator LoadMBMCoroutine(TextureLoadRequest req) + { + string path = req.File.fullPath; + Task readTask = Task.Run(() => + { + using (s_pmReadAllBytes.Auto()) + return File.ReadAllBytes(path); + }); + while (!readTask.IsCompleted) + yield return null; + if (readTask.IsFaulted) + throw UnwrapFaultedTask(readTask, "MBM file read failed"); + + byte[] buffer = readTask.Result; + req.FileLength = buffer.Length; + + Texture2D texture; + bool isNormalMap; + using (MemoryStream ms = new MemoryStream(buffer, 0, buffer.Length)) + using (BinaryReader br = new BinaryReader(ms)) + { + texture = MBMReader.ReadTexture2D(buffer, br, true, true, out isNormalMap); + } + if (texture.IsNullOrDestroyed()) + { + req.ErrorMessage = "MBM: ReadTexture2D failed"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + req.Result = new TextureInfo(req.File, texture, isNormalMap, isReadable: false, isCompressed: true); + req.Status = TextureLoadRequest.State.Ready; + } + + private static IEnumerator LoadTGACoroutine(TextureLoadRequest req) + { + string path = req.File.fullPath; + Task readTask = Task.Run(() => + { + using (s_pmReadAllBytes.Auto()) + return File.ReadAllBytes(path); + }); + while (!readTask.IsCompleted) + yield return null; + if (readTask.IsFaulted) + throw UnwrapFaultedTask(readTask, "TGA file read failed"); + + byte[] buffer = readTask.Result; + req.FileLength = buffer.Length; + if (buffer.Length < 18) + { + req.ErrorMessage = $"TGA invalid length of only {buffer.Length} bytes"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + TGAImage tgaImage = new TGAImage(); + TGAImage.header = new TGAHeader(buffer); + TGAImage.colorData = tgaImage.ReadImage(TGAImage.header, buffer); + if (TGAImage.colorData == null) + { + req.ErrorMessage = "TGA: ReadImage failed"; + req.Status = TextureLoadRequest.State.Failed; + yield break; } - private static Texture2D BitmapToCompressedNormalMapFast(Texture2D original, bool makeNoLongerReadable = true) + Texture2D texture = tgaImage.CreateTexture(mipmap: true, linear: false, compress: true, compressHighQuality: true, allowRead: true); + if (texture.IsNullOrDestroyed()) { - // ~6 times faster than the stock BitmapToUnityNormalMap() method - // Note that this would be a lot more efficient if we didn't have to create a new texture. - // Unfortunately, Unity doesn't provide any way to add mimaps to a texture that didn't - // have them initially (but I guess this is a deeper GPU related limitation)... + req.ErrorMessage = "TGA: CreateTexture failed"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } - TextureFormat originalFormat = original.format; - Texture2D normalMap = new Texture2D(original.width, original.height, TextureFormat.RGBA32, true); - normalMap.wrapMode = TextureWrapMode.Repeat; + bool isNormalMap = req.File.name.EndsWith("NRM"); + if (isNormalMap) + { + bool isPot = Numerics.IsPowerOfTwo(texture.width) && Numerics.IsPowerOfTwo(texture.height); - if (originalFormat == TextureFormat.RGBA32 - || originalFormat == TextureFormat.ARGB32 - || originalFormat == TextureFormat.RGB24) + if (texture.format == TextureFormat.RGBA32) { - NativeArray originalData = original.GetRawTextureData(); - NativeArray normalMapData = normalMap.GetRawTextureData(); - int size = originalData.Length; - byte r, g; - switch (originalFormat) + // tgaImage.CreateTexture(mipmap: true, ...) already calls Apply(true) + // and the texture is readable, so the CPU buffer holds every populated + // mip level. Swizzle the whole thing in place. + texture.wrapMode = TextureWrapMode.Repeat; + + // Wait until the texture is finished uploading so unity doesn't + // copy its internal buffer when we call GetRawTextureData + yield return WaitForGraphicsThread(); + + NativeArray allLevels; + using (s_pmGetRawDataTGA.Auto()) + allLevels = texture.GetRawTextureData(); + Task swizzleTask = Task.Run(() => { - case TextureFormat.RGBA32: - // from (r, g, b, a) - // to (g, g, g, r); - for (int i = 0; i < size; i += 4) - { - r = originalData[i]; - g = originalData[i + 1]; - normalMapData[i] = g; - normalMapData[i + 1] = g; - normalMapData[i + 2] = g; - normalMapData[i + 3] = r; - } - break; - case TextureFormat.ARGB32: - // from (a, r, g, b) - // to (g, g, g, r); - for (int i = 0; i < size; i += 4) - { - r = originalData[i + 1]; - g = originalData[i + 2]; - normalMapData[i] = g; - normalMapData[i + 1] = g; - normalMapData[i + 2] = g; - normalMapData[i + 3] = r; - } - break; - case TextureFormat.RGB24: - // from (r, g, b) - // to (g, g, g, r); - int j = 0; - for (int i = 0; i < size; i += 3) - { - r = originalData[i]; - g = originalData[i + 1]; - normalMapData[j] = g; - normalMapData[j + 1] = g; - normalMapData[j + 2] = g; - normalMapData[j + 3] = r; - j += 4; - } - break; + using (s_pmSwizzleNormalMap.Auto()) + SwizzleNormalMap(allLevels); + }); + while (!swizzleTask.IsCompleted) + yield return null; + if (swizzleTask.IsFaulted) + { + UnityEngine.Object.Destroy(texture); + throw UnwrapFaultedTask(swizzleTask, "swizzle task faulted"); } + + if (isPot) + { + using (s_pmCompress.Auto()) + texture.Compress(highQuality: false); + } + texture.Apply(updateMipmaps: false, makeNoLongerReadable: true); } else { - Color32[] pixels = original.GetPixels32(); - for (int i = 0; i < pixels.Length; i++) + // RGB24 (24bpp TGA): pixel size differs from RGBA32, so we can't + // swizzle in place. Fall back to the legacy src->dst expansion + // path. dst is allocated with a full mip chain so its byte layout + // matches the mipmapped src (CreateTexture(mipmap: true) populates + // every level), letting the swizzle fill dst end-to-end. + Texture2D dst = CreateUninitializedTexture2D(texture.width, texture.height, TextureFormat.RGBA32, mipChain: true); + dst.wrapMode = TextureWrapMode.Repeat; + + yield return null; + + NativeArray srcData; + NativeArray dstData; + using (s_pmGetRawDataTGA.Auto()) + { + srcData = texture.GetRawTextureData(); + dstData = dst.GetRawTextureData(); + } + + TextureFormat srcFormat = texture.format; + Task swizzleTask = Task.Run(() => { - Color32 pixel = pixels[i]; - pixel.a = pixel.r; - pixel.r = pixel.g; - pixel.b = pixel.g; - pixels[i] = pixel; + using (s_pmSwizzleNormalMap.Auto()) + SwizzleNormalMap(srcData, dstData, srcFormat); + }); + while (!swizzleTask.IsCompleted) + yield return null; + if (swizzleTask.IsFaulted) + { + UnityEngine.Object.Destroy(texture); + UnityEngine.Object.Destroy(dst); + throw UnwrapFaultedTask(swizzleTask, "swizzle task faulted"); + } + UnityEngine.Object.Destroy(texture); + texture = dst; + + if (isPot) + { + using (s_pmCompress.Auto()) + texture.Compress(highQuality: false); } - normalMap.SetPixels32(pixels); + texture.Apply(updateMipmaps: false, makeNoLongerReadable: true); } + } + + req.Result = new TextureInfo(req.File, texture, isNormalMap, isReadable: !isNormalMap, isCompressed: true); + req.Status = TextureLoadRequest.State.Ready; + } - // Unity can't convert NPOT textures to DXT5 with mipmaps - if (Numerics.IsPowerOfTwo(normalMap.width) && Numerics.IsPowerOfTwo(normalMap.height)) + private static IEnumerator TextureDriverCoroutine(List requests, HashSet loadedUrls) + { + GameDatabase gdb = GameDatabase.Instance; + Queue active = new Queue(); + int total = requests.Count; + var iter = requests.GetEnumerator(); + int completed = 0; + + while (true) + { + for (int i = 0; i < MaxTextureSpawnsPerFrame; ++i) { - normalMap.Apply(true); // needed to generate mipmaps, must be done before compression - normalMap.Compress(false); - normalMap.Apply(true, makeNoLongerReadable); + if (!iter.MoveNext()) + goto WINDDOWN; + var request = iter.Current; + + gdb.StartCoroutine(LoadTextureCoroutine(request)); + active.Enqueue(request); } - else + + while (active.TryPeek(out var pending)) + { + if (pending.Status == TextureLoadRequest.State.Pending) + break; + + active.Dequeue(); + InsertReadyRequest(pending, loadedUrls); + loadedAssetCount++; + completed++; + } + + gdb.progressFraction = (float)loadedAssetCount / totalAssetCount; + gdb.progressTitle = $"Loading texture asset {completed}/{total}"; + yield return null; + } + + WINDDOWN: + while (active.TryDequeue(out var pending)) + { + while (pending.Status == TextureLoadRequest.State.Pending) + { + gdb.progressFraction = (float)loadedAssetCount / totalAssetCount; + gdb.progressTitle = $"Loading texture asset {completed}/{total}"; + yield return null; + } + + InsertReadyRequest(pending, loadedUrls); + loadedAssetCount++; + completed++; + } + } + + private static IEnumerator LoadTextureCoroutine(TextureLoadRequest req) + { + IEnumerator inner; + switch (req.AssetType) + { + case RawAsset.AssetType.TextureDDS: + inner = LoadDDSCoroutine(req); + break; + case RawAsset.AssetType.TexturePNG: + case RawAsset.AssetType.TextureJPG: + inner = LoadUWRCoroutine(req); + break; + case RawAsset.AssetType.TextureTRUECOLOR: + inner = LoadTRUECOLORCoroutine(req); + break; + case RawAsset.AssetType.TextureMBM: + inner = LoadMBMCoroutine(req); + break; + case RawAsset.AssetType.TextureTGA: + inner = LoadTGACoroutine(req); + break; + default: + req.ErrorMessage = $"Unknown asset type {req.AssetType}"; + req.Status = TextureLoadRequest.State.Failed; + yield break; + } + + while (true) + { + object current; + try + { + if (!inner.MoveNext()) + break; + + current = inner.Current; + } + catch (Exception e) { - normalMap.Apply(true, makeNoLongerReadable); + req.Exception = e; + req.ErrorMessage = $"{e.GetType().Name}: {e.Message}"; + req.Status = TextureLoadRequest.State.Failed; + yield break; } - Destroy(original); - return normalMap; + yield return current; + } + + if (req.Status != TextureLoadRequest.State.Pending) + yield break; + + if (req.Result != null) + { + req.Status = TextureLoadRequest.State.Ready; + } + else + { + req.ErrorMessage ??= "Loader produced no result"; + req.Status = TextureLoadRequest.State.Failed; + } + } + + private static void SpawnTextureCoroutine(TextureLoadRequest req, Queue active, GameDatabase gdb) + { + IEnumerator inner; + switch (req.AssetType) + { + case RawAsset.AssetType.TextureDDS: + inner = LoadDDSCoroutine(req); + break; + case RawAsset.AssetType.TexturePNG: + case RawAsset.AssetType.TextureJPG: + inner = LoadUWRCoroutine(req); + break; + case RawAsset.AssetType.TextureTRUECOLOR: + inner = LoadTRUECOLORCoroutine(req); + break; + case RawAsset.AssetType.TextureMBM: + inner = LoadMBMCoroutine(req); + break; + case RawAsset.AssetType.TextureTGA: + inner = LoadTGACoroutine(req); + break; + default: + inner = null; + break; + } + + if (inner == null) + { + req.ErrorMessage = $"Unknown asset type {req.AssetType}"; + req.Status = TextureLoadRequest.State.Failed; + } + else + { + Debug.Log($"Load Texture: {req.File.url}"); + gdb.StartCoroutine(LoadTextureWrapperCoroutine(req, inner)); + } + active.Enqueue(req); + } + + private static void InsertReadyRequest(TextureLoadRequest req, HashSet loadedUrls) + { + Debug.Log($"Load Texture: {req.File.url}"); + + if (req.Status == TextureLoadRequest.State.Failed) + { + Debug.LogWarning($"LOAD FAILED: {req.File.url}: {req.ErrorMessage}"); + if (req.Result != null && req.Result.texture.IsNotNullOrDestroyed()) + UnityEngine.Object.Destroy(req.Result.texture); + return; + } + + if (!loadedUrls.Add(req.File.url)) + { + Debug.LogWarning($"Duplicate texture asset '{req.File.url}' with extension '{req.File.fileExtension}' won't be loaded"); + if (req.Result != null && req.Result.texture.IsNotNullOrDestroyed()) + UnityEngine.Object.Destroy(req.Result.texture); + return; } + + req.Result.name = req.File.url; + req.Result.texture.name = req.File.url; + GameDatabase.Instance.databaseTexture.Add(req.Result); + texturesByUrl[req.File.url] = req.Result; + KSPCFFastLoaderReport.texturesBytesLoaded += req.FileLength; + KSPCFFastLoaderReport.texturesLoaded++; } #endregion @@ -2313,216 +2769,12 @@ IEnumerable instructions } #endregion - #region PNG texture cache - - private static void SetupTextureCacheThread(List textures) - { - loader.SetupTextureCache(); - - foreach (RawAsset rawAsset in textures) - rawAsset.CheckTextureCache(); - } - - private void SetupTextureCache() - { - textureCacheDataPath = Path.Combine(textureCachePath, "textureData.json"); - textureProgressMarkerPath = Path.Combine(textureCachePath, "progressMarker"); - - textureCacheData = new Dictionary(2000); - textureDataIds = new HashSet(2000); - - if (Directory.Exists(textureCachePath)) - { - if (File.Exists(textureProgressMarkerPath)) - { - // If progress marker is still here, the game somehow crashed during loading on - // the previous run, so we delete the whole cache to avoid orphan cached texture - // files from lying around - Directory.Delete(textureCachePath, true); - Directory.CreateDirectory(textureCachePath); - } - else if (File.Exists(textureCacheDataPath)) - { - string[] textureCacheDataContent = File.ReadAllLines(textureCacheDataPath); - - if (textureCacheDataContent.Length > 0 && textureCacheDataContent[0].StartsWith(textureCacheVersion)) - { - for (int i = 1; i < textureCacheDataContent.Length; i++) - { - string json = textureCacheDataContent[i]; - CachedTextureInfo cachedTextureInfo = JsonUtility.FromJson(json); - textureCacheData.Add(cachedTextureInfo.name, cachedTextureInfo); - textureDataIds.Add(cachedTextureInfo.id); - } - } - else - { - Directory.Delete(textureCachePath, true); - Directory.CreateDirectory(textureCachePath); - } - } - } - else - { - Directory.CreateDirectory(textureCachePath); - } - - File.WriteAllText(textureProgressMarkerPath, string.Empty); - } - - private void WriteTextureCache() - { - if (!userOptInChoiceDone || !textureCacheEnabled) - { - if (Directory.Exists(textureCachePath)) - Directory.Delete(textureCachePath, true); - } - else - { - foreach (CachedTextureInfo cachedTextureInfo in textureCacheData.Values) - { - if (!cachedTextureInfo.loaded) - { - cacheUpdated = true; - File.Delete(cachedTextureInfo.FilePath); - } - } - - if (cacheUpdated) - { - File.Delete(textureCacheDataPath); - - List textureCacheDataContent = new List(textureCacheData.Count + 1); - textureCacheDataContent.Add(textureCacheVersion); - - foreach (CachedTextureInfo cachedTextureInfo in textureCacheData.Values) - if (cachedTextureInfo.loaded) - textureCacheDataContent.Add(JsonUtility.ToJson(cachedTextureInfo)); + #region User opt-in popup (vestigial) - File.WriteAllLines(textureCacheDataPath, textureCacheDataContent); - } - - File.Delete(textureProgressMarkerPath); - } - } - - [Serializable] - private class CachedTextureInfo - { - private static readonly System.Random random = new System.Random(); - - public string name; - public uint id; - public long time; - public long size; - public int width; - public int height; - public int mipCount; - public bool readable; - public bool normal; - [NonSerialized] public bool loaded = false; - - public string FilePath => Path.Combine(loader.textureCachePath, id.ToString()); - - public CachedTextureInfo() { } - - public CachedTextureInfo(UrlFile urlFile, Texture2D texture, bool isNormalMap, long size, long time) - { - name = urlFile.url; - do - { - unchecked - { - id = (uint)random.Next(); - } - } - while (loader.textureDataIds.Contains(id)); - - this.size = size; - this.time = time; - width = texture.width; - height = texture.height; - mipCount = texture.mipmapCount; - normal = isNormalMap; - readable = !isNormalMap && !name.Contains("@thumbs"); - loaded = true; - } - - public void SaveRawTextureData(Texture2D texture) - { - byte[] rawData = texture.GetRawTextureData(); - File.WriteAllBytes(Path.Combine(loader.textureCachePath, id.ToString()), rawData); - } - - public bool TryCreateTexture(byte[] buffer, out Texture2D texture) - { - try - { - texture = new Texture2D(width, height, GraphicsFormat.RGBA_DXT5_UNorm, mipCount, mipCount == 1 ? TextureCreationFlags.None : TextureCreationFlags.MipChain); - texture.LoadRawTextureData(buffer); - texture.Apply(false, !readable); - loaded = true; - return true; - } - catch (Exception e) - { - Debug.LogWarning($"[KSPCF] Failed to load cached PNG texture '{name}'\n{e}"); - texture = null; - return false; - } - } - } - - private static CachedTextureInfo GetCachedTextureInfo(UrlDir.UrlFile file) - { - if (!loader.textureCacheData.TryGetValue(file.url, out CachedTextureInfo cachedTextureInfo)) - return null; - - if (!GetFileStats(file.fullPath, out long size, out long time) || size != cachedTextureInfo.size || time != cachedTextureInfo.time) - { - loader.textureCacheData.Remove(file.url); - loader.textureDataIds.Remove(cachedTextureInfo.id); - File.Delete(cachedTextureInfo.FilePath); - loader.cacheUpdated = true; - return null; - } - - return cachedTextureInfo; - } - - private static bool GetFileStats(string path, out long size, out long time) - { - MonoIO.GetFileStat(path, out MonoIOStat stat, out MonoIOError error); - if (error == MonoIOError.ERROR_FILE_NOT_FOUND || error == MonoIOError.ERROR_PATH_NOT_FOUND || error == MonoIOError.ERROR_NOT_READY) - { - size = 0; - time = 0; - return false; - } - - size = stat.Length; - time = Math.Max(stat.CreationTime, stat.LastWriteTime); - if (size <= 0 || time <= 0) - return false; - - return true; - } - - private static void SaveCachedTexture(UrlDir.UrlFile urlFile, Texture2D texture, bool isNormalMap) - { - if (!GetFileStats(urlFile.fullPath, out long size, out long creationTime)) - { - Debug.LogWarning($"[KSPCF] PNG texture '{urlFile.url}' couldn't be cached : IO error"); - return; - } - - CachedTextureInfo cachedTextureInfo = new CachedTextureInfo(urlFile, texture, isNormalMap, size, creationTime); - cachedTextureInfo.SaveRawTextureData(texture); - loader.textureCacheData.Add(cachedTextureInfo.name, cachedTextureInfo); - loader.textureDataIds.Add(cachedTextureInfo.id); - loader.cacheUpdated = true; - Debug.Log($"[KSPCF] PNG texture '{urlFile.url}' was converted to DXT5 and has been cached for future reloads"); - } + // The popup, opt-in flow, and PNG-cache-size estimator below are intentionally + // kept around — the cache they were originally tied to has been removed, but the + // popup is going to be repurposed for an upcoming feature. Nothing currently + // triggers WaitForUserOptIn; it must be invoked explicitly by the new feature. private static IEnumerator WaitForUserOptIn() { @@ -2604,15 +2856,6 @@ private static void SetOptIn(bool optIn, ref bool? choosed) config.Save(ConfigPath); } - internal static void OnToggleCacheFromSettings(bool cacheEnabled) - { - textureCacheEnabled = cacheEnabled; - ConfigNode config = new ConfigNode(); - config.AddValue(nameof(userOptInChoiceDone), true); - config.AddValue(nameof(textureCacheEnabled), cacheEnabled); - config.Save(ConfigPath); - } - private static readonly string flagsPath = Path.DirectorySeparatorChar + "Flags" + Path.DirectorySeparatorChar; private static bool GetPngCacheSize(string path, out int cacheSize, out bool isNormal) @@ -2797,6 +3040,56 @@ public void Show() } } + // A helper that yields until it has been processed on the render thread. + // Use this to delay until the render thread is no longer using a texture + // (or any other resource). + private unsafe class WaitForGraphicsThreadInst : CustomYieldInstruction + { + static CommandBuffer DispatchCB; + static readonly IntPtr NotifyPtr = (IntPtr)Marshal.GetFunctionPointerForDelegate((Action)Notify); + static readonly int GchandleOffset = UnsafeUtility.GetFieldOffset( + typeof(WaitForGraphicsThreadInst).GetField(nameof(gchandle), BindingFlags.Instance | BindingFlags.NonPublic)); + static readonly int ReadyOffset = UnsafeUtility.GetFieldOffset( + typeof(WaitForGraphicsThreadInst).GetField(nameof(ready), BindingFlags.Instance | BindingFlags.NonPublic)); + + ulong gchandle = 0; + bool ready = false; + + public override bool keepWaiting => !ready; + + public WaitForGraphicsThreadInst() + { + DispatchCB ??= new CommandBuffer() + { + name = "KSPCF.WaitForGraphicsThreadCB" + }; + + void* addr = UnsafeUtility.PinGCObjectAndGetAddress(this, out gchandle); + try + { + DispatchCB.Clear(); + DispatchCB.IssuePluginEventAndData(NotifyPtr, 0, (IntPtr)addr); + Graphics.ExecuteCommandBuffer(DispatchCB); + } + catch + { + UnsafeUtility.ReleaseGCObject(gchandle); + throw; + } + } + + static void Notify(int _, IntPtr data) + { + ulong gchandle = *(ulong*)((byte*)data + GchandleOffset); + bool* ready = (bool*)((byte*)data + ReadyOffset); + + *ready = true; + UnsafeUtility.ReleaseGCObject(gchandle); + } + } + + private static WaitForGraphicsThreadInst WaitForGraphicsThread() => + new WaitForGraphicsThreadInst(); #endregion