ResourceTree: WIP - Path resolution

This commit is contained in:
Exter-N 2023-11-04 18:30:36 +01:00
parent 2852562a03
commit fd163f8f66
13 changed files with 452 additions and 142 deletions

@ -1 +1 @@
Subproject commit b141301c4ee65422d6802f3038c8f344911d4ae2 Subproject commit 1f274b41e3e703712deb83f3abd8727e10614ebe

View file

@ -1,5 +1,6 @@
using OtterGui.Filesystem; using OtterGui.Filesystem;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs;
using Penumbra.Interop.Services; using Penumbra.Interop.Services;
using Penumbra.Interop.Structs; using Penumbra.Interop.Structs;
using Penumbra.Meta; using Penumbra.Meta;
@ -61,6 +62,26 @@ public struct EstCache : IDisposable
return manager.TemporarilySetFile(file, idx); 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() public void Reset()
{ {
_estFaceFile?.Reset(); _estFaceFile?.Reset();

View file

@ -1,4 +1,5 @@
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs;
using Penumbra.Interop.Services; using Penumbra.Interop.Services;
using Penumbra.Interop.Structs; using Penumbra.Interop.Structs;
using Penumbra.Meta; using Penumbra.Meta;
@ -186,6 +187,23 @@ public class MetaCache : IDisposable, IEnumerable<KeyValuePair<MetaManipulation,
public bool GetImcFile(Utf8GamePath path, [NotNullWhen(true)] out Meta.Files.ImcFile? file) public bool GetImcFile(Utf8GamePath path, [NotNullWhen(true)] out Meta.Files.ImcFile? file)
=> _imcCache.GetImcFile(path, out file); => _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);
/// <summary> Use this when CharacterUtility becomes ready. </summary> /// <summary> Use this when CharacterUtility becomes ready. </summary>
private void ApplyStoredManipulations() private void ApplyStoredManipulations()
{ {

View file

@ -30,11 +30,15 @@ public unsafe class PathState : IDisposable
private readonly ResolvePathHooks _demiHuman; private readonly ResolvePathHooks _demiHuman;
private readonly ResolvePathHooks _monster; private readonly ResolvePathHooks _monster;
private readonly ThreadLocal<ResolveData> _resolveData = new(() => ResolveData.Invalid, true); private readonly ThreadLocal<ResolveData> _resolveData = new(() => ResolveData.Invalid, true);
private readonly ThreadLocal<uint> _internalResolve = new(() => 0, false);
public IList<ResolveData> CurrentData public IList<ResolveData> CurrentData
=> _resolveData.Values; => _resolveData.Values;
public bool InInternalResolve
=> _internalResolve.Value != 0u;
public PathState(CollectionResolver collectionResolver, MetaState metaState, CharacterUtility characterUtility, IGameInteropProvider interop) public PathState(CollectionResolver collectionResolver, MetaState metaState, CharacterUtility characterUtility, IGameInteropProvider interop)
{ {
interop.InitializeFromAttributes(this); interop.InitializeFromAttributes(this);
@ -55,6 +59,7 @@ public unsafe class PathState : IDisposable
public void Dispose() public void Dispose()
{ {
_resolveData.Dispose(); _resolveData.Dispose();
_internalResolve.Dispose();
_human.Dispose(); _human.Dispose();
_weapon.Dispose(); _weapon.Dispose();
_demiHuman.Dispose(); _demiHuman.Dispose();
@ -80,7 +85,10 @@ public unsafe class PathState : IDisposable
if (path == nint.Zero) if (path == nint.Zero)
return path; return path;
_resolveData.Value = collection.ToResolveData(gameObject); if (!InInternalResolve)
{
_resolveData.Value = collection.ToResolveData(gameObject);
}
return path; return path;
} }
@ -90,7 +98,37 @@ public unsafe class PathState : IDisposable
if (path == nint.Zero) if (path == nint.Zero)
return path; return path;
_resolveData.Value = data; if (!InInternalResolve)
{
_resolveData.Value = data;
}
return path; return path;
} }
/// <summary>
/// Temporarily disables metadata mod application and resolve data capture on the current thread. <para />
/// Must be called to prevent race conditions between Penumbra's internal path resolution (for example for Resource Trees) and the game's path resolution. <para />
/// Please note that this will make path resolution cases that depend on metadata incorrect.
/// </summary>
/// <returns> A struct that will undo this operation when disposed. Best used with: <code>using (var _ = pathState.EnterInternalResolve()) { ... }</code> </returns>
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public InternalResolveRaii EnterInternalResolve()
=> new(this);
public readonly ref struct InternalResolveRaii
{
private readonly ThreadLocal<uint> _internalResolve;
public InternalResolveRaii(PathState parent)
{
_internalResolve = parent._internalResolve;
++_internalResolve.Value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public readonly void Dispose()
{
--_internalResolve.Value;
}
}
} }

View file

@ -143,7 +143,7 @@ public unsafe class ResolvePathHooks : IDisposable
private nint ResolveMdlHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) private nint ResolveMdlHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex)
{ {
var data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); var data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true);
using var eqdp = slotIndex > 9 using var eqdp = slotIndex > 9 || _parent.InInternalResolve
? DisposableContainer.Empty ? DisposableContainer.Empty
: _parent.MetaState.ResolveEqdpData(data.ModCollection, MetaState.GetHumanGenderRace(drawObject), slotIndex < 5, slotIndex > 4); : _parent.MetaState.ResolveEqdpData(data.ModCollection, MetaState.GetHumanGenderRace(drawObject), slotIndex < 5, slotIndex > 4);
return ResolvePath(data, _resolveMdlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex)); 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) private DisposableContainer GetEstChanges(nint drawObject, out ResolveData data)
{ {
data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true);
if (_parent.InInternalResolve)
{
return DisposableContainer.Empty;
}
return new DisposableContainer(data.ModCollection.TemporarilySetEstFile(_parent.CharacterUtility, EstManipulation.EstType.Face), 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.Body),
data.ModCollection.TemporarilySetEstFile(_parent.CharacterUtility, EstManipulation.EstType.Hair), data.ModCollection.TemporarilySetEstFile(_parent.CharacterUtility, EstManipulation.EstType.Hair),

View file

@ -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<byte> 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<byte> 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;
}
}

View file

@ -5,6 +5,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
using FFXIVClientStructs.Interop; using FFXIVClientStructs.Interop;
using OtterGui; using OtterGui;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Collections;
using Penumbra.GameData; using Penumbra.GameData;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
@ -12,23 +13,29 @@ using Penumbra.String;
using Penumbra.String.Classes; using Penumbra.String.Classes;
using Penumbra.UI; using Penumbra.UI;
using static Penumbra.Interop.Structs.CharacterBaseUtility; using static Penumbra.Interop.Structs.CharacterBaseUtility;
using static Penumbra.Interop.Structs.ModelResourceHandleUtility;
using static Penumbra.Interop.Structs.StructExtensions; using static Penumbra.Interop.Structs.StructExtensions;
using ModelType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase.ModelType;
namespace Penumbra.Interop.ResourceTree; namespace Penumbra.Interop.ResourceTree;
internal record GlobalResolveContext(IObjectIdentifier Identifier, TreeBuildCache TreeBuildCache, internal record GlobalResolveContext(IObjectIdentifier Identifier, ModCollection Collection, TreeBuildCache TreeBuildCache, bool WithUiData)
int Skeleton, bool WithUiData)
{ {
public readonly Dictionary<(Utf8GamePath, nint), ResourceNode> Nodes = new(128); public readonly Dictionary<(Utf8GamePath, nint), ResourceNode> Nodes = new(128);
public unsafe ResolveContext CreateContext(CharacterBase* characterBase, uint slotIndex, EquipSlot slot, CharacterArmor equipment) public unsafe ResolveContext CreateContext(CharacterBase* characterBase, uint slotIndex = 0xFFFFFFFFu,
=> new(this, characterBase, slotIndex, slot, equipment); EquipSlot slot = EquipSlot.Unknown, CharacterArmor equipment = default, WeaponType weaponType = default)
=> new(this, characterBase, slotIndex, slot, equipment, weaponType);
} }
internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBase> CharacterBase, uint SlotIndex, EquipSlot Slot, CharacterArmor Equipment) internal partial record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBase> CharacterBase, uint SlotIndex,
EquipSlot Slot, CharacterArmor Equipment, WeaponType WeaponType)
{ {
private static readonly ByteString ShpkPrefix = ByteString.FromSpanUnsafe("shader/sm5/shpk"u8, true, true, true); 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) private unsafe ResourceNode? CreateNodeFromShpk(ShaderPackageResourceHandle* resourceHandle, ByteString gamePath)
{ {
if (resourceHandle == null) if (resourceHandle == null)
@ -46,35 +53,33 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
if (resourceHandle == null) if (resourceHandle == null)
return null; return null;
Utf8GamePath path;
if (dx11) if (dx11)
{ {
var lastDirectorySeparator = gamePath.LastIndexOf((byte)'/'); var lastDirectorySeparator = gamePath.LastIndexOf((byte)'/');
if (lastDirectorySeparator == -1 || lastDirectorySeparator > gamePath.Length - 3) if (lastDirectorySeparator == -1 || lastDirectorySeparator > gamePath.Length - 3)
return null; return null;
if (gamePath[lastDirectorySeparator + 1] != (byte)'-' || gamePath[lastDirectorySeparator + 2] != (byte)'-') Span<byte> prefixed = stackalloc byte[260];
{ gamePath.Span[..(lastDirectorySeparator + 1)].CopyTo(prefixed);
Span<byte> prefixed = stackalloc byte[gamePath.Length + 2]; prefixed[lastDirectorySeparator + 1] = (byte)'-';
gamePath.Span[..(lastDirectorySeparator + 1)].CopyTo(prefixed); prefixed[lastDirectorySeparator + 2] = (byte)'-';
prefixed[lastDirectorySeparator + 1] = (byte)'-'; gamePath.Span[(lastDirectorySeparator + 1)..].CopyTo(prefixed[(lastDirectorySeparator + 3)..]);
prefixed[lastDirectorySeparator + 2] = (byte)'-';
gamePath.Span[(lastDirectorySeparator + 1)..].CopyTo(prefixed[(lastDirectorySeparator + 3)..]);
if (!Utf8GamePath.FromSpan(prefixed, out var tmp)) if (!Utf8GamePath.FromSpan(prefixed[..(gamePath.Length + 2)], out var tmp))
return null; return null;
gamePath = tmp.Path.Clone(); path = tmp.Clone();
}
} }
else else
{ {
// Make sure the game path is owned, otherwise stale trees could cause crashes (access violations) or other memory safety issues. // Make sure the game path is owned, otherwise stale trees could cause crashes (access violations) or other memory safety issues.
if (!gamePath.IsOwned) if (!gamePath.IsOwned)
gamePath = gamePath.Clone(); gamePath = gamePath.Clone();
}
if (!Utf8GamePath.FromByteString(gamePath, out var path)) if (!Utf8GamePath.FromByteString(gamePath, out path))
return null; return null;
}
return GetOrCreateNode(ResourceType.Tex, (nint)resourceHandle->Texture, &resourceHandle->ResourceHandle, path); return GetOrCreateNode(ResourceType.Tex, (nint)resourceHandle->Texture, &resourceHandle->ResourceHandle, path);
} }
@ -143,23 +148,28 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, path); return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, path);
} }
public unsafe ResourceNode? CreateNodeFromRenderModel(Model* mdl) public unsafe ResourceNode? CreateNodeFromModel(Model* mdl, Utf8GamePath imcPath)
{ {
if (mdl == null || mdl->ModelResourceHandle == null) if (mdl == null || mdl->ModelResourceHandle == null)
return null; return null;
var mdlResource = mdl->ModelResourceHandle;
if (!Utf8GamePath.FromByteString(ResolveMdlPath(CharacterBase, SlotIndex), out var path)) if (!Utf8GamePath.FromByteString(ResolveMdlPath(CharacterBase, SlotIndex), out var path))
return null; 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; 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++) for (var i = 0; i < mdl->MaterialCount; i++)
{ {
var mtrl = mdl->Materials[i]; var mtrl = mdl->Materials[i];
var mtrlNode = CreateNodeFromMaterial(mtrl); if (mtrl == null)
continue;
var mtrlFileName = GetMaterialFileNameBySlot(mdlResource, (uint)i);
var mtrlNode = CreateNodeFromMaterial(mtrl, ResolveMaterialPath(path, imcPath, mtrlFileName));
if (mtrlNode != null) if (mtrlNode != null)
{ {
if (Global.WithUiData) if (Global.WithUiData)
@ -173,7 +183,7 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
return node; return node;
} }
private unsafe ResourceNode? CreateNodeFromMaterial(Material* mtrl) private unsafe ResourceNode? CreateNodeFromMaterial(Material* mtrl, Utf8GamePath path)
{ {
static ushort GetTextureIndex(Material* mtrl, ushort texFlags, HashSet<uint> alreadyVisitedSamplerIds) static ushort GetTextureIndex(Material* mtrl, ushort texFlags, HashSet<uint> alreadyVisitedSamplerIds)
{ {
@ -200,8 +210,6 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
if (mtrl == null || mtrl->MaterialResourceHandle == null) if (mtrl == null || mtrl->MaterialResourceHandle == null)
return null; return null;
var path = Utf8GamePath.Empty; // TODO
var resource = mtrl->MaterialResourceHandle; var resource = mtrl->MaterialResourceHandle;
if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached)) if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached))
return cached; return cached;
@ -265,8 +273,7 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
if (sklb == null || sklb->SkeletonResourceHandle == null) if (sklb == null || sklb->SkeletonResourceHandle == null)
return null; return null;
if (!Utf8GamePath.FromByteString(ResolveSklbPath(CharacterBase, partialSkeletonIndex), out var path)) var path = ResolveSkeletonPath(partialSkeletonIndex);
return null;
if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonResourceHandle), out var cached)) if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonResourceHandle), out var cached))
return cached; return cached;
@ -288,8 +295,7 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
if (sklb == null || sklb->SkeletonParameterResourceHandle == null) if (sklb == null || sklb->SkeletonParameterResourceHandle == null)
return null; return null;
if (!Utf8GamePath.FromByteString(ResolveSkpPath(CharacterBase, partialSkeletonIndex), out var path)) var path = ResolveSkeletonParameterPath(partialSkeletonIndex);
return null;
if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonParameterResourceHandle), out var cached)) if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonParameterResourceHandle), out var cached))
return cached; return cached;
@ -305,43 +311,6 @@ internal record ResolveContext(GlobalResolveContext Global, Pointer<CharacterBas
return node; return node;
} }
internal List<Utf8GamePath> FilterGamePaths(IReadOnlyCollection<Utf8GamePath> gamePaths)
{
var filtered = new List<Utf8GamePath>(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<string> 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<string> 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) internal ResourceNode.UiData GuessModelUIData(Utf8GamePath gamePath)
{ {
var path = gamePath.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries); var path = gamePath.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries);

View file

@ -6,6 +6,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.String.Classes;
using Penumbra.UI; using Penumbra.UI;
using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData; using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData;
@ -64,25 +65,13 @@ public class ResourceTree
CustomizeData = character->DrawData.CustomizeData; CustomizeData = character->DrawData.CustomizeData;
RaceCode = human != null ? (GenderRace)human->RaceSexId : GenderRace.Unknown; RaceCode = human != null ? (GenderRace)human->RaceSexId : GenderRace.Unknown;
var genericContext = globalContext.CreateContext(model, 0xFFFFFFFFu, EquipSlot.Unknown, default); var genericContext = globalContext.CreateContext(model);
var eid = (ResourceHandle*)model->EID;
var eidNode = genericContext.CreateNodeFromEid(eid);
if (eidNode != null)
{
if (globalContext.WithUiData)
eidNode.FallbackName = "EID";
Nodes.Add(eidNode);
}
for (var i = 0; i < model->SlotCount; ++i) for (var i = 0; i < model->SlotCount; ++i)
{ {
var slotContext = globalContext.CreateContext( var slotContext = i < equipment.Length
model, ? globalContext.CreateContext(model, (uint)i, ((uint)i).ToEquipSlot(), equipment[i])
(uint)i, : globalContext.CreateContext(model, (uint)i);
i < equipment.Length ? ((uint)i).ToEquipSlot() : EquipSlot.Unknown,
i < equipment.Length ? equipment[i] : default
);
var imc = (ResourceHandle*)model->IMCArray[i]; var imc = (ResourceHandle*)model->IMCArray[i];
var imcNode = slotContext.CreateNodeFromImc(imc); var imcNode = slotContext.CreateNodeFromImc(imc);
@ -94,7 +83,7 @@ public class ResourceTree
} }
var mdl = model->Models[i]; var mdl = model->Models[i];
var mdlNode = slotContext.CreateNodeFromRenderModel(mdl); var mdlNode = slotContext.CreateNodeFromModel(mdl, imcNode?.GamePath ?? Utf8GamePath.Empty);
if (mdlNode != null) if (mdlNode != null)
{ {
if (globalContext.WithUiData) 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) if (human != null)
AddHumanResources(globalContext, human); 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 weaponIndex = 0; var weaponNodes = new List<ResourceNode>();
var subObjectNodes = new List<ResourceNode>();
foreach (var baseSubObject in model->DrawObject.Object.ChildObjects) foreach (var baseSubObject in model->DrawObject.Object.ChildObjects)
{ {
if (baseSubObject->GetObjectType() != FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType.CharacterBase) if (baseSubObject->GetObjectType() != FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType.CharacterBase)
continue; continue;
var subObject = (CharacterBase*)baseSubObject; var subObject = (CharacterBase*)baseSubObject;
var weapon = subObject->GetModelType() == CharacterBase.ModelType.Weapon ? (Weapon*)subObject : null; if (subObject->GetModelType() != CharacterBase.ModelType.Weapon)
var subObjectNamePrefix = weapon != null ? "Weapon" : "Fashion Acc."; 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. // 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 slot = weaponIndex > 0 ? EquipSlot.OffHand : EquipSlot.MainHand;
var equipment = weapon != null ? new CharacterArmor(weapon->ModelSetId, (byte)weapon->Variant, (byte)weapon->ModelUnknown) : default; 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 genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment, weaponType);
var eid = (ResourceHandle*)subObject->EID;
var eidNode = genericContext.CreateNodeFromEid(eid);
if (eidNode != null)
{
if (globalContext.WithUiData)
eidNode.FallbackName = $"{subObjectNamePrefix} #{subObjectIndex}, EID";
Nodes.Add(eidNode);
}
for (var i = 0; i < subObject->SlotCount; ++i) 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 imc = (ResourceHandle*)subObject->IMCArray[i];
var imcNode = slotContext.CreateNodeFromImc(imc); var imcNode = slotContext.CreateNodeFromImc(imc);
if (imcNode != null) if (imcNode != null)
{ {
if (globalContext.WithUiData) if (globalContext.WithUiData)
imcNode.FallbackName = $"{subObjectNamePrefix} #{subObjectIndex}, IMC #{i}"; imcNode.FallbackName = $"Weapon #{weaponIndex}, IMC #{i}";
subObjectNodes.Add(imcNode); weaponNodes.Add(imcNode);
} }
var mdl = subObject->Models[i]; var mdl = subObject->Models[i];
var mdlNode = slotContext.CreateNodeFromRenderModel(mdl); var mdlNode = slotContext.CreateNodeFromModel(mdl, imcNode?.GamePath ?? Utf8GamePath.Empty);
if (mdlNode != null) if (mdlNode != null)
{ {
if (globalContext.WithUiData) if (globalContext.WithUiData)
mdlNode.FallbackName = $"{subObjectNamePrefix} #{subObjectIndex}, Model #{i}"; mdlNode.FallbackName = $"Weapon #{weaponIndex}, Model #{i}";
subObjectNodes.Add(mdlNode); weaponNodes.Add(mdlNode);
} }
} }
AddSkeleton(subObjectNodes, genericContext, subObject->Skeleton, $"{subObjectNamePrefix} #{subObjectIndex}, "); AddSkeleton(weaponNodes, genericContext, subObject->EID, subObject->Skeleton, $"Weapon #{weaponIndex}, ");
++subObjectIndex; ++weaponIndex;
if (weapon != null)
++weaponIndex;
} }
Nodes.InsertRange(0, subObjectNodes); Nodes.InsertRange(0, weaponNodes);
} }
private unsafe void AddHumanResources(GlobalResolveContext globalContext, Human* human) 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 decalId = (byte)(human->Customize[(int)CustomizeIndex.Facepaint] & 0x7F);
var decalPath = decalId != 0 var decalPath = decalId != 0
@ -208,8 +188,16 @@ public class ResourceTree
} }
} }
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, Skeleton* skeleton, string prefix = "") private unsafe void AddSkeleton(List<ResourceNode> 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) if (skeleton == null)
return; return;

