mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-21 16:09:27 +01:00
Merge branch 'pbd-modding'
This commit is contained in:
commit
a31bdb66c8
14 changed files with 254 additions and 41 deletions
|
|
@ -18,13 +18,14 @@ public record ModConflicts(IMod Mod2, List<object> Conflicts, bool HasPriority,
|
||||||
/// The Cache contains all required temporary data to use a collection.
|
/// The Cache contains all required temporary data to use a collection.
|
||||||
/// It will only be setup if a collection gets activated in any way.
|
/// It will only be setup if a collection gets activated in any way.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CollectionCache : IDisposable
|
public sealed class CollectionCache : IDisposable
|
||||||
{
|
{
|
||||||
private readonly CollectionCacheManager _manager;
|
private readonly CollectionCacheManager _manager;
|
||||||
private readonly ModCollection _collection;
|
private readonly ModCollection _collection;
|
||||||
public readonly CollectionModData ModData = new();
|
public readonly CollectionModData ModData = new();
|
||||||
private readonly SortedList<string, (SingleArray<IMod>, object?)> _changedItems = [];
|
private readonly SortedList<string, (SingleArray<IMod>, object?)> _changedItems = [];
|
||||||
public readonly ConcurrentDictionary<Utf8GamePath, ModPath> ResolvedFiles = new();
|
public readonly ConcurrentDictionary<Utf8GamePath, ModPath> ResolvedFiles = new();
|
||||||
|
public readonly CustomResourceCache CustomResources;
|
||||||
public readonly MetaCache Meta;
|
public readonly MetaCache Meta;
|
||||||
public readonly Dictionary<IMod, SingleArray<ModConflicts>> ConflictDict = [];
|
public readonly Dictionary<IMod, SingleArray<ModConflicts>> ConflictDict = [];
|
||||||
|
|
||||||
|
|
@ -37,7 +38,7 @@ public class CollectionCache : IDisposable
|
||||||
=> ConflictDict.Values;
|
=> ConflictDict.Values;
|
||||||
|
|
||||||
public SingleArray<ModConflicts> Conflicts(IMod mod)
|
public SingleArray<ModConflicts> Conflicts(IMod mod)
|
||||||
=> ConflictDict.TryGetValue(mod, out SingleArray<ModConflicts> c) ? c : new SingleArray<ModConflicts>();
|
=> ConflictDict.TryGetValue(mod, out var c) ? c : new SingleArray<ModConflicts>();
|
||||||
|
|
||||||
private int _changedItemsSaveCounter = -1;
|
private int _changedItemsSaveCounter = -1;
|
||||||
|
|
||||||
|
|
@ -57,13 +58,18 @@ public class CollectionCache : IDisposable
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
_collection = collection;
|
_collection = collection;
|
||||||
Meta = new MetaCache(manager.MetaFileManager, _collection);
|
Meta = new MetaCache(manager.MetaFileManager, _collection);
|
||||||
|
CustomResources = new CustomResourceCache(manager.ResourceLoader);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
=> Meta.Dispose();
|
{
|
||||||
|
Meta.Dispose();
|
||||||
|
CustomResources.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
~CollectionCache()
|
~CollectionCache()
|
||||||
=> Meta.Dispose();
|
=> Dispose();
|
||||||
|
|
||||||
// Resolve a given game path according to this collection.
|
// Resolve a given game path according to this collection.
|
||||||
public FullPath? ResolvePath(Utf8GamePath gameResourcePath)
|
public FullPath? ResolvePath(Utf8GamePath gameResourcePath)
|
||||||
|
|
@ -72,7 +78,7 @@ public class CollectionCache : IDisposable
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (candidate.Path.InternalName.Length > Utf8GamePath.MaxGamePathLength
|
if (candidate.Path.InternalName.Length > Utf8GamePath.MaxGamePathLength
|
||||||
|| candidate.Path.IsRooted && !candidate.Path.Exists)
|
|| candidate.Path is { IsRooted: true, Exists: false })
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return candidate.Path;
|
return candidate.Path;
|
||||||
|
|
@ -100,7 +106,7 @@ public class CollectionCache : IDisposable
|
||||||
public HashSet<Utf8GamePath>[] ReverseResolvePaths(IReadOnlyCollection<string> fullPaths)
|
public HashSet<Utf8GamePath>[] ReverseResolvePaths(IReadOnlyCollection<string> fullPaths)
|
||||||
{
|
{
|
||||||
if (fullPaths.Count == 0)
|
if (fullPaths.Count == 0)
|
||||||
return Array.Empty<HashSet<Utf8GamePath>>();
|
return [];
|
||||||
|
|
||||||
var ret = new HashSet<Utf8GamePath>[fullPaths.Count];
|
var ret = new HashSet<Utf8GamePath>[fullPaths.Count];
|
||||||
var dict = new Dictionary<FullPath, int>(fullPaths.Count);
|
var dict = new Dictionary<FullPath, int>(fullPaths.Count);
|
||||||
|
|
@ -108,8 +114,8 @@ public class CollectionCache : IDisposable
|
||||||
{
|
{
|
||||||
dict[new FullPath(path)] = idx;
|
dict[new FullPath(path)] = idx;
|
||||||
ret[idx] = !Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var utf8)
|
ret[idx] = !Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var utf8)
|
||||||
? new HashSet<Utf8GamePath> { utf8 }
|
? [utf8]
|
||||||
: new HashSet<Utf8GamePath>();
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (game, full) in ResolvedFiles)
|
foreach (var (game, full) in ResolvedFiles)
|
||||||
|
|
@ -148,17 +154,20 @@ public class CollectionCache : IDisposable
|
||||||
if (fullPath.FullName.Length > 0)
|
if (fullPath.FullName.Length > 0)
|
||||||
{
|
{
|
||||||
ResolvedFiles.TryAdd(path, new ModPath(Mod.ForcedFiles, fullPath));
|
ResolvedFiles.TryAdd(path, new ModPath(Mod.ForcedFiles, fullPath));
|
||||||
|
CustomResources.Invalidate(path);
|
||||||
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Replaced, path, fullPath, modPath.Path,
|
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Replaced, path, fullPath, modPath.Path,
|
||||||
Mod.ForcedFiles);
|
Mod.ForcedFiles);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
CustomResources.Invalidate(path);
|
||||||
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Removed, path, FullPath.Empty, modPath.Path, null);
|
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Removed, path, FullPath.Empty, modPath.Path, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (fullPath.FullName.Length > 0)
|
else if (fullPath.FullName.Length > 0)
|
||||||
{
|
{
|
||||||
ResolvedFiles.TryAdd(path, new ModPath(Mod.ForcedFiles, fullPath));
|
ResolvedFiles.TryAdd(path, new ModPath(Mod.ForcedFiles, fullPath));
|
||||||
|
CustomResources.Invalidate(path);
|
||||||
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Added, path, fullPath, FullPath.Empty, Mod.ForcedFiles);
|
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Added, path, fullPath, FullPath.Empty, Mod.ForcedFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +190,7 @@ public class CollectionCache : IDisposable
|
||||||
{
|
{
|
||||||
if (ResolvedFiles.Remove(path, out var mp))
|
if (ResolvedFiles.Remove(path, out var mp))
|
||||||
{
|
{
|
||||||
|
CustomResources.Invalidate(path);
|
||||||
if (mp.Mod != mod)
|
if (mp.Mod != mod)
|
||||||
Penumbra.Log.Warning(
|
Penumbra.Log.Warning(
|
||||||
$"Invalid mod state, removing {mod.Name} and associated file {path} returned current mod {mp.Mod.Name}.");
|
$"Invalid mod state, removing {mod.Name} and associated file {path} returned current mod {mp.Mod.Name}.");
|
||||||
|
|
@ -295,6 +305,7 @@ public class CollectionCache : IDisposable
|
||||||
if (ResolvedFiles.TryAdd(path, new ModPath(mod, file)))
|
if (ResolvedFiles.TryAdd(path, new ModPath(mod, file)))
|
||||||
{
|
{
|
||||||
ModData.AddPath(mod, path);
|
ModData.AddPath(mod, path);
|
||||||
|
CustomResources.Invalidate(path);
|
||||||
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Added, path, file, FullPath.Empty, mod);
|
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Added, path, file, FullPath.Empty, mod);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -309,13 +320,14 @@ public class CollectionCache : IDisposable
|
||||||
ModData.RemovePath(modPath.Mod, path);
|
ModData.RemovePath(modPath.Mod, path);
|
||||||
ResolvedFiles[path] = new ModPath(mod, file);
|
ResolvedFiles[path] = new ModPath(mod, file);
|
||||||
ModData.AddPath(mod, path);
|
ModData.AddPath(mod, path);
|
||||||
|
CustomResources.Invalidate(path);
|
||||||
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Replaced, path, file, modPath.Path, mod);
|
InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Replaced, path, file, modPath.Path, mod);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Penumbra.Log.Error(
|
Penumbra.Log.Error(
|
||||||
$"[{Thread.CurrentThread.ManagedThreadId}] Error adding redirection {file} -> {path} for mod {mod.Name} to collection cache {AnonymizedName}:\n{ex}");
|
$"[{Environment.CurrentManagedThreadId}] Error adding redirection {file} -> {path} for mod {mod.Name} to collection cache {AnonymizedName}:\n{ex}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Penumbra.Api;
|
||||||
using Penumbra.Api.Enums;
|
using Penumbra.Api.Enums;
|
||||||
using Penumbra.Collections.Manager;
|
using Penumbra.Collections.Manager;
|
||||||
using Penumbra.Communication;
|
using Penumbra.Communication;
|
||||||
|
using Penumbra.Interop.ResourceLoading;
|
||||||
using Penumbra.Meta;
|
using Penumbra.Meta;
|
||||||
using Penumbra.Mods;
|
using Penumbra.Mods;
|
||||||
using Penumbra.Mods.Manager;
|
using Penumbra.Mods.Manager;
|
||||||
|
|
@ -21,8 +22,8 @@ public class CollectionCacheManager : IDisposable
|
||||||
private readonly CollectionStorage _storage;
|
private readonly CollectionStorage _storage;
|
||||||
private readonly ActiveCollections _active;
|
private readonly ActiveCollections _active;
|
||||||
internal readonly ResolvedFileChanged ResolvedFileChanged;
|
internal readonly ResolvedFileChanged ResolvedFileChanged;
|
||||||
|
|
||||||
internal readonly MetaFileManager MetaFileManager;
|
internal readonly MetaFileManager MetaFileManager;
|
||||||
|
internal readonly ResourceLoader ResourceLoader;
|
||||||
|
|
||||||
private readonly ConcurrentQueue<CollectionCache.ChangeData> _changeQueue = new();
|
private readonly ConcurrentQueue<CollectionCache.ChangeData> _changeQueue = new();
|
||||||
|
|
||||||
|
|
@ -35,7 +36,7 @@ public class CollectionCacheManager : IDisposable
|
||||||
=> _storage.Where(c => c.HasCache);
|
=> _storage.Where(c => c.HasCache);
|
||||||
|
|
||||||
public CollectionCacheManager(FrameworkManager framework, CommunicatorService communicator, TempModManager tempMods, ModStorage modStorage,
|
public CollectionCacheManager(FrameworkManager framework, CommunicatorService communicator, TempModManager tempMods, ModStorage modStorage,
|
||||||
MetaFileManager metaFileManager, ActiveCollections active, CollectionStorage storage)
|
MetaFileManager metaFileManager, ActiveCollections active, CollectionStorage storage, ResourceLoader resourceLoader)
|
||||||
{
|
{
|
||||||
_framework = framework;
|
_framework = framework;
|
||||||
_communicator = communicator;
|
_communicator = communicator;
|
||||||
|
|
@ -44,6 +45,7 @@ public class CollectionCacheManager : IDisposable
|
||||||
MetaFileManager = metaFileManager;
|
MetaFileManager = metaFileManager;
|
||||||
_active = active;
|
_active = active;
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
|
ResourceLoader = resourceLoader;
|
||||||
ResolvedFileChanged = _communicator.ResolvedFileChanged;
|
ResolvedFileChanged = _communicator.ResolvedFileChanged;
|
||||||
|
|
||||||
if (!_active.Individuals.IsLoaded)
|
if (!_active.Individuals.IsLoaded)
|
||||||
|
|
|
||||||
49
Penumbra/Collections/Cache/CustomResourceCache.cs
Normal file
49
Penumbra/Collections/Cache/CustomResourceCache.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
||||||
|
using Penumbra.Api.Enums;
|
||||||
|
using Penumbra.Interop.ResourceLoading;
|
||||||
|
using Penumbra.Interop.SafeHandles;
|
||||||
|
using Penumbra.String.Classes;
|
||||||
|
|
||||||
|
namespace Penumbra.Collections.Cache;
|
||||||
|
|
||||||
|
/// <summary> A cache for resources owned by a collection. </summary>
|
||||||
|
public sealed class CustomResourceCache(ResourceLoader loader)
|
||||||
|
: ConcurrentDictionary<Utf8GamePath, SafeResourceHandle>, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary> Invalidate an existing resource by clearing it from the cache and disposing it. </summary>
|
||||||
|
public void Invalidate(Utf8GamePath path)
|
||||||
|
{
|
||||||
|
if (TryRemove(path, out var handle))
|
||||||
|
handle.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var handle in Values)
|
||||||
|
handle.Dispose();
|
||||||
|
Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Get the requested resource either from the cached resource, or load a new one if it does not exist. </summary>
|
||||||
|
public SafeResourceHandle Get(ResourceCategory category, ResourceType type, Utf8GamePath path, ResolveData data)
|
||||||
|
{
|
||||||
|
if (TryGetClonedValue(path, out var handle))
|
||||||
|
return handle;
|
||||||
|
|
||||||
|
handle = loader.LoadResolvedSafeResource(category, type, path.Path, data);
|
||||||
|
var clone = handle.Clone();
|
||||||
|
if (!TryAdd(path, clone))
|
||||||
|
clone.Dispose();
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Get a cloned cached resource if it exists. </summary>
|
||||||
|
private bool TryGetClonedValue(Utf8GamePath path, [NotNullWhen(true)] out SafeResourceHandle? handle)
|
||||||
|
{
|
||||||
|
if (!TryGetValue(path, out handle))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
handle = handle.Clone();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
||||||
using Penumbra.Api.Enums;
|
using Penumbra.Api.Enums;
|
||||||
using Penumbra.Collections;
|
using Penumbra.Collections;
|
||||||
|
using Penumbra.Interop.SafeHandles;
|
||||||
using Penumbra.Interop.Structs;
|
using Penumbra.Interop.Structs;
|
||||||
using Penumbra.String;
|
using Penumbra.String;
|
||||||
using Penumbra.String.Classes;
|
using Penumbra.String.Classes;
|
||||||
|
|
@ -39,6 +40,15 @@ public unsafe class ResourceLoader : IDisposable
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary> Load a resource for a given path and a specific collection. </summary>
|
||||||
|
public SafeResourceHandle LoadResolvedSafeResource(ResourceCategory category, ResourceType type, ByteString path, ResolveData resolveData)
|
||||||
|
{
|
||||||
|
_resolvedData = resolveData;
|
||||||
|
var ret = _resources.GetSafeResource(category, type, path);
|
||||||
|
_resolvedData = ResolveData.Invalid;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary> The function to use to resolve a given path. </summary>
|
/// <summary> The function to use to resolve a given path. </summary>
|
||||||
public Func<Utf8GamePath, ResourceCategory, ResourceType, (FullPath?, ResolveData)> ResolvePath = null!;
|
public Func<Utf8GamePath, ResourceCategory, ResourceType, (FullPath?, ResolveData)> ResolvePath = null!;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ using Dalamud.Utility.Signatures;
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
||||||
using Penumbra.Api.Enums;
|
using Penumbra.Api.Enums;
|
||||||
using Penumbra.GameData;
|
using Penumbra.GameData;
|
||||||
|
using Penumbra.Interop.SafeHandles;
|
||||||
using Penumbra.Interop.Structs;
|
using Penumbra.Interop.Structs;
|
||||||
using Penumbra.String;
|
using Penumbra.String;
|
||||||
using Penumbra.String.Classes;
|
using Penumbra.String.Classes;
|
||||||
using Penumbra.Util;
|
using Penumbra.Util;
|
||||||
|
using CSResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle;
|
||||||
|
|
||||||
namespace Penumbra.Interop.ResourceLoading;
|
namespace Penumbra.Interop.ResourceLoading;
|
||||||
|
|
||||||
|
|
@ -25,11 +27,11 @@ public unsafe class ResourceService : IDisposable
|
||||||
_getResourceAsyncHook.Enable();
|
_getResourceAsyncHook.Enable();
|
||||||
_resourceHandleDestructorHook.Enable();
|
_resourceHandleDestructorHook.Enable();
|
||||||
_incRefHook = interop.HookFromAddress<ResourceHandlePrototype>(
|
_incRefHook = interop.HookFromAddress<ResourceHandlePrototype>(
|
||||||
(nint)FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle.MemberFunctionPointers.IncRef,
|
(nint)CSResourceHandle.MemberFunctionPointers.IncRef,
|
||||||
ResourceHandleIncRefDetour);
|
ResourceHandleIncRefDetour);
|
||||||
_incRefHook.Enable();
|
_incRefHook.Enable();
|
||||||
_decRefHook = interop.HookFromAddress<ResourceHandleDecRefPrototype>(
|
_decRefHook = interop.HookFromAddress<ResourceHandleDecRefPrototype>(
|
||||||
(nint)FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle.MemberFunctionPointers.DecRef,
|
(nint)CSResourceHandle.MemberFunctionPointers.DecRef,
|
||||||
ResourceHandleDecRefDetour);
|
ResourceHandleDecRefDetour);
|
||||||
_decRefHook.Enable();
|
_decRefHook.Enable();
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +43,9 @@ public unsafe class ResourceService : IDisposable
|
||||||
&category, &type, &hash, path.Path, null, false);
|
&category, &type, &hash, path.Path, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SafeResourceHandle GetSafeResource(ResourceCategory category, ResourceType type, ByteString path)
|
||||||
|
=> new((CSResourceHandle*)GetResource(category, type, path), false);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_getResourceSyncHook.Dispose();
|
_getResourceSyncHook.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Penumbra.Collections;
|
||||||
using Penumbra.GameData.Data;
|
using Penumbra.GameData.Data;
|
||||||
using Penumbra.GameData.Enums;
|
using Penumbra.GameData.Enums;
|
||||||
using Penumbra.GameData.Structs;
|
using Penumbra.GameData.Structs;
|
||||||
|
using Penumbra.Interop.Services;
|
||||||
using Penumbra.String;
|
using Penumbra.String;
|
||||||
using Penumbra.String.Classes;
|
using Penumbra.String.Classes;
|
||||||
using Penumbra.UI;
|
using Penumbra.UI;
|
||||||
|
|
@ -144,6 +145,14 @@ internal unsafe partial record ResolveContext(
|
||||||
return GetOrCreateNode(ResourceType.Imc, 0, imc, path);
|
return GetOrCreateNode(ResourceType.Imc, 0, imc, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ResourceNode? CreateNodeFromPbd(ResourceHandle* pbd)
|
||||||
|
{
|
||||||
|
if (pbd == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return GetOrCreateNode(ResourceType.Pbd, 0, pbd, PreBoneDeformerReplacer.PreBoneDeformerPath);
|
||||||
|
}
|
||||||
|
|
||||||
public ResourceNode? CreateNodeFromTex(TextureResourceHandle* tex, string gamePath)
|
public ResourceNode? CreateNodeFromTex(TextureResourceHandle* tex, string gamePath)
|
||||||
{
|
{
|
||||||
if (tex == null)
|
if (tex == null)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using Dalamud.Game.ClientState.Objects.Enums;
|
|
||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
|
|
@ -6,6 +5,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
|
||||||
using Penumbra.GameData.Data;
|
using Penumbra.GameData.Data;
|
||||||
using Penumbra.GameData.Enums;
|
using Penumbra.GameData.Enums;
|
||||||
using Penumbra.GameData.Structs;
|
using Penumbra.GameData.Structs;
|
||||||
|
using Penumbra.Interop.Services;
|
||||||
using Penumbra.UI;
|
using Penumbra.UI;
|
||||||
using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData;
|
using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData;
|
||||||
using CustomizeIndex = Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex;
|
using CustomizeIndex = Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex;
|
||||||
|
|
@ -155,6 +155,22 @@ public class ResourceTree
|
||||||
{
|
{
|
||||||
var genericContext = globalContext.CreateContext(&human->CharacterBase);
|
var genericContext = globalContext.CreateContext(&human->CharacterBase);
|
||||||
|
|
||||||
|
var cache = globalContext.Collection._cache;
|
||||||
|
if (cache != null && cache.CustomResources.TryGetValue(PreBoneDeformerReplacer.PreBoneDeformerPath, out var pbdHandle))
|
||||||
|
{
|
||||||
|
var pbdNode = genericContext.CreateNodeFromPbd(pbdHandle.ResourceHandle);
|
||||||
|
if (pbdNode != null)
|
||||||
|
{
|
||||||
|
if (globalContext.WithUiData)
|
||||||
|
{
|
||||||
|
pbdNode = pbdNode.Clone();
|
||||||
|
pbdNode.FallbackName = "Racial Deformer";
|
||||||
|
pbdNode.Icon = ChangedItemDrawer.ChangedItemIcon.Customization;
|
||||||
|
}
|
||||||
|
Nodes.Add(pbdNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var decalId = (byte)(human->Customize[(int)CustomizeIndex.Facepaint] & 0x7F);
|
var decalId = (byte)(human->Customize[(int)CustomizeIndex.Facepaint] & 0x7F);
|
||||||
var decalPath = decalId != 0
|
var decalPath = decalId != 0
|
||||||
? GamePaths.Human.Decal.FaceDecalPath(decalId)
|
? GamePaths.Human.Decal.FaceDecalPath(decalId)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
|
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
|
||||||
|
|
||||||
namespace Penumbra.Interop.SafeHandles;
|
namespace Penumbra.Interop.SafeHandles;
|
||||||
|
|
||||||
public unsafe class SafeResourceHandle : SafeHandle
|
public unsafe class SafeResourceHandle : SafeHandle, ICloneable
|
||||||
{
|
{
|
||||||
public ResourceHandle* ResourceHandle
|
public ResourceHandle* ResourceHandle
|
||||||
=> (ResourceHandle*)handle;
|
=> (ResourceHandle*)handle;
|
||||||
|
|
@ -21,6 +21,12 @@ public unsafe class SafeResourceHandle : SafeHandle
|
||||||
SetHandle((nint)handle);
|
SetHandle((nint)handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SafeResourceHandle Clone()
|
||||||
|
=> new(ResourceHandle, true);
|
||||||
|
|
||||||
|
object ICloneable.Clone()
|
||||||
|
=> Clone();
|
||||||
|
|
||||||
public static SafeResourceHandle CreateInvalid()
|
public static SafeResourceHandle CreateInvalid()
|
||||||
=> new(null, false);
|
=> new(null, false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ public unsafe class CharacterUtility : IDisposable
|
||||||
|
|
||||||
public bool Ready { get; private set; }
|
public bool Ready { get; private set; }
|
||||||
public event Action LoadingFinished;
|
public event Action LoadingFinished;
|
||||||
|
public nint DefaultHumanPbdResource { get; private set; }
|
||||||
public nint DefaultTransparentResource { get; private set; }
|
public nint DefaultTransparentResource { get; private set; }
|
||||||
public nint DefaultDecalResource { get; private set; }
|
public nint DefaultDecalResource { get; private set; }
|
||||||
public nint DefaultSkinShpkResource { get; private set; }
|
public nint DefaultSkinShpkResource { get; private set; }
|
||||||
|
|
@ -88,6 +89,12 @@ public unsafe class CharacterUtility : IDisposable
|
||||||
anyMissing |= !_lists[i].Ready;
|
anyMissing |= !_lists[i].Ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DefaultHumanPbdResource == nint.Zero)
|
||||||
|
{
|
||||||
|
DefaultHumanPbdResource = (nint)Address->HumanPbdResource;
|
||||||
|
anyMissing |= DefaultHumanPbdResource == nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
if (DefaultTransparentResource == nint.Zero)
|
if (DefaultTransparentResource == nint.Zero)
|
||||||
{
|
{
|
||||||
DefaultTransparentResource = (nint)Address->TransparentTexResource;
|
DefaultTransparentResource = (nint)Address->TransparentTexResource;
|
||||||
|
|
@ -151,6 +158,7 @@ public unsafe class CharacterUtility : IDisposable
|
||||||
foreach (var list in _lists)
|
foreach (var list in _lists)
|
||||||
list.Dispose();
|
list.Dispose();
|
||||||
|
|
||||||
|
Address->HumanPbdResource = (ResourceHandle*)DefaultHumanPbdResource;
|
||||||
Address->TransparentTexResource = (TextureResourceHandle*)DefaultTransparentResource;
|
Address->TransparentTexResource = (TextureResourceHandle*)DefaultTransparentResource;
|
||||||
Address->DecalTexResource = (TextureResourceHandle*)DefaultDecalResource;
|
Address->DecalTexResource = (TextureResourceHandle*)DefaultDecalResource;
|
||||||
Address->SkinShpkResource = (ResourceHandle*)DefaultSkinShpkResource;
|
Address->SkinShpkResource = (ResourceHandle*)DefaultSkinShpkResource;
|
||||||
|
|
|
||||||
97
Penumbra/Interop/Services/PreBoneDeformerReplacer.cs
Normal file
97
Penumbra/Interop/Services/PreBoneDeformerReplacer.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
using Dalamud.Hooking;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
||||||
|
using OtterGui.Services;
|
||||||
|
using Penumbra.Api.Enums;
|
||||||
|
using Penumbra.Interop.PathResolving;
|
||||||
|
using Penumbra.Interop.ResourceLoading;
|
||||||
|
using Penumbra.Interop.SafeHandles;
|
||||||
|
using Penumbra.String.Classes;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.Services;
|
||||||
|
|
||||||
|
public sealed unsafe class PreBoneDeformerReplacer : IDisposable, IRequiredService
|
||||||
|
{
|
||||||
|
public static readonly Utf8GamePath PreBoneDeformerPath =
|
||||||
|
Utf8GamePath.FromSpan("chara/xls/boneDeformer/human.pbd"u8, out var p) ? p : Utf8GamePath.Empty;
|
||||||
|
|
||||||
|
// Approximate name guesses.
|
||||||
|
private delegate void CharacterBaseSetupScalingDelegate(CharacterBase* drawObject, uint slotIndex);
|
||||||
|
private delegate void* CharacterBaseCreateDeformerDelegate(CharacterBase* drawObject, uint slotIndex);
|
||||||
|
|
||||||
|
private readonly Hook<CharacterBaseSetupScalingDelegate> _humanSetupScalingHook;
|
||||||
|
private readonly Hook<CharacterBaseCreateDeformerDelegate> _humanCreateDeformerHook;
|
||||||
|
|
||||||
|
private readonly CharacterUtility _utility;
|
||||||
|
private readonly CollectionResolver _collectionResolver;
|
||||||
|
private readonly ResourceLoader _resourceLoader;
|
||||||
|
private readonly IFramework _framework;
|
||||||
|
|
||||||
|
public PreBoneDeformerReplacer(CharacterUtility utility, CollectionResolver collectionResolver, ResourceLoader resourceLoader,
|
||||||
|
IGameInteropProvider interop, IFramework framework, CharacterBaseVTables vTables)
|
||||||
|
{
|
||||||
|
interop.InitializeFromAttributes(this);
|
||||||
|
_utility = utility;
|
||||||
|
_collectionResolver = collectionResolver;
|
||||||
|
_resourceLoader = resourceLoader;
|
||||||
|
_framework = framework;
|
||||||
|
_humanSetupScalingHook = interop.HookFromAddress<CharacterBaseSetupScalingDelegate>(vTables.HumanVTable[57], SetupScaling);
|
||||||
|
_humanCreateDeformerHook = interop.HookFromAddress<CharacterBaseCreateDeformerDelegate>(vTables.HumanVTable[91], CreateDeformer);
|
||||||
|
_humanSetupScalingHook.Enable();
|
||||||
|
_humanCreateDeformerHook.Enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_humanCreateDeformerHook.Dispose();
|
||||||
|
_humanSetupScalingHook.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SafeResourceHandle GetPreBoneDeformerForCharacter(CharacterBase* drawObject)
|
||||||
|
{
|
||||||
|
var resolveData = _collectionResolver.IdentifyCollection(&drawObject->DrawObject, true);
|
||||||
|
if (resolveData.ModCollection._cache is not { } cache)
|
||||||
|
return _resourceLoader.LoadResolvedSafeResource(ResourceCategory.Chara, ResourceType.Pbd, PreBoneDeformerPath.Path, resolveData);
|
||||||
|
|
||||||
|
return cache.CustomResources.Get(ResourceCategory.Chara, ResourceType.Pbd, PreBoneDeformerPath, resolveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupScaling(CharacterBase* drawObject, uint slotIndex)
|
||||||
|
{
|
||||||
|
if (!_framework.IsInFrameworkUpdateThread)
|
||||||
|
Penumbra.Log.Warning(
|
||||||
|
$"{nameof(PreBoneDeformerReplacer)}.{nameof(SetupScaling)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread");
|
||||||
|
|
||||||
|
using var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!preBoneDeformer.IsInvalid)
|
||||||
|
_utility.Address->HumanPbdResource = (Structs.ResourceHandle*)preBoneDeformer.ResourceHandle;
|
||||||
|
_humanSetupScalingHook.Original(drawObject, slotIndex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_utility.Address->HumanPbdResource = (Structs.ResourceHandle*)_utility.DefaultHumanPbdResource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void* CreateDeformer(CharacterBase* drawObject, uint slotIndex)
|
||||||
|
{
|
||||||
|
if (!_framework.IsInFrameworkUpdateThread)
|
||||||
|
Penumbra.Log.Warning(
|
||||||
|
$"{nameof(PreBoneDeformerReplacer)}.{nameof(CreateDeformer)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread");
|
||||||
|
|
||||||
|
using var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!preBoneDeformer.IsInvalid)
|
||||||
|
_utility.Address->HumanPbdResource = (Structs.ResourceHandle*)preBoneDeformer.ResourceHandle;
|
||||||
|
return _humanCreateDeformerHook.Original(drawObject, slotIndex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_utility.Address->HumanPbdResource = (Structs.ResourceHandle*)_utility.DefaultHumanPbdResource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
|
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
|
||||||
using OtterGui.Classes;
|
using OtterGui.Classes;
|
||||||
|
using OtterGui.Services;
|
||||||
using Penumbra.Communication;
|
using Penumbra.Communication;
|
||||||
using Penumbra.GameData;
|
using Penumbra.GameData;
|
||||||
using Penumbra.Interop.Hooks.Resources;
|
using Penumbra.Interop.Hooks.Resources;
|
||||||
|
|
@ -13,7 +14,7 @@ using CSModelRenderer = FFXIVClientStructs.FFXIV.Client.Graphics.Render.ModelRen
|
||||||
|
|
||||||
namespace Penumbra.Interop.Services;
|
namespace Penumbra.Interop.Services;
|
||||||
|
|
||||||
public sealed unsafe class ShaderReplacementFixer : IDisposable
|
public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredService
|
||||||
{
|
{
|
||||||
public static ReadOnlySpan<byte> SkinShpkName
|
public static ReadOnlySpan<byte> SkinShpkName
|
||||||
=> "skin.shpk"u8;
|
=> "skin.shpk"u8;
|
||||||
|
|
@ -21,11 +22,10 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable
|
||||||
public static ReadOnlySpan<byte> CharacterGlassShpkName
|
public static ReadOnlySpan<byte> CharacterGlassShpkName
|
||||||
=> "characterglass.shpk"u8;
|
=> "characterglass.shpk"u8;
|
||||||
|
|
||||||
[Signature(Sigs.HumanVTable, ScanType = ScanType.StaticAddress)]
|
|
||||||
private readonly nint* _humanVTable = null!;
|
|
||||||
|
|
||||||
private delegate nint CharacterBaseOnRenderMaterialDelegate(CharacterBase* drawObject, CSModelRenderer.OnRenderMaterialParams* param);
|
private delegate nint CharacterBaseOnRenderMaterialDelegate(CharacterBase* drawObject, CSModelRenderer.OnRenderMaterialParams* param);
|
||||||
private delegate nint ModelRendererOnRenderMaterialDelegate(CSModelRenderer* modelRenderer, ushort* outFlags, CSModelRenderer.OnRenderModelParams* param, Material* material, uint materialIndex);
|
|
||||||
|
private delegate nint ModelRendererOnRenderMaterialDelegate(CSModelRenderer* modelRenderer, ushort* outFlags,
|
||||||
|
CSModelRenderer.OnRenderModelParams* param, Material* material, uint materialIndex);
|
||||||
|
|
||||||
private readonly Hook<CharacterBaseOnRenderMaterialDelegate> _humanOnRenderMaterialHook;
|
private readonly Hook<CharacterBaseOnRenderMaterialDelegate> _humanOnRenderMaterialHook;
|
||||||
|
|
||||||
|
|
@ -59,14 +59,15 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable
|
||||||
=> _moddedCharacterGlassShpkCount;
|
=> _moddedCharacterGlassShpkCount;
|
||||||
|
|
||||||
public ShaderReplacementFixer(ResourceHandleDestructor resourceHandleDestructor, CharacterUtility utility, ModelRenderer modelRenderer,
|
public ShaderReplacementFixer(ResourceHandleDestructor resourceHandleDestructor, CharacterUtility utility, ModelRenderer modelRenderer,
|
||||||
CommunicatorService communicator, IGameInteropProvider interop)
|
CommunicatorService communicator, IGameInteropProvider interop, CharacterBaseVTables vTables)
|
||||||
{
|
{
|
||||||
interop.InitializeFromAttributes(this);
|
interop.InitializeFromAttributes(this);
|
||||||
_resourceHandleDestructor = resourceHandleDestructor;
|
_resourceHandleDestructor = resourceHandleDestructor;
|
||||||
_utility = utility;
|
_utility = utility;
|
||||||
_modelRenderer = modelRenderer;
|
_modelRenderer = modelRenderer;
|
||||||
_communicator = communicator;
|
_communicator = communicator;
|
||||||
_humanOnRenderMaterialHook = interop.HookFromAddress<CharacterBaseOnRenderMaterialDelegate>(_humanVTable[62], OnRenderHumanMaterial);
|
_humanOnRenderMaterialHook =
|
||||||
|
interop.HookFromAddress<CharacterBaseOnRenderMaterialDelegate>(vTables.HumanVTable[62], OnRenderHumanMaterial);
|
||||||
_communicator.MtrlShpkLoaded.Subscribe(OnMtrlShpkLoaded, MtrlShpkLoaded.Priority.ShaderReplacementFixer);
|
_communicator.MtrlShpkLoaded.Subscribe(OnMtrlShpkLoaded, MtrlShpkLoaded.Priority.ShaderReplacementFixer);
|
||||||
_resourceHandleDestructor.Subscribe(OnResourceHandleDestructor, ResourceHandleDestructor.Priority.ShaderReplacementFixer);
|
_resourceHandleDestructor.Subscribe(OnResourceHandleDestructor, ResourceHandleDestructor.Priority.ShaderReplacementFixer);
|
||||||
_humanOnRenderMaterialHook.Enable();
|
_humanOnRenderMaterialHook.Enable();
|
||||||
|
|
@ -106,17 +107,13 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable
|
||||||
var shpkName = mtrl->ShpkNameSpan;
|
var shpkName = mtrl->ShpkNameSpan;
|
||||||
|
|
||||||
if (SkinShpkName.SequenceEqual(shpkName) && (nint)shpk != _utility.DefaultSkinShpkResource)
|
if (SkinShpkName.SequenceEqual(shpkName) && (nint)shpk != _utility.DefaultSkinShpkResource)
|
||||||
{
|
|
||||||
if (_moddedSkinShpkMaterials.TryAdd(mtrlResourceHandle))
|
if (_moddedSkinShpkMaterials.TryAdd(mtrlResourceHandle))
|
||||||
Interlocked.Increment(ref _moddedSkinShpkCount);
|
Interlocked.Increment(ref _moddedSkinShpkCount);
|
||||||
}
|
|
||||||
|
|
||||||
if (CharacterGlassShpkName.SequenceEqual(shpkName) && shpk != _modelRenderer.DefaultCharacterGlassShaderPackage)
|
if (CharacterGlassShpkName.SequenceEqual(shpkName) && shpk != _modelRenderer.DefaultCharacterGlassShaderPackage)
|
||||||
{
|
|
||||||
if (_moddedCharacterGlassShpkMaterials.TryAdd(mtrlResourceHandle))
|
if (_moddedCharacterGlassShpkMaterials.TryAdd(mtrlResourceHandle))
|
||||||
Interlocked.Increment(ref _moddedCharacterGlassShpkCount);
|
Interlocked.Increment(ref _moddedCharacterGlassShpkCount);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void OnResourceHandleDestructor(Structs.ResourceHandle* handle)
|
private void OnResourceHandleDestructor(Structs.ResourceHandle* handle)
|
||||||
{
|
{
|
||||||
|
|
@ -159,9 +156,9 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nint ModelRendererOnRenderMaterialDetour(CSModelRenderer* modelRenderer, ushort* outFlags, CSModelRenderer.OnRenderModelParams* param, Material* material, uint materialIndex)
|
private nint ModelRendererOnRenderMaterialDetour(CSModelRenderer* modelRenderer, ushort* outFlags,
|
||||||
|
CSModelRenderer.OnRenderModelParams* param, Material* material, uint materialIndex)
|
||||||
{
|
{
|
||||||
|
|
||||||
// If we don't have any on-screen instances of modded characterglass.shpk, we don't need the slow path at all.
|
// If we don't have any on-screen instances of modded characterglass.shpk, we don't need the slow path at all.
|
||||||
if (!Enabled || _moddedCharacterGlassShpkCount == 0)
|
if (!Enabled || _moddedCharacterGlassShpkCount == 0)
|
||||||
return _modelRendererOnRenderMaterialHook.Original(modelRenderer, outFlags, param, material, materialIndex);
|
return _modelRendererOnRenderMaterialHook.Original(modelRenderer, outFlags, param, material, materialIndex);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace Penumbra.Interop.Structs;
|
||||||
[StructLayout(LayoutKind.Explicit)]
|
[StructLayout(LayoutKind.Explicit)]
|
||||||
public unsafe struct CharacterUtilityData
|
public unsafe struct CharacterUtilityData
|
||||||
{
|
{
|
||||||
|
public const int IndexHumanPbd = 63;
|
||||||
public const int IndexTransparentTex = 72;
|
public const int IndexTransparentTex = 72;
|
||||||
public const int IndexDecalTex = 73;
|
public const int IndexDecalTex = 73;
|
||||||
public const int IndexSkinShpk = 76;
|
public const int IndexSkinShpk = 76;
|
||||||
|
|
@ -72,6 +73,9 @@ public unsafe struct CharacterUtilityData
|
||||||
public ResourceHandle* EqdpResource(GenderRace raceCode, bool accessory)
|
public ResourceHandle* EqdpResource(GenderRace raceCode, bool accessory)
|
||||||
=> Resource((int)EqdpIdx(raceCode, accessory));
|
=> Resource((int)EqdpIdx(raceCode, accessory));
|
||||||
|
|
||||||
|
[FieldOffset(8 + IndexHumanPbd * 8)]
|
||||||
|
public ResourceHandle* HumanPbdResource;
|
||||||
|
|
||||||
[FieldOffset(8 + (int)MetaIndex.HumanCmp * 8)]
|
[FieldOffset(8 + (int)MetaIndex.HumanCmp * 8)]
|
||||||
public ResourceHandle* HumanCmpResource;
|
public ResourceHandle* HumanCmpResource;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,6 @@ public class Penumbra : IDalamudPlugin
|
||||||
_services.GetService<ModCacheManager>(); // Initialize because not required anywhere else.
|
_services.GetService<ModCacheManager>(); // Initialize because not required anywhere else.
|
||||||
_collectionManager.Caches.CreateNecessaryCaches();
|
_collectionManager.Caches.CreateNecessaryCaches();
|
||||||
_services.GetService<PathResolver>();
|
_services.GetService<PathResolver>();
|
||||||
_services.GetService<ShaderReplacementFixer>();
|
|
||||||
|
|
||||||
_services.GetService<DalamudSubstitutionProvider>(); // Initialize before Interface.
|
_services.GetService<DalamudSubstitutionProvider>(); // Initialize before Interface.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,7 @@ public static class StaticServiceManager
|
||||||
=> services.AddSingleton<ResourceLoader>()
|
=> services.AddSingleton<ResourceLoader>()
|
||||||
.AddSingleton<ResourceWatcher>()
|
.AddSingleton<ResourceWatcher>()
|
||||||
.AddSingleton<ResourceTreeFactory>()
|
.AddSingleton<ResourceTreeFactory>()
|
||||||
.AddSingleton<MetaFileManager>()
|
.AddSingleton<MetaFileManager>();
|
||||||
.AddSingleton<ShaderReplacementFixer>();
|
|
||||||
|
|
||||||
private static ServiceManager AddResolvers(this ServiceManager services)
|
private static ServiceManager AddResolvers(this ServiceManager services)
|
||||||
=> services.AddSingleton<CollectionResolver>()
|
=> services.AddSingleton<CollectionResolver>()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue