From fd163f8f66b1bc6edaa9a0cb2a431dcb94eef29d Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sat, 4 Nov 2023 18:30:36 +0100 Subject: [PATCH] ResourceTree: WIP - Path resolution --- Penumbra.GameData | 2 +- Penumbra/Collections/Cache/EstCache.cs | 21 ++ Penumbra/Collections/Cache/MetaCache.cs | 18 ++ Penumbra/Interop/PathResolving/PathState.cs | 44 +++- .../Interop/PathResolving/ResolvePathHooks.cs | 6 +- .../ResolveContext.PathResolution.cs | 248 ++++++++++++++++++ .../Interop/ResourceTree/ResolveContext.cs | 105 +++----- Penumbra/Interop/ResourceTree/ResourceTree.cs | 88 +++---- .../ResourceTree/ResourceTreeFactory.cs | 33 ++- .../Structs/ModelResourceHandleUtility.cs | 18 ++ Penumbra/Meta/Files/ImcFile.cs | 7 + Penumbra/Penumbra.cs | 1 + Penumbra/Services/ServiceManager.cs | 3 +- 13 files changed, 452 insertions(+), 142 deletions(-) create mode 100644 Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs create mode 100644 Penumbra/Interop/Structs/ModelResourceHandleUtility.cs diff --git a/Penumbra.GameData b/Penumbra.GameData index b141301c..1f274b41 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit b141301c4ee65422d6802f3038c8f344911d4ae2 +Subproject commit 1f274b41e3e703712deb83f3abd8727e10614ebe diff --git a/Penumbra/Collections/Cache/EstCache.cs b/Penumbra/Collections/Cache/EstCache.cs index 43ebcf56..9e2cdef9 100644 --- a/Penumbra/Collections/Cache/EstCache.cs +++ b/Penumbra/Collections/Cache/EstCache.cs @@ -1,5 +1,6 @@ using OtterGui.Filesystem; using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; using Penumbra.Interop.Services; using Penumbra.Interop.Structs; using Penumbra.Meta; @@ -61,6 +62,26 @@ public struct EstCache : IDisposable return manager.TemporarilySetFile(file, idx); } + private readonly EstFile? GetEstFile(EstManipulation.EstType type) + { + return type switch + { + EstManipulation.EstType.Face => _estFaceFile, + EstManipulation.EstType.Hair => _estHairFile, + EstManipulation.EstType.Body => _estBodyFile, + EstManipulation.EstType.Head => _estHeadFile, + _ => null, + }; + } + + internal ushort GetEstEntry(MetaFileManager manager, EstManipulation.EstType type, GenderRace genderRace, SetId setId) + { + var file = GetEstFile(type); + return file != null + ? file[genderRace, setId.Id] + : EstFile.GetDefault(manager, type, genderRace, setId); + } + public void Reset() { _estFaceFile?.Reset(); diff --git a/Penumbra/Collections/Cache/MetaCache.cs b/Penumbra/Collections/Cache/MetaCache.cs index 8eb7a5a0..0da11022 100644 --- a/Penumbra/Collections/Cache/MetaCache.cs +++ b/Penumbra/Collections/Cache/MetaCache.cs @@ -1,4 +1,5 @@ using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; using Penumbra.Interop.Services; using Penumbra.Interop.Structs; using Penumbra.Meta; @@ -186,6 +187,23 @@ public class MetaCache : IDisposable, IEnumerable _imcCache.GetImcFile(path, out file); + public ImcEntry GetImcEntry(Utf8GamePath path, EquipSlot slot, Variant variantIdx, out bool exists) + => GetImcFile(path, out var file) + ? file.GetEntry(Meta.Files.ImcFile.PartIndex(slot), variantIdx, out exists) + : Meta.Files.ImcFile.GetDefault(_manager, path, slot, variantIdx, out exists); + + internal EqdpEntry GetEqdpEntry(GenderRace race, bool accessory, SetId setId) + { + var eqdpFile = _eqdpCache.EqdpFile(race, accessory); + if (eqdpFile != null) + return setId.Id < eqdpFile.Count ? eqdpFile[setId] : default; + else + return Meta.Files.ExpandedEqdpFile.GetDefault(_manager, race, accessory, setId); + } + + internal ushort GetEstEntry(EstManipulation.EstType type, GenderRace genderRace, SetId setId) + => _estCache.GetEstEntry(_manager, type, genderRace, setId); + /// Use this when CharacterUtility becomes ready. private void ApplyStoredManipulations() { diff --git a/Penumbra/Interop/PathResolving/PathState.cs b/Penumbra/Interop/PathResolving/PathState.cs index f300a666..6d7840d8 100644 --- a/Penumbra/Interop/PathResolving/PathState.cs +++ b/Penumbra/Interop/PathResolving/PathState.cs @@ -30,11 +30,15 @@ public unsafe class PathState : IDisposable private readonly ResolvePathHooks _demiHuman; private readonly ResolvePathHooks _monster; - private readonly ThreadLocal _resolveData = new(() => ResolveData.Invalid, true); + private readonly ThreadLocal _resolveData = new(() => ResolveData.Invalid, true); + private readonly ThreadLocal _internalResolve = new(() => 0, false); public IList CurrentData => _resolveData.Values; + public bool InInternalResolve + => _internalResolve.Value != 0u; + public PathState(CollectionResolver collectionResolver, MetaState metaState, CharacterUtility characterUtility, IGameInteropProvider interop) { interop.InitializeFromAttributes(this); @@ -55,6 +59,7 @@ public unsafe class PathState : IDisposable public void Dispose() { _resolveData.Dispose(); + _internalResolve.Dispose(); _human.Dispose(); _weapon.Dispose(); _demiHuman.Dispose(); @@ -80,7 +85,10 @@ public unsafe class PathState : IDisposable if (path == nint.Zero) return path; - _resolveData.Value = collection.ToResolveData(gameObject); + if (!InInternalResolve) + { + _resolveData.Value = collection.ToResolveData(gameObject); + } return path; } @@ -90,7 +98,37 @@ public unsafe class PathState : IDisposable if (path == nint.Zero) return path; - _resolveData.Value = data; + if (!InInternalResolve) + { + _resolveData.Value = data; + } return path; } + + /// + /// Temporarily disables metadata mod application and resolve data capture on the current thread. + /// Must be called to prevent race conditions between Penumbra's internal path resolution (for example for Resource Trees) and the game's path resolution. + /// Please note that this will make path resolution cases that depend on metadata incorrect. + /// + /// A struct that will undo this operation when disposed. Best used with: using (var _ = pathState.EnterInternalResolve()) { ... } + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public InternalResolveRaii EnterInternalResolve() + => new(this); + + public readonly ref struct InternalResolveRaii + { + private readonly ThreadLocal _internalResolve; + + public InternalResolveRaii(PathState parent) + { + _internalResolve = parent._internalResolve; + ++_internalResolve.Value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public readonly void Dispose() + { + --_internalResolve.Value; + } + } } diff --git a/Penumbra/Interop/PathResolving/ResolvePathHooks.cs b/Penumbra/Interop/PathResolving/ResolvePathHooks.cs index 9d010d64..3be7ffdd 100644 --- a/Penumbra/Interop/PathResolving/ResolvePathHooks.cs +++ b/Penumbra/Interop/PathResolving/ResolvePathHooks.cs @@ -143,7 +143,7 @@ public unsafe class ResolvePathHooks : IDisposable private nint ResolveMdlHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) { var data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); - using var eqdp = slotIndex > 9 + using var eqdp = slotIndex > 9 || _parent.InInternalResolve ? DisposableContainer.Empty : _parent.MetaState.ResolveEqdpData(data.ModCollection, MetaState.GetHumanGenderRace(drawObject), slotIndex < 5, slotIndex > 4); return ResolvePath(data, _resolveMdlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex)); @@ -176,6 +176,10 @@ public unsafe class ResolvePathHooks : IDisposable private DisposableContainer GetEstChanges(nint drawObject, out ResolveData data) { data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + if (_parent.InInternalResolve) + { + return DisposableContainer.Empty; + } return new DisposableContainer(data.ModCollection.TemporarilySetEstFile(_parent.CharacterUtility, EstManipulation.EstType.Face), data.ModCollection.TemporarilySetEstFile(_parent.CharacterUtility, EstManipulation.EstType.Body), data.ModCollection.TemporarilySetEstFile(_parent.CharacterUtility, EstManipulation.EstType.Hair), diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs new file mode 100644 index 00000000..bcc957df --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs @@ -0,0 +1,248 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String; +using Penumbra.String.Classes; +using static Penumbra.Interop.Structs.CharacterBaseUtility; +using ModelType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase.ModelType; + +namespace Penumbra.Interop.ResourceTree; + +internal partial record ResolveContext +{ + + private Utf8GamePath ResolveModelPath() + { + // Correctness: + // Resolving a model path through the game's code can use EQDP metadata for human equipment models. + return ModelType switch + { + ModelType.Human when SlotIndex < 10 => ResolveEquipmentModelPath(), + _ => ResolveModelPathNative(), + }; + } + + private Utf8GamePath ResolveEquipmentModelPath() + { + var path = SlotIndex < 5 + ? GamePaths.Equipment.Mdl.Path(Equipment.Set, ResolveModelRaceCode(), Slot) + : GamePaths.Accessory.Mdl.Path(Equipment.Set, ResolveModelRaceCode(), Slot); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe GenderRace ResolveModelRaceCode() + => ResolveEqdpRaceCode(Slot, Equipment.Set); + + private unsafe GenderRace ResolveEqdpRaceCode(EquipSlot slot, SetId setId) + { + var slotIndex = slot.ToIndex(); + if (slotIndex >= 10 || ModelType != ModelType.Human) + return GenderRace.MidlanderMale; + + var characterRaceCode = (GenderRace)((Human*)CharacterBase.Value)->RaceSexId; + if (characterRaceCode == GenderRace.MidlanderMale) + return GenderRace.MidlanderMale; + + var accessory = slotIndex >= 5; + if ((ushort)characterRaceCode % 10 != 1 && accessory) + return GenderRace.MidlanderMale; + + var metaCache = Global.Collection.MetaCache; + if (metaCache == null) + return GenderRace.MidlanderMale; + + var entry = metaCache.GetEqdpEntry(characterRaceCode, accessory, setId); + if (entry.ToBits(slot).Item2) + return characterRaceCode; + + var fallbackRaceCode = characterRaceCode.Fallback(); + if (fallbackRaceCode == GenderRace.MidlanderMale) + return GenderRace.MidlanderMale; + + entry = metaCache.GetEqdpEntry(fallbackRaceCode, accessory, setId); + if (entry.ToBits(slot).Item2) + return fallbackRaceCode; + + return GenderRace.MidlanderMale; + } + + private unsafe Utf8GamePath ResolveModelPathNative() + { + var path = ResolveMdlPath(CharacterBase, SlotIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveMaterialPath(Utf8GamePath modelPath, Utf8GamePath imcPath, byte* mtrlFileName) + { + // Safety: + // Resolving a material path through the game's code can dereference null pointers for equipment materials. + return ModelType switch + { + ModelType.Human when SlotIndex < 10 && mtrlFileName[8] != (byte)'b' => ResolveEquipmentMaterialPath(modelPath, imcPath, mtrlFileName), + ModelType.DemiHuman => ResolveEquipmentMaterialPath(modelPath, imcPath, mtrlFileName), + ModelType.Weapon => ResolveEquipmentMaterialPath(modelPath, imcPath, mtrlFileName), + _ => ResolveMaterialPathNative(mtrlFileName), + }; + } + + private unsafe Utf8GamePath ResolveEquipmentMaterialPath(Utf8GamePath modelPath, Utf8GamePath imcPath, byte* mtrlFileName) + { + var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); + var modelPathSpan = modelPath.Path.Span; + var baseDirectory = modelPathSpan[..modelPathSpan.IndexOf("/model/"u8)]; + + var variant = ResolveMaterialVariant(imcPath); + + Span pathBuffer = stackalloc byte[260]; + baseDirectory.CopyTo(pathBuffer); + "/material/v"u8.CopyTo(pathBuffer[baseDirectory.Length..]); + WriteZeroPaddedNumber(pathBuffer.Slice(baseDirectory.Length + 11, 4), variant); + pathBuffer[baseDirectory.Length + 15] = (byte)'/'; + fileName.CopyTo(pathBuffer[(baseDirectory.Length + 16)..]); + + return Utf8GamePath.FromSpan(pathBuffer[..(baseDirectory.Length + 16 + fileName.Length)], out var path) ? path.Clone() : Utf8GamePath.Empty; + } + + private byte ResolveMaterialVariant(Utf8GamePath imcPath) + { + var metaCache = Global.Collection.MetaCache; + if (metaCache == null) + return Equipment.Variant.Id; + + var entry = metaCache.GetImcEntry(imcPath, Slot, Equipment.Variant, out var exists); + if (!exists) + return Equipment.Variant.Id; + + return entry.MaterialId; + } + + private static void WriteZeroPaddedNumber(Span destination, ushort number) + { + for (var i = destination.Length; i-- > 0;) + { + destination[i] = (byte)('0' + number % 10); + number /= 10; + } + } + + private unsafe Utf8GamePath ResolveMaterialPathNative(byte* mtrlFileName) + { + ByteString? path; + try + { + path = ResolveMtrlPath(CharacterBase, SlotIndex, mtrlFileName); + } + catch (AccessViolationException) + { + Penumbra.Log.Error($"Access violation during attempt to resolve material path\nDraw object: {(nint)CharacterBase.Value:X} (of type {ModelType})\nSlot index: {SlotIndex}\nMaterial file name: {(nint)mtrlFileName:X} ({new string((sbyte*)mtrlFileName)})"); + return Utf8GamePath.Empty; + } + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolveSkeletonPath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a skeleton path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanSkeletonPath(partialSkeletonIndex), + _ => ResolveSkeletonPathNative(partialSkeletonIndex), + }; + } + + private unsafe Utf8GamePath ResolveHumanSkeletonPath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set == 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Skeleton.Sklb.Path(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe (GenderRace RaceCode, string Slot, SetId Set) ResolveHumanSkeletonData(uint partialSkeletonIndex) + { + var human = (Human*)CharacterBase.Value; + var characterRaceCode = (GenderRace)human->RaceSexId; + switch (partialSkeletonIndex) + { + case 0: + return (characterRaceCode, "base", 1); + case 1: + var faceId = human->FaceId; + var tribe = human->Customize[(int)CustomizeIndex.Tribe]; + var modelType = human->Customize[(int)CustomizeIndex.ModelType]; + if (faceId < 201) + { + faceId -= tribe switch + { + 0xB when modelType == 4 => 100, + 0xE | 0xF => 100, + _ => 0, + }; + } + return ResolveHumanExtraSkeletonData(characterRaceCode, EstManipulation.EstType.Face, faceId); + case 2: + return ResolveHumanExtraSkeletonData(characterRaceCode, EstManipulation.EstType.Hair, human->HairId); + case 3: + return ResolveHumanEquipmentSkeletonData(EquipSlot.Head, EstManipulation.EstType.Head); + case 4: + return ResolveHumanEquipmentSkeletonData(EquipSlot.Body, EstManipulation.EstType.Body); + default: + return (0, string.Empty, 0); + } + } + + private unsafe (GenderRace RaceCode, string Slot, SetId Set) ResolveHumanEquipmentSkeletonData(EquipSlot slot, EstManipulation.EstType type) + { + var human = (Human*)CharacterBase.Value; + var equipment = ((CharacterArmor*)&human->Head)[slot.ToIndex()]; + return ResolveHumanExtraSkeletonData(ResolveEqdpRaceCode(slot, equipment.Set), type, equipment.Set); + } + + private unsafe (GenderRace RaceCode, string Slot, SetId Set) ResolveHumanExtraSkeletonData(GenderRace raceCode, EstManipulation.EstType type, SetId set) + { + var metaCache = Global.Collection.MetaCache; + var skeletonSet = metaCache == null ? default : metaCache.GetEstEntry(type, raceCode, set); + return (raceCode, EstManipulation.ToName(type), skeletonSet); + } + + private unsafe Utf8GamePath ResolveSkeletonPathNative(uint partialSkeletonIndex) + { + var path = ResolveSklbPath(CharacterBase, partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolveSkeletonParameterPath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a skeleton parameter path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanSkeletonParameterPath(partialSkeletonIndex), + _ => ResolveSkeletonParameterPathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanSkeletonParameterPath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set == 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Skeleton.Skp.Path(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveSkeletonParameterPathNative(uint partialSkeletonIndex) + { + var path = ResolveSkpPath(CharacterBase, partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } +} diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.cs b/Penumbra/Interop/ResourceTree/ResolveContext.cs index d700131d..f34a6ae2 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.cs @@ -5,6 +5,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; using FFXIVClientStructs.Interop; using OtterGui; using Penumbra.Api.Enums; +using Penumbra.Collections; using Penumbra.GameData; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; @@ -12,23 +13,29 @@ using Penumbra.String; using Penumbra.String.Classes; using Penumbra.UI; using static Penumbra.Interop.Structs.CharacterBaseUtility; +using static Penumbra.Interop.Structs.ModelResourceHandleUtility; using static Penumbra.Interop.Structs.StructExtensions; +using ModelType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase.ModelType; namespace Penumbra.Interop.ResourceTree; -internal record GlobalResolveContext(IObjectIdentifier Identifier, TreeBuildCache TreeBuildCache, - int Skeleton, bool WithUiData) +internal record GlobalResolveContext(IObjectIdentifier Identifier, ModCollection Collection, TreeBuildCache TreeBuildCache, bool WithUiData) { public readonly Dictionary<(Utf8GamePath, nint), ResourceNode> Nodes = new(128); - public unsafe ResolveContext CreateContext(CharacterBase* characterBase, uint slotIndex, EquipSlot slot, CharacterArmor equipment) - => new(this, characterBase, slotIndex, slot, equipment); + public unsafe ResolveContext CreateContext(CharacterBase* characterBase, uint slotIndex = 0xFFFFFFFFu, + EquipSlot slot = EquipSlot.Unknown, CharacterArmor equipment = default, WeaponType weaponType = default) + => new(this, characterBase, slotIndex, slot, equipment, weaponType); } -internal record ResolveContext(GlobalResolveContext Global, Pointer CharacterBase, uint SlotIndex, EquipSlot Slot, CharacterArmor Equipment) +internal partial record ResolveContext(GlobalResolveContext Global, Pointer CharacterBase, uint SlotIndex, + EquipSlot Slot, CharacterArmor Equipment, WeaponType WeaponType) { private static readonly ByteString ShpkPrefix = ByteString.FromSpanUnsafe("shader/sm5/shpk"u8, true, true, true); + private unsafe ModelType ModelType + => CharacterBase.Value->GetModelType(); + private unsafe ResourceNode? CreateNodeFromShpk(ShaderPackageResourceHandle* resourceHandle, ByteString gamePath) { if (resourceHandle == null) @@ -46,35 +53,33 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer gamePath.Length - 3) return null; - if (gamePath[lastDirectorySeparator + 1] != (byte)'-' || gamePath[lastDirectorySeparator + 2] != (byte)'-') - { - Span prefixed = stackalloc byte[gamePath.Length + 2]; - gamePath.Span[..(lastDirectorySeparator + 1)].CopyTo(prefixed); - prefixed[lastDirectorySeparator + 1] = (byte)'-'; - prefixed[lastDirectorySeparator + 2] = (byte)'-'; - gamePath.Span[(lastDirectorySeparator + 1)..].CopyTo(prefixed[(lastDirectorySeparator + 3)..]); + Span prefixed = stackalloc byte[260]; + gamePath.Span[..(lastDirectorySeparator + 1)].CopyTo(prefixed); + prefixed[lastDirectorySeparator + 1] = (byte)'-'; + prefixed[lastDirectorySeparator + 2] = (byte)'-'; + gamePath.Span[(lastDirectorySeparator + 1)..].CopyTo(prefixed[(lastDirectorySeparator + 3)..]); - if (!Utf8GamePath.FromSpan(prefixed, out var tmp)) - return null; + if (!Utf8GamePath.FromSpan(prefixed[..(gamePath.Length + 2)], out var tmp)) + return null; - gamePath = tmp.Path.Clone(); - } + path = tmp.Clone(); } else { // Make sure the game path is owned, otherwise stale trees could cause crashes (access violations) or other memory safety issues. if (!gamePath.IsOwned) gamePath = gamePath.Clone(); - } - if (!Utf8GamePath.FromByteString(gamePath, out var path)) - return null; + if (!Utf8GamePath.FromByteString(gamePath, out path)) + return null; + } return GetOrCreateNode(ResourceType.Tex, (nint)resourceHandle->Texture, &resourceHandle->ResourceHandle, path); } @@ -143,23 +148,28 @@ internal record ResolveContext(GlobalResolveContext Global, PointerTexture, &tex->ResourceHandle, path); } - public unsafe ResourceNode? CreateNodeFromRenderModel(Model* mdl) + public unsafe ResourceNode? CreateNodeFromModel(Model* mdl, Utf8GamePath imcPath) { if (mdl == null || mdl->ModelResourceHandle == null) return null; + var mdlResource = mdl->ModelResourceHandle; if (!Utf8GamePath.FromByteString(ResolveMdlPath(CharacterBase, SlotIndex), out var path)) return null; - if (Global.Nodes.TryGetValue((path, (nint)mdl->ModelResourceHandle), out var cached)) + if (Global.Nodes.TryGetValue((path, (nint)mdlResource), out var cached)) return cached; - var node = CreateNode(ResourceType.Mdl, (nint)mdl, &mdl->ModelResourceHandle->ResourceHandle, path, false); + var node = CreateNode(ResourceType.Mdl, (nint)mdl, &mdlResource->ResourceHandle, path, false); for (var i = 0; i < mdl->MaterialCount; i++) { - var mtrl = mdl->Materials[i]; - var mtrlNode = CreateNodeFromMaterial(mtrl); + var mtrl = mdl->Materials[i]; + if (mtrl == null) + continue; + + var mtrlFileName = GetMaterialFileNameBySlot(mdlResource, (uint)i); + var mtrlNode = CreateNodeFromMaterial(mtrl, ResolveMaterialPath(path, imcPath, mtrlFileName)); if (mtrlNode != null) { if (Global.WithUiData) @@ -173,7 +183,7 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer alreadyVisitedSamplerIds) { @@ -200,8 +210,6 @@ internal record ResolveContext(GlobalResolveContext Global, PointerMaterialResourceHandle == null) return null; - var path = Utf8GamePath.Empty; // TODO - var resource = mtrl->MaterialResourceHandle; if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached)) return cached; @@ -265,8 +273,7 @@ internal record ResolveContext(GlobalResolveContext Global, PointerSkeletonResourceHandle == null) return null; - if (!Utf8GamePath.FromByteString(ResolveSklbPath(CharacterBase, partialSkeletonIndex), out var path)) - return null; + var path = ResolveSkeletonPath(partialSkeletonIndex); if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonResourceHandle), out var cached)) return cached; @@ -288,8 +295,7 @@ internal record ResolveContext(GlobalResolveContext Global, PointerSkeletonParameterResourceHandle == null) return null; - if (!Utf8GamePath.FromByteString(ResolveSkpPath(CharacterBase, partialSkeletonIndex), out var path)) - return null; + var path = ResolveSkeletonParameterPath(partialSkeletonIndex); if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonParameterResourceHandle), out var cached)) return cached; @@ -305,43 +311,6 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer FilterGamePaths(IReadOnlyCollection gamePaths) - { - var filtered = new List(gamePaths.Count); - foreach (var path in gamePaths) - { - // In doubt, keep the paths. - if (IsMatch(path.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries)) - ?? true) - filtered.Add(path); - } - - return filtered; - } - - private bool? IsMatch(ReadOnlySpan path) - => SafeGet(path, 0) switch - { - "chara" => SafeGet(path, 1) switch - { - "accessory" => IsMatchEquipment(path[2..], $"a{Equipment.Set.Id:D4}"), - "equipment" => IsMatchEquipment(path[2..], $"e{Equipment.Set.Id:D4}"), - "monster" => SafeGet(path, 2) == $"m{Global.Skeleton:D4}", - "weapon" => IsMatchEquipment(path[2..], $"w{Equipment.Set.Id:D4}"), - _ => null, - }, - _ => null, - }; - - private bool? IsMatchEquipment(ReadOnlySpan path, string equipmentDir) - => SafeGet(path, 0) == equipmentDir - ? SafeGet(path, 1) switch - { - "material" => SafeGet(path, 2) == $"v{Equipment.Variant.Id:D4}", - _ => null, - } - : false; - internal ResourceNode.UiData GuessModelUIData(Utf8GamePath gamePath) { var path = gamePath.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries); diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs index 7c58d6a8..5e96d8bf 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTree.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -6,6 +6,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; using Penumbra.GameData.Data; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; +using Penumbra.String.Classes; using Penumbra.UI; using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData; @@ -64,25 +65,13 @@ public class ResourceTree CustomizeData = character->DrawData.CustomizeData; RaceCode = human != null ? (GenderRace)human->RaceSexId : GenderRace.Unknown; - var genericContext = globalContext.CreateContext(model, 0xFFFFFFFFu, EquipSlot.Unknown, default); - - var eid = (ResourceHandle*)model->EID; - var eidNode = genericContext.CreateNodeFromEid(eid); - if (eidNode != null) - { - if (globalContext.WithUiData) - eidNode.FallbackName = "EID"; - Nodes.Add(eidNode); - } + var genericContext = globalContext.CreateContext(model); for (var i = 0; i < model->SlotCount; ++i) { - var slotContext = globalContext.CreateContext( - model, - (uint)i, - i < equipment.Length ? ((uint)i).ToEquipSlot() : EquipSlot.Unknown, - i < equipment.Length ? equipment[i] : default - ); + var slotContext = i < equipment.Length + ? globalContext.CreateContext(model, (uint)i, ((uint)i).ToEquipSlot(), equipment[i]) + : globalContext.CreateContext(model, (uint)i); var imc = (ResourceHandle*)model->IMCArray[i]; var imcNode = slotContext.CreateNodeFromImc(imc); @@ -94,7 +83,7 @@ public class ResourceTree } var mdl = model->Models[i]; - var mdlNode = slotContext.CreateNodeFromRenderModel(mdl); + var mdlNode = slotContext.CreateNodeFromModel(mdl, imcNode?.GamePath ?? Utf8GamePath.Empty); if (mdlNode != null) { if (globalContext.WithUiData) @@ -103,77 +92,68 @@ public class ResourceTree } } - AddSkeleton(Nodes, genericContext, model->Skeleton); + AddSkeleton(Nodes, genericContext, model->EID, model->Skeleton); - AddSubObjects(globalContext, model); + AddWeapons(globalContext, model); if (human != null) AddHumanResources(globalContext, human); } - private unsafe void AddSubObjects(GlobalResolveContext globalContext, CharacterBase* model) + private unsafe void AddWeapons(GlobalResolveContext globalContext, CharacterBase* model) { - var subObjectIndex = 0; - var weaponIndex = 0; - var subObjectNodes = new List(); + var weaponIndex = 0; + var weaponNodes = new List(); foreach (var baseSubObject in model->DrawObject.Object.ChildObjects) { if (baseSubObject->GetObjectType() != FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType.CharacterBase) continue; var subObject = (CharacterBase*)baseSubObject; - var weapon = subObject->GetModelType() == CharacterBase.ModelType.Weapon ? (Weapon*)subObject : null; - var subObjectNamePrefix = weapon != null ? "Weapon" : "Fashion Acc."; + if (subObject->GetModelType() != CharacterBase.ModelType.Weapon) + continue; + var weapon = (Weapon*)subObject; + // This way to tell apart MainHand and OffHand is not always accurate, but seems good enough for what we're doing with it. - var slot = weapon != null ? (weaponIndex > 0 ? EquipSlot.OffHand : EquipSlot.MainHand) : EquipSlot.Unknown; - var equipment = weapon != null ? new CharacterArmor(weapon->ModelSetId, (byte)weapon->Variant, (byte)weapon->ModelUnknown) : default; + var slot = weaponIndex > 0 ? EquipSlot.OffHand : EquipSlot.MainHand; + var equipment = new CharacterArmor(weapon->ModelSetId, (byte)weapon->Variant, (byte)weapon->ModelUnknown); + var weaponType = weapon->SecondaryId; - var genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment); - - var eid = (ResourceHandle*)subObject->EID; - var eidNode = genericContext.CreateNodeFromEid(eid); - if (eidNode != null) - { - if (globalContext.WithUiData) - eidNode.FallbackName = $"{subObjectNamePrefix} #{subObjectIndex}, EID"; - Nodes.Add(eidNode); - } + var genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment, weaponType); for (var i = 0; i < subObject->SlotCount; ++i) { - var slotContext = globalContext.CreateContext(subObject, (uint)i, slot, equipment); + var slotContext = globalContext.CreateContext(subObject, (uint)i, slot, equipment, weaponType); var imc = (ResourceHandle*)subObject->IMCArray[i]; var imcNode = slotContext.CreateNodeFromImc(imc); if (imcNode != null) { if (globalContext.WithUiData) - imcNode.FallbackName = $"{subObjectNamePrefix} #{subObjectIndex}, IMC #{i}"; - subObjectNodes.Add(imcNode); + imcNode.FallbackName = $"Weapon #{weaponIndex}, IMC #{i}"; + weaponNodes.Add(imcNode); } var mdl = subObject->Models[i]; - var mdlNode = slotContext.CreateNodeFromRenderModel(mdl); + var mdlNode = slotContext.CreateNodeFromModel(mdl, imcNode?.GamePath ?? Utf8GamePath.Empty); if (mdlNode != null) { if (globalContext.WithUiData) - mdlNode.FallbackName = $"{subObjectNamePrefix} #{subObjectIndex}, Model #{i}"; - subObjectNodes.Add(mdlNode); + mdlNode.FallbackName = $"Weapon #{weaponIndex}, Model #{i}"; + weaponNodes.Add(mdlNode); } } - AddSkeleton(subObjectNodes, genericContext, subObject->Skeleton, $"{subObjectNamePrefix} #{subObjectIndex}, "); + AddSkeleton(weaponNodes, genericContext, subObject->EID, subObject->Skeleton, $"Weapon #{weaponIndex}, "); - ++subObjectIndex; - if (weapon != null) - ++weaponIndex; + ++weaponIndex; } - Nodes.InsertRange(0, subObjectNodes); + Nodes.InsertRange(0, weaponNodes); } private unsafe void AddHumanResources(GlobalResolveContext globalContext, Human* human) { - var genericContext = globalContext.CreateContext(&human->CharacterBase, 0xFFFFFFFFu, EquipSlot.Unknown, default); + var genericContext = globalContext.CreateContext(&human->CharacterBase); var decalId = (byte)(human->Customize[(int)CustomizeIndex.Facepaint] & 0x7F); var decalPath = decalId != 0 @@ -208,8 +188,16 @@ public class ResourceTree } } - private unsafe void AddSkeleton(List nodes, ResolveContext context, Skeleton* skeleton, string prefix = "") + private unsafe void AddSkeleton(List nodes, ResolveContext context, void* eid, Skeleton* skeleton, string prefix = "") { + var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid); + if (eidNode != null) + { + if (context.Global.WithUiData) + eidNode.FallbackName = $"{prefix}EID"; + Nodes.Add(eidNode); + } + if (skeleton == null) return; diff --git a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs index 6353d5b5..0e3a92e2 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs @@ -1,5 +1,4 @@ using Dalamud.Plugin.Services; -using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Object; using Penumbra.Api.Enums; using Penumbra.Collections; @@ -18,9 +17,10 @@ public class ResourceTreeFactory private readonly IdentifierService _identifier; private readonly Configuration _config; private readonly ActorService _actors; + private readonly PathState _pathState; public ResourceTreeFactory(IDataManager gameData, IObjectTable objects, CollectionResolver resolver, IdentifierService identifier, - Configuration config, ActorService actors) + Configuration config, ActorService actors, PathState pathState) { _gameData = gameData; _objects = objects; @@ -28,6 +28,7 @@ public class ResourceTreeFactory _identifier = identifier; _config = config; _actors = actors; + _pathState = pathState; } private TreeBuildCache CreateTreeBuildCache() @@ -87,13 +88,17 @@ public class ResourceTreeFactory var networked = character.ObjectId != Dalamud.Game.ClientState.Objects.Types.GameObject.InvalidGameObjectId; var tree = new ResourceTree(name, character.ObjectIndex, (nint)gameObjStruct, (nint)drawObjStruct, localPlayerRelated, related, networked, collectionResolveData.ModCollection.Name); - var globalContext = new GlobalResolveContext(_identifier.AwaitedService, cache, - ((Character*)gameObjStruct)->CharacterData.ModelCharaId, (flags & Flags.WithUiData) != 0); - tree.LoadResources(globalContext); + var globalContext = new GlobalResolveContext(_identifier.AwaitedService, collectionResolveData.ModCollection, + cache, (flags & Flags.WithUiData) != 0); + using (var _ = _pathState.EnterInternalResolve()) + { + tree.LoadResources(globalContext); + } tree.FlatNodes.UnionWith(globalContext.Nodes.Values); tree.ProcessPostfix((node, _) => tree.FlatNodes.Add(node)); - ResolveGamePaths(tree, collectionResolveData.ModCollection); + // This is currently unneeded as we can resolve all paths by querying the draw object: + // ResolveGamePaths(tree, collectionResolveData.ModCollection); if (globalContext.WithUiData) ResolveUiData(tree); FilterFullPaths(tree, (flags & Flags.RedactExternalPaths) != 0 ? _config.ModDirectory : null); @@ -128,23 +133,15 @@ public class ResourceTreeFactory if (!reverseDictionary.TryGetValue(node.FullPath.ToPath(), out var resolvedSet)) continue; - IReadOnlyCollection resolvedList = resolvedSet; - if (resolvedList.Count > 1) - { - var filteredList = node.ResolveContext!.FilterGamePaths(resolvedList); - if (filteredList.Count > 0) - resolvedList = filteredList; - } - - if (resolvedList.Count != 1) + if (resolvedSet.Count != 1) { Penumbra.Log.Debug( - $"Found {resolvedList.Count} game paths while reverse-resolving {node.FullPath} in {collection.Name}:"); - foreach (var gamePath in resolvedList) + $"Found {resolvedSet.Count} game paths while reverse-resolving {node.FullPath} in {collection.Name}:"); + foreach (var gamePath in resolvedSet) Penumbra.Log.Debug($"Game path: {gamePath}"); } - node.PossibleGamePaths = resolvedList.ToArray(); + node.PossibleGamePaths = resolvedSet.ToArray(); } else if (node.FullPath.InternalName.IsEmpty && node.PossibleGamePaths.Length == 1) { diff --git a/Penumbra/Interop/Structs/ModelResourceHandleUtility.cs b/Penumbra/Interop/Structs/ModelResourceHandleUtility.cs new file mode 100644 index 00000000..008cd59a --- /dev/null +++ b/Penumbra/Interop/Structs/ModelResourceHandleUtility.cs @@ -0,0 +1,18 @@ +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; + +namespace Penumbra.Interop.Structs; + +// TODO submit this to ClientStructs +public class ModelResourceHandleUtility +{ + public ModelResourceHandleUtility(IGameInteropProvider interop) + => interop.InitializeFromAttributes(this); + + [Signature("E8 ?? ?? ?? ?? 44 8B CD 48 89 44 24")] + private static nint _getMaterialFileNameBySlot = nint.Zero; + + public static unsafe byte* GetMaterialFileNameBySlot(ModelResourceHandle* handle, uint slot) + => ((delegate* unmanaged)_getMaterialFileNameBySlot)(handle, slot); +} diff --git a/Penumbra/Meta/Files/ImcFile.cs b/Penumbra/Meta/Files/ImcFile.cs index 94bc2428..e3c31a42 100644 --- a/Penumbra/Meta/Files/ImcFile.cs +++ b/Penumbra/Meta/Files/ImcFile.cs @@ -65,6 +65,13 @@ public unsafe class ImcFile : MetaBaseFile return ptr == null ? new ImcEntry() : *ptr; } + public ImcEntry GetEntry(int partIdx, Variant variantIdx, out bool exists) + { + var ptr = VariantPtr(Data, partIdx, variantIdx); + exists = ptr != null; + return exists ? *ptr : new ImcEntry(); + } + public static int PartIndex(EquipSlot slot) => slot switch { diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index df470d63..d7daaf70 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -76,6 +76,7 @@ public class Penumbra : IDalamudPlugin _communicatorService = _services.GetRequiredService(); _services.GetRequiredService(); // Initialize because not required anywhere else. _services.GetRequiredService(); // Initialize because not required anywhere else. + _services.GetRequiredService(); // Initialize because not required anywhere else. _collectionManager.Caches.CreateNecessaryCaches(); using (var t = _services.GetRequiredService().Measure(StartTimeType.PathResolver)) { diff --git a/Penumbra/Services/ServiceManager.cs b/Penumbra/Services/ServiceManager.cs index 6a522ca2..2c4f385d 100644 --- a/Penumbra/Services/ServiceManager.cs +++ b/Penumbra/Services/ServiceManager.cs @@ -90,7 +90,8 @@ public static class ServiceManager .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); private static IServiceCollection AddConfiguration(this IServiceCollection services) => services.AddTransient()