View file

@ -1,5 +1,4 @@
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Game.Object;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Collections; using Penumbra.Collections;
@ -18,9 +17,10 @@ public class ResourceTreeFactory
private readonly IdentifierService _identifier; private readonly IdentifierService _identifier;
private readonly Configuration _config; private readonly Configuration _config;
private readonly ActorService _actors; private readonly ActorService _actors;
private readonly PathState _pathState;
public ResourceTreeFactory(IDataManager gameData, IObjectTable objects, CollectionResolver resolver, IdentifierService identifier, public ResourceTreeFactory(IDataManager gameData, IObjectTable objects, CollectionResolver resolver, IdentifierService identifier,
Configuration config, ActorService actors) Configuration config, ActorService actors, PathState pathState)
{ {
_gameData = gameData; _gameData = gameData;
_objects = objects; _objects = objects;
@ -28,6 +28,7 @@ public class ResourceTreeFactory
_identifier = identifier; _identifier = identifier;
_config = config; _config = config;
_actors = actors; _actors = actors;
_pathState = pathState;
} }
private TreeBuildCache CreateTreeBuildCache() private TreeBuildCache CreateTreeBuildCache()
@ -87,13 +88,17 @@ public class ResourceTreeFactory
var networked = character.ObjectId != Dalamud.Game.ClientState.Objects.Types.GameObject.InvalidGameObjectId; var networked = character.ObjectId != Dalamud.Game.ClientState.Objects.Types.GameObject.InvalidGameObjectId;
var tree = new ResourceTree(name, character.ObjectIndex, (nint)gameObjStruct, (nint)drawObjStruct, localPlayerRelated, related, var tree = new ResourceTree(name, character.ObjectIndex, (nint)gameObjStruct, (nint)drawObjStruct, localPlayerRelated, related,
networked, collectionResolveData.ModCollection.Name); networked, collectionResolveData.ModCollection.Name);
var globalContext = new GlobalResolveContext(_identifier.AwaitedService, cache, var globalContext = new GlobalResolveContext(_identifier.AwaitedService, collectionResolveData.ModCollection,
((Character*)gameObjStruct)->CharacterData.ModelCharaId, (flags & Flags.WithUiData) != 0); cache, (flags & Flags.WithUiData) != 0);
tree.LoadResources(globalContext); using (var _ = _pathState.EnterInternalResolve())
{
tree.LoadResources(globalContext);
}
tree.FlatNodes.UnionWith(globalContext.Nodes.Values); tree.FlatNodes.UnionWith(globalContext.Nodes.Values);
tree.ProcessPostfix((node, _) => tree.FlatNodes.Add(node)); 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) if (globalContext.WithUiData)
ResolveUiData(tree); ResolveUiData(tree);
FilterFullPaths(tree, (flags & Flags.RedactExternalPaths) != 0 ? _config.ModDirectory : null); 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)) if (!reverseDictionary.TryGetValue(node.FullPath.ToPath(), out var resolvedSet))
continue; continue;
IReadOnlyCollection<Utf8GamePath> resolvedList = resolvedSet; if (resolvedSet.Count != 1)
if (resolvedList.Count > 1)
{
var filteredList = node.ResolveContext!.FilterGamePaths(resolvedList);
if (filteredList.Count > 0)
resolvedList = filteredList;
}
if (resolvedList.Count != 1)
{ {
Penumbra.Log.Debug( Penumbra.Log.Debug(
$"Found {resolvedList.Count} game paths while reverse-resolving {node.FullPath} in {collection.Name}:"); $"Found {resolvedSet.Count} game paths while reverse-resolving {node.FullPath} in {collection.Name}:");
foreach (var gamePath in resolvedList) foreach (var gamePath in resolvedSet)
Penumbra.Log.Debug($"Game path: {gamePath}"); Penumbra.Log.Debug($"Game path: {gamePath}");
} }
node.PossibleGamePaths = resolvedList.ToArray(); node.PossibleGamePaths = resolvedSet.ToArray();
} }
else if (node.FullPath.InternalName.IsEmpty && node.PossibleGamePaths.Length == 1) else if (node.FullPath.InternalName.IsEmpty && node.PossibleGamePaths.Length == 1)
{ {

View file

@ -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<ModelResourceHandle*, uint, byte*>)_getMaterialFileNameBySlot)(handle, slot);
}

