mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-12 10:17:22 +01:00
Merge branch 'async-stuff'
# Conflicts: # Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs
This commit is contained in:
commit
e508e6158f
11 changed files with 307 additions and 69 deletions
|
|
@ -1 +1 @@
|
||||||
Subproject commit ebeea67c17f6bf4ce7e635041b2138e835d31262
|
Subproject commit 4a987167b665184d4c05fc9863993981c35a1d19
|
||||||
|
|
@ -100,6 +100,7 @@ public class HookOverrides
|
||||||
public bool DecRef;
|
public bool DecRef;
|
||||||
public bool GetResourceSync;
|
public bool GetResourceSync;
|
||||||
public bool GetResourceAsync;
|
public bool GetResourceAsync;
|
||||||
|
public bool UpdateResourceState;
|
||||||
public bool CheckFileState;
|
public bool CheckFileState;
|
||||||
public bool TexResourceHandleOnLoad;
|
public bool TexResourceHandleOnLoad;
|
||||||
public bool LoadMdlFileExtern;
|
public bool LoadMdlFileExtern;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
||||||
using OtterGui.Services;
|
using OtterGui.Services;
|
||||||
using Penumbra.Api.Enums;
|
using Penumbra.Api.Enums;
|
||||||
using Penumbra.Collections;
|
using Penumbra.Collections;
|
||||||
|
using Penumbra.Interop.Hooks.Resources;
|
||||||
using Penumbra.Interop.PathResolving;
|
using Penumbra.Interop.PathResolving;
|
||||||
using Penumbra.Interop.SafeHandles;
|
using Penumbra.Interop.SafeHandles;
|
||||||
using Penumbra.Interop.Structs;
|
using Penumbra.Interop.Structs;
|
||||||
|
|
@ -13,27 +14,38 @@ namespace Penumbra.Interop.Hooks.ResourceLoading;
|
||||||
|
|
||||||
public unsafe class ResourceLoader : IDisposable, IService
|
public unsafe class ResourceLoader : IDisposable, IService
|
||||||
{
|
{
|
||||||
private readonly ResourceService _resources;
|
private readonly ResourceService _resources;
|
||||||
private readonly FileReadService _fileReadService;
|
private readonly FileReadService _fileReadService;
|
||||||
private readonly RsfService _rsfService;
|
private readonly RsfService _rsfService;
|
||||||
private readonly PapHandler _papHandler;
|
private readonly PapHandler _papHandler;
|
||||||
private readonly Configuration _config;
|
private readonly Configuration _config;
|
||||||
|
private readonly ResourceHandleDestructor _destructor;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<nint, Utf8GamePath> _ongoingLoads = [];
|
||||||
|
|
||||||
private ResolveData _resolvedData = ResolveData.Invalid;
|
private ResolveData _resolvedData = ResolveData.Invalid;
|
||||||
public event Action<Utf8GamePath, FullPath?, ResolveData>? PapRequested;
|
public event Action<Utf8GamePath, FullPath?, ResolveData>? PapRequested;
|
||||||
|
|
||||||
public ResourceLoader(ResourceService resources, FileReadService fileReadService, RsfService rsfService, Configuration config, PeSigScanner sigScanner)
|
public IReadOnlyDictionary<nint, Utf8GamePath> OngoingLoads
|
||||||
|
=> _ongoingLoads;
|
||||||
|
|
||||||
|
public ResourceLoader(ResourceService resources, FileReadService fileReadService, RsfService rsfService, Configuration config, PeSigScanner sigScanner,
|
||||||
|
ResourceHandleDestructor destructor)
|
||||||
{
|
{
|
||||||
_resources = resources;
|
_resources = resources;
|
||||||
_fileReadService = fileReadService;
|
_fileReadService = fileReadService;
|
||||||
_rsfService = rsfService;
|
_rsfService = rsfService;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_destructor = destructor;
|
||||||
ResetResolvePath();
|
ResetResolvePath();
|
||||||
|
|
||||||
_resources.ResourceRequested += ResourceHandler;
|
_resources.ResourceRequested += ResourceHandler;
|
||||||
_resources.ResourceHandleIncRef += IncRefProtection;
|
_resources.ResourceStateUpdating += ResourceStateUpdatingHandler;
|
||||||
_resources.ResourceHandleDecRef += DecRefProtection;
|
_resources.ResourceStateUpdated += ResourceStateUpdatedHandler;
|
||||||
_fileReadService.ReadSqPack += ReadSqPackDetour;
|
_resources.ResourceHandleIncRef += IncRefProtection;
|
||||||
|
_resources.ResourceHandleDecRef += DecRefProtection;
|
||||||
|
_fileReadService.ReadSqPack += ReadSqPackDetour;
|
||||||
|
_destructor.Subscribe(ResourceDestructorHandler, ResourceHandleDestructor.Priority.ResourceLoader);
|
||||||
|
|
||||||
_papHandler = new PapHandler(sigScanner, PapResourceHandler);
|
_papHandler = new PapHandler(sigScanner, PapResourceHandler);
|
||||||
_papHandler.Enable();
|
_papHandler.Enable();
|
||||||
|
|
@ -109,12 +121,32 @@ public unsafe class ResourceLoader : IDisposable, IService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event FileLoadedDelegate? FileLoaded;
|
public event FileLoadedDelegate? FileLoaded;
|
||||||
|
|
||||||
|
public delegate void ResourceCompleteDelegate(ResourceHandle* resource, CiByteString path, Utf8GamePath originalPath,
|
||||||
|
ReadOnlySpan<byte> additionalData, bool isAsync);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event fired just before a resource finishes loading.
|
||||||
|
/// <see cref="ResourceHandle.LoadState"/> must be checked to know whether the load was successful or not.
|
||||||
|
/// AdditionalData is either empty or the part of the path inside the leading pipes.
|
||||||
|
/// </summary>
|
||||||
|
public event ResourceCompleteDelegate? BeforeResourceComplete;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event fired when a resource has finished loading.
|
||||||
|
/// <see cref="ResourceHandle.LoadState"/> must be checked to know whether the load was successful or not.
|
||||||
|
/// AdditionalData is either empty or the part of the path inside the leading pipes.
|
||||||
|
/// </summary>
|
||||||
|
public event ResourceCompleteDelegate? ResourceComplete;
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_resources.ResourceRequested -= ResourceHandler;
|
_resources.ResourceRequested -= ResourceHandler;
|
||||||
_resources.ResourceHandleIncRef -= IncRefProtection;
|
_resources.ResourceStateUpdating -= ResourceStateUpdatingHandler;
|
||||||
_resources.ResourceHandleDecRef -= DecRefProtection;
|
_resources.ResourceStateUpdated -= ResourceStateUpdatedHandler;
|
||||||
_fileReadService.ReadSqPack -= ReadSqPackDetour;
|
_resources.ResourceHandleIncRef -= IncRefProtection;
|
||||||
|
_resources.ResourceHandleDecRef -= DecRefProtection;
|
||||||
|
_fileReadService.ReadSqPack -= ReadSqPackDetour;
|
||||||
|
_destructor.Unsubscribe(ResourceDestructorHandler);
|
||||||
_papHandler.Dispose();
|
_papHandler.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +167,8 @@ public unsafe class ResourceLoader : IDisposable, IService
|
||||||
|
|
||||||
if (resolvedPath == null || !Utf8GamePath.FromByteString(resolvedPath.Value.InternalName, out var p))
|
if (resolvedPath == null || !Utf8GamePath.FromByteString(resolvedPath.Value.InternalName, out var p))
|
||||||
{
|
{
|
||||||
returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, parameters);
|
returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, original, parameters);
|
||||||
|
TrackResourceLoad(returnValue, original);
|
||||||
ResourceLoaded?.Invoke(returnValue, path, resolvedPath, data);
|
ResourceLoaded?.Invoke(returnValue, path, resolvedPath, data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -145,10 +178,57 @@ public unsafe class ResourceLoader : IDisposable, IService
|
||||||
hash = ComputeHash(resolvedPath.Value.InternalName, parameters);
|
hash = ComputeHash(resolvedPath.Value.InternalName, parameters);
|
||||||
var oldPath = path;
|
var oldPath = path;
|
||||||
path = p;
|
path = p;
|
||||||
returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, parameters);
|
returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, original, parameters);
|
||||||
|
TrackResourceLoad(returnValue, original);
|
||||||
ResourceLoaded?.Invoke(returnValue, oldPath, resolvedPath.Value, data);
|
ResourceLoaded?.Invoke(returnValue, oldPath, resolvedPath.Value, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void TrackResourceLoad(ResourceHandle* handle, Utf8GamePath original)
|
||||||
|
{
|
||||||
|
if (handle->UnkState == 2 && handle->LoadState >= LoadState.Success)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_ongoingLoads.TryAdd((nint)handle, original.Clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResourceStateUpdatedHandler(ResourceHandle* handle, Utf8GamePath syncOriginal, (byte, LoadState) previousState, ref uint returnValue)
|
||||||
|
{
|
||||||
|
if (handle->UnkState != 2 || handle->LoadState < LoadState.Success || previousState is { Item1: 2, Item2: >= LoadState.Success })
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_ongoingLoads.TryRemove((nint)handle, out var asyncOriginal))
|
||||||
|
asyncOriginal = Utf8GamePath.Empty;
|
||||||
|
|
||||||
|
var path = handle->CsHandle.FileName;
|
||||||
|
if (!syncOriginal.IsEmpty && !asyncOriginal.IsEmpty && !syncOriginal.Equals(asyncOriginal))
|
||||||
|
Penumbra.Log.Warning($"[ResourceLoader] Resource original paths inconsistency: 0x{(nint)handle:X}, of path {path}, sync original {syncOriginal}, async original {asyncOriginal}.");
|
||||||
|
var original = !asyncOriginal.IsEmpty ? asyncOriginal : syncOriginal;
|
||||||
|
|
||||||
|
Penumbra.Log.Excessive($"[ResourceLoader] Resource is complete: 0x{(nint)handle:X}, of path {path}, original {original}, state {previousState.Item1}:{previousState.Item2} -> {handle->UnkState}:{handle->LoadState}, sync: {asyncOriginal.IsEmpty}");
|
||||||
|
if (PathDataHandler.Split(path.AsSpan(), out var actualPath, out var additionalData))
|
||||||
|
ResourceComplete?.Invoke(handle, new CiByteString(actualPath), original, additionalData, !asyncOriginal.IsEmpty);
|
||||||
|
else
|
||||||
|
ResourceComplete?.Invoke(handle, path.AsByteString(), original, [], !asyncOriginal.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResourceStateUpdatingHandler(ResourceHandle* handle, Utf8GamePath syncOriginal)
|
||||||
|
{
|
||||||
|
if (handle->UnkState != 1 || handle->LoadState != LoadState.Success)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_ongoingLoads.TryGetValue((nint)handle, out var asyncOriginal))
|
||||||
|
asyncOriginal = Utf8GamePath.Empty;
|
||||||
|
|
||||||
|
var path = handle->CsHandle.FileName;
|
||||||
|
var original = asyncOriginal.IsEmpty ? syncOriginal : asyncOriginal;
|
||||||
|
|
||||||
|
Penumbra.Log.Excessive($"[ResourceLoader] Resource is about to be complete: 0x{(nint)handle:X}, of path {path}, original {original}");
|
||||||
|
if (PathDataHandler.Split(path.AsSpan(), out var actualPath, out var additionalData))
|
||||||
|
BeforeResourceComplete?.Invoke(handle, new CiByteString(actualPath), original, additionalData, !asyncOriginal.IsEmpty);
|
||||||
|
else
|
||||||
|
BeforeResourceComplete?.Invoke(handle, path.AsByteString(), original, [], !asyncOriginal.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
private void ReadSqPackDetour(SeFileDescriptor* fileDescriptor, ref int priority, ref bool isSync, ref byte? returnValue)
|
private void ReadSqPackDetour(SeFileDescriptor* fileDescriptor, ref int priority, ref bool isSync, ref byte? returnValue)
|
||||||
{
|
{
|
||||||
if (fileDescriptor->ResourceHandle == null)
|
if (fileDescriptor->ResourceHandle == null)
|
||||||
|
|
@ -176,7 +256,6 @@ public unsafe class ResourceLoader : IDisposable, IService
|
||||||
gamePath.Path.IsAscii);
|
gamePath.Path.IsAscii);
|
||||||
fileDescriptor->ResourceHandle->FileNameData = path.Path;
|
fileDescriptor->ResourceHandle->FileNameData = path.Path;
|
||||||
fileDescriptor->ResourceHandle->FileNameLength = path.Length;
|
fileDescriptor->ResourceHandle->FileNameLength = path.Length;
|
||||||
ForceSync(fileDescriptor, ref isSync);
|
|
||||||
returnValue = DefaultLoadResource(path, fileDescriptor, priority, isSync, data);
|
returnValue = DefaultLoadResource(path, fileDescriptor, priority, isSync, data);
|
||||||
// Return original resource handle path so that they can be loaded separately.
|
// Return original resource handle path so that they can be loaded separately.
|
||||||
fileDescriptor->ResourceHandle->FileNameData = gamePath.Path.Path;
|
fileDescriptor->ResourceHandle->FileNameData = gamePath.Path.Path;
|
||||||
|
|
@ -215,16 +294,6 @@ public unsafe class ResourceLoader : IDisposable, IService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Special handling for materials and IMCs. </summary>
|
|
||||||
private static void ForceSync(SeFileDescriptor* fileDescriptor, ref bool isSync)
|
|
||||||
{
|
|
||||||
// Force isSync = true for Materials. I don't really understand why,
|
|
||||||
// or where the difference even comes from.
|
|
||||||
// Was called with True on my client and with false on other peoples clients,
|
|
||||||
// which caused problems.
|
|
||||||
isSync |= fileDescriptor->ResourceHandle->FileType is ResourceType.Mtrl or ResourceType.Imc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A resource with ref count 0 that gets incremented goes through GetResourceAsync again.
|
/// A resource with ref count 0 that gets incremented goes through GetResourceAsync again.
|
||||||
/// This means, that if the path determined from that is different than the resources path,
|
/// This means, that if the path determined from that is different than the resources path,
|
||||||
|
|
@ -265,6 +334,11 @@ public unsafe class ResourceLoader : IDisposable, IService
|
||||||
returnValue = 1;
|
returnValue = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResourceDestructorHandler(ResourceHandle* handle)
|
||||||
|
{
|
||||||
|
_ongoingLoads.TryRemove((nint)handle, out _);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary> Compute the CRC32 hash for a given path together with potential resource parameters. </summary>
|
/// <summary> Compute the CRC32 hash for a given path together with potential resource parameters. </summary>
|
||||||
private static int ComputeHash(CiByteString path, GetResourceParameters* pGetResParams)
|
private static int ComputeHash(CiByteString path, GetResourceParameters* pGetResParams)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
|
||||||
private readonly PerformanceTracker _performance;
|
private readonly PerformanceTracker _performance;
|
||||||
private readonly ResourceManagerService _resourceManager;
|
private readonly ResourceManagerService _resourceManager;
|
||||||
|
|
||||||
|
private readonly ThreadLocal<Utf8GamePath> _currentGetResourcePath = new(() => Utf8GamePath.Empty);
|
||||||
|
|
||||||
public ResourceService(PerformanceTracker performance, ResourceManagerService resourceManager, IGameInteropProvider interop)
|
public ResourceService(PerformanceTracker performance, ResourceManagerService resourceManager, IGameInteropProvider interop)
|
||||||
{
|
{
|
||||||
_performance = performance;
|
_performance = performance;
|
||||||
|
|
@ -34,6 +36,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
|
||||||
_getResourceSyncHook.Enable();
|
_getResourceSyncHook.Enable();
|
||||||
if (!HookOverrides.Instance.ResourceLoading.GetResourceAsync)
|
if (!HookOverrides.Instance.ResourceLoading.GetResourceAsync)
|
||||||
_getResourceAsyncHook.Enable();
|
_getResourceAsyncHook.Enable();
|
||||||
|
if (!HookOverrides.Instance.ResourceLoading.UpdateResourceState)
|
||||||
|
_updateResourceStateHook.Enable();
|
||||||
if (!HookOverrides.Instance.ResourceLoading.IncRef)
|
if (!HookOverrides.Instance.ResourceLoading.IncRef)
|
||||||
_incRefHook.Enable();
|
_incRefHook.Enable();
|
||||||
if (!HookOverrides.Instance.ResourceLoading.DecRef)
|
if (!HookOverrides.Instance.ResourceLoading.DecRef)
|
||||||
|
|
@ -54,8 +58,10 @@ public unsafe class ResourceService : IDisposable, IRequiredService
|
||||||
{
|
{
|
||||||
_getResourceSyncHook.Dispose();
|
_getResourceSyncHook.Dispose();
|
||||||
_getResourceAsyncHook.Dispose();
|
_getResourceAsyncHook.Dispose();
|
||||||
|
_updateResourceStateHook.Dispose();
|
||||||
_incRefHook.Dispose();
|
_incRefHook.Dispose();
|
||||||
_decRefHook.Dispose();
|
_decRefHook.Dispose();
|
||||||
|
_currentGetResourcePath.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
#region GetResource
|
#region GetResource
|
||||||
|
|
@ -112,28 +118,82 @@ public unsafe class ResourceService : IDisposable, IRequiredService
|
||||||
unk9);
|
unk9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var original = gamePath;
|
||||||
ResourceHandle* returnValue = null;
|
ResourceHandle* returnValue = null;
|
||||||
ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, gamePath, pGetResParams, ref isSync,
|
ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, original, pGetResParams, ref isSync,
|
||||||
ref returnValue);
|
ref returnValue);
|
||||||
if (returnValue != null)
|
if (returnValue != null)
|
||||||
return returnValue;
|
return returnValue;
|
||||||
|
|
||||||
return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, pGetResParams, isUnk, unk8, unk9);
|
return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8, unk9);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Call the original GetResource function. </summary>
|
/// <summary> Call the original GetResource function. </summary>
|
||||||
public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path,
|
public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path, Utf8GamePath original,
|
||||||
GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0)
|
GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0)
|
||||||
=> sync
|
{
|
||||||
? _getResourceSyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path,
|
var previous = _currentGetResourcePath.Value;
|
||||||
resourceParameters, unk8, unk9)
|
try
|
||||||
: _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path,
|
{
|
||||||
resourceParameters, unk, unk8, unk9);
|
_currentGetResourcePath.Value = original;
|
||||||
|
return sync
|
||||||
|
? _getResourceSyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path,
|
||||||
|
resourceParameters, unk8, unk9)
|
||||||
|
: _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path,
|
||||||
|
resourceParameters, unk, unk8, unk9);
|
||||||
|
} finally
|
||||||
|
{
|
||||||
|
_currentGetResourcePath.Value = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private delegate nint ResourceHandlePrototype(ResourceHandle* handle);
|
private delegate nint ResourceHandlePrototype(ResourceHandle* handle);
|
||||||
|
|
||||||
|
#region UpdateResourceState
|
||||||
|
|
||||||
|
/// <summary> Invoked before a resource state is updated. </summary>
|
||||||
|
/// <param name="handle">The resource handle.</param>
|
||||||
|
/// <param name="syncOriginal">The original game path of the resource, if loaded synchronously.</param>
|
||||||
|
public delegate void ResourceStateUpdatingDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal);
|
||||||
|
|
||||||
|
/// <summary> Invoked after a resource state is updated. </summary>
|
||||||
|
/// <param name="handle">The resource handle.</param>
|
||||||
|
/// <param name="syncOriginal">The original game path of the resource, if loaded synchronously.</param>
|
||||||
|
/// <param name="previousState">The previous state of the resource.</param>
|
||||||
|
/// <param name="returnValue">The return value to use.</param>
|
||||||
|
public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal, (byte UnkState, LoadState LoadState) previousState, ref uint returnValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <inheritdoc cref="ResourceStateUpdatingDelegate"/> <para/>
|
||||||
|
/// Subscribers should be exception-safe.
|
||||||
|
/// </summary>
|
||||||
|
public event ResourceStateUpdatingDelegate? ResourceStateUpdating;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <inheritdoc cref="ResourceStateUpdatedDelegate"/> <para/>
|
||||||
|
/// Subscribers should be exception-safe.
|
||||||
|
/// </summary>
|
||||||
|
public event ResourceStateUpdatedDelegate? ResourceStateUpdated;
|
||||||
|
|
||||||
|
private delegate uint UpdateResourceStatePrototype(ResourceHandle* handle, byte offFileThread);
|
||||||
|
|
||||||
|
[Signature(Sigs.UpdateResourceState, DetourName = nameof(UpdateResourceStateDetour))]
|
||||||
|
private readonly Hook<UpdateResourceStatePrototype> _updateResourceStateHook = null!;
|
||||||
|
|
||||||
|
private uint UpdateResourceStateDetour(ResourceHandle* handle, byte offFileThread)
|
||||||
|
{
|
||||||
|
var previousState = (handle->UnkState, handle->LoadState);
|
||||||
|
var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty;
|
||||||
|
ResourceStateUpdating?.Invoke(handle, syncOriginal);
|
||||||
|
var ret = _updateResourceStateHook.OriginalDisposeSafe(handle, offFileThread);
|
||||||
|
ResourceStateUpdated?.Invoke(handle, syncOriginal, previousState, ref ret);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region IncRef
|
#region IncRef
|
||||||
|
|
||||||
/// <summary> Invoked before a resource handle reference count is incremented. </summary>
|
/// <summary> Invoked before a resource handle reference count is incremented. </summary>
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ public sealed unsafe class ResourceHandleDestructor : EventWrapperPtr<ResourceHa
|
||||||
/// <seealso cref="PathResolving.SubfileHelper"/>
|
/// <seealso cref="PathResolving.SubfileHelper"/>
|
||||||
SubfileHelper,
|
SubfileHelper,
|
||||||
|
|
||||||
/// <seealso cref="ShaderReplacementFixer"/>
|
/// <seealso cref="PostProcessing.ShaderReplacementFixer"/>
|
||||||
ShaderReplacementFixer,
|
ShaderReplacementFixer,
|
||||||
|
|
||||||
|
/// <seealso cref="ResourceLoading.ResourceLoader"/>
|
||||||
|
ResourceLoader,
|
||||||
|
|
||||||
/// <seealso cref="ResourceWatcher.OnResourceDestroyed"/>
|
/// <seealso cref="ResourceWatcher.OnResourceDestroyed"/>
|
||||||
ResourceWatcher,
|
ResourceWatcher,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Penumbra.Api.Enums;
|
||||||
using Penumbra.Interop.Hooks.ResourceLoading;
|
using Penumbra.Interop.Hooks.ResourceLoading;
|
||||||
using Penumbra.Interop.Structs;
|
using Penumbra.Interop.Structs;
|
||||||
using Penumbra.String;
|
using Penumbra.String;
|
||||||
|
using Penumbra.String.Classes;
|
||||||
|
|
||||||
namespace Penumbra.Interop.Processing;
|
namespace Penumbra.Interop.Processing;
|
||||||
|
|
||||||
|
|
@ -20,20 +21,20 @@ public unsafe class FilePostProcessService : IRequiredService, IDisposable
|
||||||
|
|
||||||
public FilePostProcessService(ResourceLoader resourceLoader, ServiceManager services)
|
public FilePostProcessService(ResourceLoader resourceLoader, ServiceManager services)
|
||||||
{
|
{
|
||||||
_resourceLoader = resourceLoader;
|
_resourceLoader = resourceLoader;
|
||||||
_processors = services.GetServicesImplementing<IFilePostProcessor>().ToFrozenDictionary(s => s.Type, s => s);
|
_processors = services.GetServicesImplementing<IFilePostProcessor>().ToFrozenDictionary(s => s.Type, s => s);
|
||||||
_resourceLoader.FileLoaded += OnFileLoaded;
|
_resourceLoader.BeforeResourceComplete += OnBeforeResourceComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_resourceLoader.FileLoaded -= OnFileLoaded;
|
_resourceLoader.BeforeResourceComplete -= OnBeforeResourceComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFileLoaded(ResourceHandle* resource, CiByteString path, bool returnValue, bool custom,
|
private void OnBeforeResourceComplete(ResourceHandle* resource, CiByteString path, Utf8GamePath original,
|
||||||
ReadOnlySpan<byte> additionalData)
|
ReadOnlySpan<byte> additionalData, bool isAsync)
|
||||||
{
|
{
|
||||||
if (_processors.TryGetValue(resource->FileType, out var processor))
|
if (_processors.TryGetValue(resource->FileType, out var processor))
|
||||||
processor.PostProcess(resource, path, additionalData);
|
processor.PostProcess(resource, original.Path, additionalData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,20 @@ public unsafe struct TextureResourceHandle
|
||||||
|
|
||||||
public enum LoadState : byte
|
public enum LoadState : byte
|
||||||
{
|
{
|
||||||
|
Constructing = 0x00,
|
||||||
|
Constructed = 0x01,
|
||||||
|
Async2 = 0x02,
|
||||||
|
AsyncRequested = 0x03,
|
||||||
|
Async4 = 0x04,
|
||||||
|
AsyncLoading = 0x05,
|
||||||
|
Async6 = 0x06,
|
||||||
Success = 0x07,
|
Success = 0x07,
|
||||||
Async = 0x03,
|
Unknown8 = 0x08,
|
||||||
Failure = 0x09,
|
Failure = 0x09,
|
||||||
FailedSubResource = 0x0A,
|
FailedSubResource = 0x0A,
|
||||||
|
FailureB = 0x0B,
|
||||||
|
FailureC = 0x0C,
|
||||||
|
FailureD = 0x0D,
|
||||||
None = 0xFF,
|
None = 0xFF,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +84,9 @@ public unsafe struct ResourceHandle
|
||||||
[FieldOffset(0x58)]
|
[FieldOffset(0x58)]
|
||||||
public int FileNameLength;
|
public int FileNameLength;
|
||||||
|
|
||||||
|
[FieldOffset(0xA8)]
|
||||||
|
public byte UnkState;
|
||||||
|
|
||||||
[FieldOffset(0xA9)]
|
[FieldOffset(0xA9)]
|
||||||
public LoadState LoadState;
|
public LoadState LoadState;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ namespace Penumbra.UI.ResourceWatcher;
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum RecordType : byte
|
public enum RecordType : byte
|
||||||
{
|
{
|
||||||
Request = 0x01,
|
Request = 0x01,
|
||||||
ResourceLoad = 0x02,
|
ResourceLoad = 0x02,
|
||||||
FileLoad = 0x04,
|
FileLoad = 0x04,
|
||||||
Destruction = 0x08,
|
Destruction = 0x08,
|
||||||
|
ResourceComplete = 0x10,
|
||||||
}
|
}
|
||||||
|
|
||||||
internal unsafe struct Record
|
internal unsafe struct Record
|
||||||
|
|
@ -141,4 +142,37 @@ internal unsafe struct Record
|
||||||
LoadState = handle->LoadState,
|
LoadState = handle->LoadState,
|
||||||
Crc64 = 0,
|
Crc64 = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static Record CreateResourceComplete(CiByteString path, ResourceHandle* handle, Utf8GamePath originalPath, ReadOnlySpan<byte> additionalData)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Time = DateTime.UtcNow,
|
||||||
|
Path = CombinedPath(path, additionalData),
|
||||||
|
OriginalPath = originalPath.Path.IsOwned ? originalPath.Path : originalPath.Path.Clone(),
|
||||||
|
Collection = null,
|
||||||
|
Handle = handle,
|
||||||
|
ResourceType = handle->FileType.ToFlag(),
|
||||||
|
Category = handle->Category.ToFlag(),
|
||||||
|
RefCount = handle->RefCount,
|
||||||
|
RecordType = RecordType.ResourceComplete,
|
||||||
|
Synchronously = false,
|
||||||
|
ReturnValue = OptionalBool.Null,
|
||||||
|
CustomLoad = OptionalBool.Null,
|
||||||
|
AssociatedGameObject = string.Empty,
|
||||||
|
LoadState = handle->LoadState,
|
||||||
|
Crc64 = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CiByteString CombinedPath(CiByteString path, ReadOnlySpan<byte> additionalData)
|
||||||
|
{
|
||||||
|
if (additionalData.Length is 0)
|
||||||
|
return path.IsOwned ? path : path.Clone();
|
||||||
|
|
||||||
|
fixed (byte* ptr = additionalData)
|
||||||
|
{
|
||||||
|
// If a path has additional data and is split, it is always in the form of |{additionalData}|{path},
|
||||||
|
// so we can just read from the start of additional data - 1 and sum their length +2 for the pipes.
|
||||||
|
return new CiByteString(new ReadOnlySpan<byte>(ptr - 1, additionalData.Length + 2 + path.Length)).Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,10 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService
|
||||||
_table = new ResourceWatcherTable(config.Ephemeral, _records);
|
_table = new ResourceWatcherTable(config.Ephemeral, _records);
|
||||||
_resources.ResourceRequested += OnResourceRequested;
|
_resources.ResourceRequested += OnResourceRequested;
|
||||||
_destructor.Subscribe(OnResourceDestroyed, ResourceHandleDestructor.Priority.ResourceWatcher);
|
_destructor.Subscribe(OnResourceDestroyed, ResourceHandleDestructor.Priority.ResourceWatcher);
|
||||||
_loader.ResourceLoaded += OnResourceLoaded;
|
_loader.ResourceLoaded += OnResourceLoaded;
|
||||||
_loader.FileLoaded += OnFileLoaded;
|
_loader.ResourceComplete += OnResourceComplete;
|
||||||
_loader.PapRequested += OnPapRequested;
|
_loader.FileLoaded += OnFileLoaded;
|
||||||
|
_loader.PapRequested += OnPapRequested;
|
||||||
UpdateFilter(_ephemeral.ResourceLoggingFilter, false);
|
UpdateFilter(_ephemeral.ResourceLoggingFilter, false);
|
||||||
_newMaxEntries = _config.MaxResourceWatcherRecords;
|
_newMaxEntries = _config.MaxResourceWatcherRecords;
|
||||||
}
|
}
|
||||||
|
|
@ -73,9 +74,10 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService
|
||||||
_records.TrimExcess();
|
_records.TrimExcess();
|
||||||
_resources.ResourceRequested -= OnResourceRequested;
|
_resources.ResourceRequested -= OnResourceRequested;
|
||||||
_destructor.Unsubscribe(OnResourceDestroyed);
|
_destructor.Unsubscribe(OnResourceDestroyed);
|
||||||
_loader.ResourceLoaded -= OnResourceLoaded;
|
_loader.ResourceLoaded -= OnResourceLoaded;
|
||||||
_loader.FileLoaded -= OnFileLoaded;
|
_loader.ResourceComplete -= OnResourceComplete;
|
||||||
_loader.PapRequested -= OnPapRequested;
|
_loader.FileLoaded -= OnFileLoaded;
|
||||||
|
_loader.PapRequested -= OnPapRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Clear()
|
private void Clear()
|
||||||
|
|
@ -255,6 +257,23 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService
|
||||||
_newRecords.Enqueue(record);
|
_newRecords.Enqueue(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private unsafe void OnResourceComplete(ResourceHandle* resource, CiByteString path, Utf8GamePath original, ReadOnlySpan<byte> additionalData, bool isAsync)
|
||||||
|
{
|
||||||
|
if (!isAsync)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match))
|
||||||
|
Penumbra.Log.Information(
|
||||||
|
$"[ResourceLoader] [DONE] [{resource->FileType}] Finished loading {match} into 0x{(ulong)resource:X}, state {resource->LoadState}.");
|
||||||
|
|
||||||
|
if (!_ephemeral.EnableResourceWatcher)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var record = Record.CreateResourceComplete(path, resource, original, additionalData);
|
||||||
|
if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record))
|
||||||
|
_newRecords.Enqueue(record);
|
||||||
|
}
|
||||||
|
|
||||||
private unsafe void OnFileLoaded(ResourceHandle* resource, CiByteString path, bool success, bool custom, ReadOnlySpan<byte> _)
|
private unsafe void OnFileLoaded(ResourceHandle* resource, CiByteString path, bool success, bool custom, ReadOnlySpan<byte> _)
|
||||||
{
|
{
|
||||||
if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match))
|
if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match))
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,12 @@ internal sealed class ResourceWatcherTable : Table<Record>
|
||||||
{
|
{
|
||||||
ImGui.TextUnformatted(item.RecordType switch
|
ImGui.TextUnformatted(item.RecordType switch
|
||||||
{
|
{
|
||||||
RecordType.Request => "REQ",
|
RecordType.Request => "REQ",
|
||||||
RecordType.ResourceLoad => "LOAD",
|
RecordType.ResourceLoad => "LOAD",
|
||||||
RecordType.FileLoad => "FILE",
|
RecordType.FileLoad => "FILE",
|
||||||
RecordType.Destruction => "DEST",
|
RecordType.Destruction => "DEST",
|
||||||
_ => string.Empty,
|
RecordType.ResourceComplete => "DONE",
|
||||||
|
_ => string.Empty,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -317,10 +318,10 @@ internal sealed class ResourceWatcherTable : Table<Record>
|
||||||
{
|
{
|
||||||
LoadState.None => FilterValue.HasFlag(LoadStateFlag.None),
|
LoadState.None => FilterValue.HasFlag(LoadStateFlag.None),
|
||||||
LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Success),
|
LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Success),
|
||||||
LoadState.Async => FilterValue.HasFlag(LoadStateFlag.Async),
|
|
||||||
LoadState.Failure => FilterValue.HasFlag(LoadStateFlag.Failed),
|
|
||||||
LoadState.FailedSubResource => FilterValue.HasFlag(LoadStateFlag.FailedSub),
|
LoadState.FailedSubResource => FilterValue.HasFlag(LoadStateFlag.FailedSub),
|
||||||
_ => FilterValue.HasFlag(LoadStateFlag.Unknown),
|
<= LoadState.Constructed => FilterValue.HasFlag(LoadStateFlag.Unknown),
|
||||||
|
< LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Async),
|
||||||
|
> LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Failed),
|
||||||
};
|
};
|
||||||
|
|
||||||
public override void DrawColumn(Record item, int _)
|
public override void DrawColumn(Record item, int _)
|
||||||
|
|
@ -332,12 +333,12 @@ internal sealed class ResourceWatcherTable : Table<Record>
|
||||||
{
|
{
|
||||||
LoadState.Success => (FontAwesomeIcon.CheckCircle, ColorId.IncreasedMetaValue.Value(),
|
LoadState.Success => (FontAwesomeIcon.CheckCircle, ColorId.IncreasedMetaValue.Value(),
|
||||||
$"Successfully loaded ({(byte)item.LoadState})."),
|
$"Successfully loaded ({(byte)item.LoadState})."),
|
||||||
LoadState.Async => (FontAwesomeIcon.Clock, ColorId.FolderLine.Value(), $"Loading asynchronously ({(byte)item.LoadState})."),
|
|
||||||
LoadState.Failure => (FontAwesomeIcon.Times, ColorId.DecreasedMetaValue.Value(),
|
|
||||||
$"Failed to load ({(byte)item.LoadState})."),
|
|
||||||
LoadState.FailedSubResource => (FontAwesomeIcon.ExclamationCircle, ColorId.DecreasedMetaValue.Value(),
|
LoadState.FailedSubResource => (FontAwesomeIcon.ExclamationCircle, ColorId.DecreasedMetaValue.Value(),
|
||||||
$"Dependencies failed to load ({(byte)item.LoadState})."),
|
$"Dependencies failed to load ({(byte)item.LoadState})."),
|
||||||
_ => (FontAwesomeIcon.QuestionCircle, ColorId.UndefinedMod.Value(), $"Unknown state ({(byte)item.LoadState})."),
|
<= LoadState.Constructed => (FontAwesomeIcon.QuestionCircle, ColorId.UndefinedMod.Value(), $"Not yet loaded ({(byte)item.LoadState})."),
|
||||||
|
< LoadState.Success => (FontAwesomeIcon.Clock, ColorId.FolderLine.Value(), $"Loading asynchronously ({(byte)item.LoadState})."),
|
||||||
|
> LoadState.Success => (FontAwesomeIcon.Times, ColorId.DecreasedMetaValue.Value(),
|
||||||
|
$"Failed to load ({(byte)item.LoadState})."),
|
||||||
};
|
};
|
||||||
using (var font = ImRaii.PushFont(UiBuilder.IconFont))
|
using (var font = ImRaii.PushFont(UiBuilder.IconFont))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ public class DebugTab : Window, ITab, IUiService
|
||||||
private readonly StainService _stains;
|
private readonly StainService _stains;
|
||||||
private readonly GlobalVariablesDrawer _globalVariablesDrawer;
|
private readonly GlobalVariablesDrawer _globalVariablesDrawer;
|
||||||
private readonly ResourceManagerService _resourceManager;
|
private readonly ResourceManagerService _resourceManager;
|
||||||
|
private readonly ResourceLoader _resourceLoader;
|
||||||
private readonly CollectionResolver _collectionResolver;
|
private readonly CollectionResolver _collectionResolver;
|
||||||
private readonly DrawObjectState _drawObjectState;
|
private readonly DrawObjectState _drawObjectState;
|
||||||
private readonly PathState _pathState;
|
private readonly PathState _pathState;
|
||||||
|
|
@ -109,7 +110,7 @@ public class DebugTab : Window, ITab, IUiService
|
||||||
public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, ObjectManager objects,
|
public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, ObjectManager objects,
|
||||||
IClientState clientState, IDataManager dataManager,
|
IClientState clientState, IDataManager dataManager,
|
||||||
ValidityChecker validityChecker, ModManager modManager, HttpApi httpApi, ActorManager actors, StainService stains,
|
ValidityChecker validityChecker, ModManager modManager, HttpApi httpApi, ActorManager actors, StainService stains,
|
||||||
ResourceManagerService resourceManager, CollectionResolver collectionResolver,
|
ResourceManagerService resourceManager, ResourceLoader resourceLoader, CollectionResolver collectionResolver,
|
||||||
DrawObjectState drawObjectState, PathState pathState, SubfileHelper subfileHelper, IdentifiedCollectionCache identifiedCollectionCache,
|
DrawObjectState drawObjectState, PathState pathState, SubfileHelper subfileHelper, IdentifiedCollectionCache identifiedCollectionCache,
|
||||||
CutsceneService cutsceneService, ModImportManager modImporter, ImportPopup importPopup, FrameworkManager framework,
|
CutsceneService cutsceneService, ModImportManager modImporter, ImportPopup importPopup, FrameworkManager framework,
|
||||||
TextureManager textureManager, ShaderReplacementFixer shaderReplacementFixer, RedrawService redraws, DictEmote emotes,
|
TextureManager textureManager, ShaderReplacementFixer shaderReplacementFixer, RedrawService redraws, DictEmote emotes,
|
||||||
|
|
@ -133,6 +134,7 @@ public class DebugTab : Window, ITab, IUiService
|
||||||
_actors = actors;
|
_actors = actors;
|
||||||
_stains = stains;
|
_stains = stains;
|
||||||
_resourceManager = resourceManager;
|
_resourceManager = resourceManager;
|
||||||
|
_resourceLoader = resourceLoader;
|
||||||
_collectionResolver = collectionResolver;
|
_collectionResolver = collectionResolver;
|
||||||
_drawObjectState = drawObjectState;
|
_drawObjectState = drawObjectState;
|
||||||
_pathState = pathState;
|
_pathState = pathState;
|
||||||
|
|
@ -191,6 +193,7 @@ public class DebugTab : Window, ITab, IUiService
|
||||||
DrawShaderReplacementFixer();
|
DrawShaderReplacementFixer();
|
||||||
DrawData();
|
DrawData();
|
||||||
DrawCrcCache();
|
DrawCrcCache();
|
||||||
|
DrawResourceLoader();
|
||||||
DrawResourceProblems();
|
DrawResourceProblems();
|
||||||
_renderTargetDrawer.Draw();
|
_renderTargetDrawer.Draw();
|
||||||
_hookOverrides.Draw();
|
_hookOverrides.Draw();
|
||||||
|
|
@ -1099,6 +1102,35 @@ public class DebugTab : Window, ITab, IUiService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private unsafe void DrawResourceLoader()
|
||||||
|
{
|
||||||
|
if (!ImUtf8.CollapsingHeader("Resource Loader"u8))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ongoingLoads = _resourceLoader.OngoingLoads;
|
||||||
|
var ongoingLoadCount = ongoingLoads.Count;
|
||||||
|
ImUtf8.Text($"Ongoing Loads: {ongoingLoadCount}");
|
||||||
|
|
||||||
|
if (ongoingLoadCount == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var table = ImUtf8.Table("ongoingLoadTable"u8, 3);
|
||||||
|
if (!table)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ImUtf8.TableSetupColumn("Resource Handle"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f);
|
||||||
|
ImUtf8.TableSetupColumn("Actual Path"u8, ImGuiTableColumnFlags.WidthStretch, 0.4f);
|
||||||
|
ImUtf8.TableSetupColumn("Original Path"u8, ImGuiTableColumnFlags.WidthStretch, 0.4f);
|
||||||
|
ImGui.TableHeadersRow();
|
||||||
|
|
||||||
|
foreach (var (handle, original) in ongoingLoads)
|
||||||
|
{
|
||||||
|
ImUtf8.DrawTableColumn($"0x{handle:X}");
|
||||||
|
ImUtf8.DrawTableColumn(((ResourceHandle*)handle)->CsHandle.FileName);
|
||||||
|
ImUtf8.DrawTableColumn(original.Path.Span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary> Draw resources with unusual reference count. </summary>
|
/// <summary> Draw resources with unusual reference count. </summary>
|
||||||
private unsafe void DrawResourceProblems()
|
private unsafe void DrawResourceProblems()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue