diff --git a/Nautilus/Initializer.cs b/Nautilus/Initializer.cs
index e30cf17a..59a8d544 100644
--- a/Nautilus/Initializer.cs
+++ b/Nautilus/Initializer.cs
@@ -78,6 +78,7 @@ static Initializer()
SurvivalPatcher.Patch(_harmony);
CustomSoundPatcher.Patch(_harmony);
MaterialUtils.Patch();
+ MaterialLibrary.Patch();
FontReferencesPatcher.Patch(_harmony);
VehicleUpgradesPatcher.Patch(_harmony);
StoryGoalPatcher.Patch(_harmony);
diff --git a/Nautilus/Nautilus.csproj b/Nautilus/Nautilus.csproj
index 33f66a35..aaea3355 100644
--- a/Nautilus/Nautilus.csproj
+++ b/Nautilus/Nautilus.csproj
@@ -52,5 +52,12 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Nautilus/Resources/MatFilePathMapBZ.resources b/Nautilus/Resources/MatFilePathMapBZ.resources
new file mode 100644
index 00000000..eb868070
Binary files /dev/null and b/Nautilus/Resources/MatFilePathMapBZ.resources differ
diff --git a/Nautilus/Resources/MatFilePathMapSN.resources b/Nautilus/Resources/MatFilePathMapSN.resources
new file mode 100644
index 00000000..03f5bfa0
Binary files /dev/null and b/Nautilus/Resources/MatFilePathMapSN.resources differ
diff --git a/Nautilus/Utility/MaterialLibrary.cs b/Nautilus/Utility/MaterialLibrary.cs
new file mode 100644
index 00000000..7f0a9efc
--- /dev/null
+++ b/Nautilus/Utility/MaterialLibrary.cs
@@ -0,0 +1,352 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Reflection;
+using BepInEx;
+using UnityEngine;
+using UWE;
+using ResourceManager = System.Resources.ResourceManager;
+
+namespace Nautilus.Utility;
+
+///
+/// Allows for quick and simple retrieval of any base-game material throughout both Subnautica, and Subnautica:
+/// Below Zero. Materials can either be fetched directly using their names by accessing the
+/// method, or applied generically across the entirety of a custom prefab via the method.
+///
+public static class MaterialLibrary
+{
+ ///
+ /// Handles loading the material filepath maps from the embedded .resources files, and accessing their contents.
+ ///
+ private static ResourceManager _resourceManager;
+
+ ///
+ /// The maximum number of times the method is allowed to retry loading a material
+ /// from it's designated path. Necessary because .mat files will occasionally fail to load a couple of times before
+ /// being successfully retrieved. This cap exists only as a failsafe, and should never actually be reached.
+ ///
+ private const int MaxFetchAttempts = 1000;
+
+ ///
+ /// The current amount of entries within the MaterialLibrary.
+ ///
+ public static int Size
+ {
+ get
+ {
+ if (_resourceManager == null)
+ return 0;
+
+ var resourceSet = _resourceManager.GetResourceSet(CultureInfo.InvariantCulture, true, true);
+
+ if (resourceSet == null)
+ {
+ InternalLogger.Error("Failed to get the ResourceSet of the material library.");
+ return 0;
+ }
+
+ //Sadly, this is actually the simplest way to get the total number of entries in a .resources file.
+ int materialEntries = 0;
+ foreach (var _ in resourceSet)
+ materialEntries++;
+
+ return materialEntries;
+ }
+ }
+
+ internal static void Patch()
+ {
+ #if SUBNAUTICA
+ string resourcePath = "Nautilus.Resources.MatFilePathMapSN";
+ #elif BELOWZERO
+ string resourcePath = "Nautilus.Resources.MatFilePathMapBZ";
+ #endif
+
+ _resourceManager = new ResourceManager(resourcePath, Assembly.GetExecutingAssembly());
+ }
+
+ ///
+ /// Iterates over every present on the given and any of its
+ /// children, and replaces any custom materials it finds that share the exact same name (case-sensitive) of a
+ /// base-game material with its vanilla counterpart.
+ ///
+ /// The custom object you'd like to apply base-game materials to. Children included.
+ ///
+ public static IEnumerator ReplaceVanillaMats(GameObject customPrefab)
+ {
+ if (customPrefab == null)
+ {
+ InternalLogger.Error("Attempted to apply vanilla materials to a null prefab.");
+ yield break;
+ }
+
+ var loadedVanillaMats = new List();
+ var customMatNames = new List();
+ foreach (var renderer in customPrefab.GetAllComponentsInChildren())
+ {
+ var newMatList = renderer.materials;
+
+ for (int i = 0; i < newMatList.Length; i++)
+ {
+ if (newMatList[i] == null)
+ continue;
+
+ var currentMatName = MaterialUtils.RemoveInstanceFromMatName(newMatList[i].name);
+
+ bool skipMat = customMatNames.Contains(currentMatName);
+ if (!skipMat)
+ {
+ foreach (var material in loadedVanillaMats)
+ {
+ if (MaterialUtils.RemoveInstanceFromMatName(material.name).Equals(currentMatName))
+ {
+ newMatList[i] = material;
+ skipMat = true;
+ break;
+ }
+ }
+ }
+
+ if (skipMat)
+ continue;
+
+ var taskResult = new TaskResult();
+ yield return FetchMaterial(currentMatName, taskResult);
+
+ var foundMaterial = taskResult.value;
+
+ if (foundMaterial == null)
+ {
+ customMatNames.Add(currentMatName);
+ continue;
+ }
+
+ newMatList[i] = foundMaterial;
+ loadedVanillaMats.Add(foundMaterial);
+ }
+
+ renderer.materials = newMatList;
+ }
+ }
+
+ ///
+ /// Searches the library for the provided , and loads it from its path, if an entry for
+ /// it exists. If the material exists within the library, but fails to load, fetching the asset will be reattempted
+ /// until the limit is reached.
+ ///
+ /// The exact name of the material you wish to retrieve as seen in-game. Case-sensitive!
+ /// The to load the found material into. Otherwise, has it's value set to null.
+ ///
+ public static IEnumerator FetchMaterial(string materialName, IOut foundMaterial)
+ {
+ Material matResult = null;
+
+ string filteredMatName = MaterialUtils.RemoveInstanceFromMatName(materialName);
+ string resourcePath = GetPathToMaterial(filteredMatName);
+ if (!resourcePath.IsNullOrWhiteSpace())
+ {
+ int fetchAttempts = 0;
+ do
+ {
+ if (fetchAttempts >= MaxFetchAttempts)
+ {
+ InternalLogger.Error($"Max retries limit reached when trying to fetch material: {materialName}.");
+ InternalLogger.Error("Please ensure the material's path is valid, or up the maximum # of retries.");
+ yield break;
+ }
+
+ fetchAttempts++;
+ InternalLogger.Debug($"Attempting to grab material: {materialName}...");
+
+ var taskResult = new TaskResult();
+
+ if (resourcePath.EndsWith(".mat"))
+ yield return GetMaterialFromPath(resourcePath, taskResult);
+ else if (resourcePath.EndsWith(".prefab"))
+ yield return GetMaterialFromPrefab(filteredMatName, resourcePath, taskResult);
+ else if (resourcePath.StartsWith("LightmappedPrefabs/"))
+ yield return GetMaterialFromScene(filteredMatName, resourcePath.Substring(resourcePath.IndexOf('/') + 1), taskResult);
+ else
+ {
+ InternalLogger.Error($"Invalid path provided for material: {filteredMatName}");
+ break;
+ }
+
+ matResult = taskResult.value;
+ } while (matResult == null);
+ }
+
+ foundMaterial.Set(matResult);
+ }
+
+ ///
+ /// Loads and returns a material using its path relative to the .
+ /// NOTE: The provided .mat file will occasionally fail to load, resulting in 's value
+ /// being null after this method is finished running. It is not currently known what causes this, but it
+ /// does not happen constantly, and will only occur a handful of times in a row, if it does at all. As such,
+ /// the best solution for this problem, for the time being, is simply to try calling this method again, until
+ /// a successful result is retrieved.
+ ///
+ /// The path to the .mat file, relative to the .
+ /// The to load the found material into. Otherwise, has it's value set to null.
+ ///
+ private static IEnumerator GetMaterialFromPath(string matPath, IOut matResult)
+ {
+ matResult.Set(null);
+
+ if (!matPath.EndsWith(".mat"))
+ {
+ InternalLogger.Error($"{matPath} is not a valid path to a material file.");
+ yield break;
+ }
+
+ var handle = AddressablesUtility.LoadAsync(matPath);
+
+ yield return handle.Task;
+
+ matResult.Set(handle.Result);
+ }
+
+ ///
+ /// Finds and returns a material by first loading an associated Prefab, given that prefab's path, and the desired
+ /// material's name. Iterates over every on the Prefab's parent object, and any of its
+ /// children objects, in order to find the material requested.
+ ///
+ /// The name of the material to search for.
+ /// The path to the reference Prefab, for use in the .
+ /// The to load the found material into. Otherwise, has it's value set to null.
+ ///
+ private static IEnumerator GetMaterialFromPrefab(string matName, string prefabPath, IOut matResult)
+ {
+ matResult.Set(null);
+
+ if (!prefabPath.EndsWith(".prefab"))
+ {
+ InternalLogger.Error($"{prefabPath} is not a valid path to a prefab file.");
+ yield break;
+ }
+
+ var task = PrefabDatabase.GetPrefabForFilenameAsync(prefabPath);
+ yield return task;
+
+ if (!task.TryGetPrefab(out var prefab))
+ {
+ InternalLogger.Error($"Failed to get prefab at path {prefabPath} from PrefabDatabase.");
+ yield break;
+ }
+
+ foreach (var renderer in prefab.GetAllComponentsInChildren())
+ {
+ foreach (var material in renderer.materials)
+ {
+ if (material == null)
+ continue;
+
+ if (MaterialUtils.RemoveInstanceFromMatName(material.name).Equals(matName))
+ {
+ matResult.Set(material);
+ yield break;
+ }
+ }
+ }
+
+ InternalLogger.Error($"Failed to find material: {matName} on prefab at path: {prefabPath}");
+ }
+
+ ///
+ /// Finds and returns a material with the specified , by searching through a scene prefab,
+ /// loaded using the given . NOTE: This method won't be able to provide a material result
+ /// until the specified Scene is loaded via the .
+ ///
+ /// The name of the material to search the scene prefab for.
+ /// The name of the additive scene prefab to load and iterate through for the desired material.
+ /// The to load the found material into. Otherwise, has it's value set to null.
+ ///
+ private static IEnumerator GetMaterialFromScene(string matName, string sceneName, IOut matResult)
+ {
+ matResult.Set(null);
+
+ if (!AddressablesUtility.IsAddressableScene(sceneName))
+ {
+ InternalLogger.Error($"Attempted to get a material from invalid scene: {sceneName}");
+ yield break;
+ }
+
+ yield return new WaitUntil(() => LightmappedPrefabs.main);
+
+ bool materialSet = false;
+ bool matCheckFailed = false;
+ LightmappedPrefabs.main.RequestScenePrefab(sceneName, scenePrefab =>
+ {
+ foreach (var renderer in scenePrefab.GetAllComponentsInChildren())
+ {
+ foreach (var material in renderer.materials)
+ {
+ if (material == null)
+ continue;
+
+ if (MaterialUtils.RemoveInstanceFromMatName(material.name).Equals(matName))
+ {
+ matResult.Set(material);
+ materialSet = true;
+ return;
+ }
+ }
+ }
+
+ matCheckFailed = true;
+ });
+
+ yield return new WaitUntil(() => materialSet || matCheckFailed);
+ }
+
+ ///
+ /// Uses the to access the MatFilePathMaps, and retrieve the path associated with
+ /// a specified material using the , so that it may be loaded when requested.
+ ///
+ /// The name of the material whose path is being requested.
+ /// The path to the resource which should be loaded in order to retrieve the specified material.
+ /// Points to either a mat, prefab, or scene prefab file. Returns an empty string if the provided
+ /// does not have an entry within the library.
+ private static string GetPathToMaterial(string materialName)
+ {
+ if (_resourceManager == null)
+ {
+ InternalLogger.Error("Tried to get material path from library while ResourceManager is null. Please initialize first!");
+ return String.Empty;
+ }
+
+ return _resourceManager.GetString(ConvertNameToKey(materialName));
+ }
+
+ ///
+ /// Converts the name of a material to the MatFilePathMap key equivalent. This approach is necessary because files
+ /// with the .resources extension do not allow entries with duplicate text to exist, even if the text entries
+ /// have different capitalization from one another. The MatFilePathMap contains many entries with the same name,
+ /// however, and the only way to differentiate them from one another is by preserving their casing. To get around
+ /// this issue, keys within the MatFilePathMap are made to be lowercased versions of the original mat name, with
+ /// the number of lowercase and uppercase characters in the original name preserved by being appended to the end
+ /// of the key version of the name. I.e. RawTitanium -> rawtitanium_lc9_uc2
+ ///
+ /// The name of the base-game material being searched for.
+ /// The MatFilePathMap key version of the given material name.
+ private static string ConvertNameToKey(string matName)
+ {
+ var characters = matName.ToCharArray();
+
+ int upperCaseLetters = 0;
+ int lowerCaseLetters = 0;
+ for (int i = 0; i < characters.Length; i++)
+ {
+ if (char.IsUpper(characters[i]))
+ upperCaseLetters++;
+ else
+ lowerCaseLetters++;
+ }
+
+ return matName.ToLower() + "_lc" + lowerCaseLetters + "_uc" + upperCaseLetters;
+ }
+
+}
\ No newline at end of file
diff --git a/Nautilus/Utility/MaterialUtils.cs b/Nautilus/Utility/MaterialUtils.cs
index a870eaf1..bf2943b0 100644
--- a/Nautilus/Utility/MaterialUtils.cs
+++ b/Nautilus/Utility/MaterialUtils.cs
@@ -333,6 +333,29 @@ public static void SetMaterialCutout(Material material, bool cutout)
}
}
+ ///
+ /// Removes the (Instance) text from the end of a material's name, recursively. Primarily helpful for programmatic
+ /// material name comparisons.
+ ///
+ /// The material name from which to remove the Instance string.
+ /// The altered material name if any trailing (Instance) strings were found, otherwise the uneffected
+ /// matName.
+ public static string RemoveInstanceFromMatName(string matName)
+ {
+ string returnValue = matName;
+ string instanceString = " (Instance)";
+
+ // We avoid using .Replace() here to account for users possibly including the instance string at the beginning
+ // Or in the middle of their material names. Not likely to happen, but better safe than sorry.
+ if (matName.EndsWith(instanceString))
+ {
+ returnValue = matName.Substring(0, matName.Length - instanceString.Length);
+ return RemoveInstanceFromMatName(returnValue);
+ }
+
+ return returnValue;
+ }
+
private static void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name != "MenuEnvironment") return;