View file

@ -65,6 +65,13 @@ public unsafe class ImcFile : MetaBaseFile
return ptr == null ? new ImcEntry() : *ptr; 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) public static int PartIndex(EquipSlot slot)
=> slot switch => slot switch
{ {

View file

@ -76,6 +76,7 @@ public class Penumbra : IDalamudPlugin
_communicatorService = _services.GetRequiredService<CommunicatorService>(); _communicatorService = _services.GetRequiredService<CommunicatorService>();
_services.GetRequiredService<ResourceService>(); // Initialize because not required anywhere else. _services.GetRequiredService<ResourceService>(); // Initialize because not required anywhere else.
_services.GetRequiredService<ModCacheManager>(); // Initialize because not required anywhere else. _services.GetRequiredService<ModCacheManager>(); // Initialize because not required anywhere else.
_services.GetRequiredService<ModelResourceHandleUtility>(); // Initialize because not required anywhere else.
_collectionManager.Caches.CreateNecessaryCaches(); _collectionManager.Caches.CreateNecessaryCaches();
using (var t = _services.GetRequiredService<StartTracker>().Measure(StartTimeType.PathResolver)) using (var t = _services.GetRequiredService<StartTracker>().Measure(StartTimeType.PathResolver))
{ {

View file

@ -90,7 +90,8 @@ public static class ServiceManager
.AddSingleton<CreateFileWHook>() .AddSingleton<CreateFileWHook>()
.AddSingleton<ResidentResourceManager>() .AddSingleton<ResidentResourceManager>()
.AddSingleton<FontReloader>() .AddSingleton<FontReloader>()
.AddSingleton<RedrawService>(); .AddSingleton<RedrawService>()
.AddSingleton<ModelResourceHandleUtility>();
private static IServiceCollection AddConfiguration(this IServiceCollection services) private static IServiceCollection AddConfiguration(this IServiceCollection services)
=> services.AddTransient<ConfigMigrationService>() => services.AddTransient<ConfigMigrationService>()