Compare commits

...

4 commits

Author SHA1 Message Date
Actions User
1e07e43498 [CI] Updating repo.json for testing_1.5.0.9
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-24 13:51:43 +00:00
Ottermandias
f51f8a7bf8 Try to filter meta entries for relevance. 2025-08-24 15:24:57 +02:00
Exter-N
1fca78fa71 Add Kdb files to ResourceTree 2025-08-24 14:09:02 +02:00
Exter-N
c8b6325a87 Add game integrity message to On-Screen 2025-08-24 14:06:39 +02:00
12 changed files with 296 additions and 23 deletions

@ -1 +1 @@
Subproject commit 15e7c8eb41867e6bbd3fe6a8885404df087bc7e7
Subproject commit 73010350338ecd7b98ad85d127bed08d7d8718d4

View file

@ -40,7 +40,7 @@ public static unsafe class SkinMtrlPathEarlyProcessing
if (character->TempSlotData is not null)
{
// TODO ClientStructs-ify
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1564)
var handle = *(ModelResourceHandle**)((nint)character->TempSlotData + 0xE0 * slotIndex + 0x8);
if (handle != null)
return handle;

View file

@ -338,6 +338,34 @@ internal partial record ResolveContext
return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private Utf8GamePath ResolveKineDriverModulePath(uint partialSkeletonIndex)
{
// Correctness and Safety:
// Resolving a KineDriver module 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 => ResolveHumanKineDriverModulePath(partialSkeletonIndex),
_ => ResolveKineDriverModulePathNative(partialSkeletonIndex),
};
}
private Utf8GamePath ResolveHumanKineDriverModulePath(uint partialSkeletonIndex)
{
var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex);
if (set.Id is 0)
return Utf8GamePath.Empty;
var path = GamePaths.Kdb.Customization(raceCode, slot, set);
return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private unsafe Utf8GamePath ResolveKineDriverModulePathNative(uint partialSkeletonIndex)
{
var path = CharacterBase->ResolveKdbPathAsByteString(partialSkeletonIndex);
return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private unsafe Utf8GamePath ResolveMaterialAnimationPath(ResourceHandle* imc)
{
var animation = ResolveImcData(imc).MaterialAnimationId;

View file

@ -371,7 +371,8 @@ internal unsafe partial record ResolveContext(
return node;
}
public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, ResourceHandle* phybHandle, uint partialSkeletonIndex)
public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, ResourceHandle* phybHandle, ResourceHandle* kdbHandle,
uint partialSkeletonIndex)
{
if (sklb is null || sklb->SkeletonResourceHandle is null)
return null;
@ -386,6 +387,8 @@ internal unsafe partial record ResolveContext(
node.Children.Add(skpNode);
if (CreateNodeFromPhyb(phybHandle, partialSkeletonIndex) is { } phybNode)
node.Children.Add(phybNode);
if (CreateNodeFromKdb(kdbHandle, partialSkeletonIndex) is { } kdbNode)
node.Children.Add(kdbNode);
Global.Nodes.Add((path, (nint)sklb->SkeletonResourceHandle), node);
return node;
@ -427,6 +430,24 @@ internal unsafe partial record ResolveContext(
return node;
}
private ResourceNode? CreateNodeFromKdb(ResourceHandle* kdbHandle, uint partialSkeletonIndex)
{
if (kdbHandle is null)
return null;
var path = ResolveKineDriverModulePath(partialSkeletonIndex);
if (Global.Nodes.TryGetValue((path, (nint)kdbHandle), out var cached))
return cached;
var node = CreateNode(ResourceType.Phyb, 0, kdbHandle, path, false);
if (Global.WithUiData)
node.FallbackName = "KineDriver Module";
Global.Nodes.Add((path, (nint)kdbHandle), node);
return node;
}
internal ResourceNode.UiData GuessModelUiData(Utf8GamePath gamePath)
{
var path = gamePath.Path.Split((byte)'/');

View file

@ -45,7 +45,9 @@ public class ResourceNode : ICloneable
/// <summary> Whether to treat the file as protected (require holding the Mod Deletion Modifier to make a quick import). </summary>
public bool Protected
=> ForceProtected || Internal || Type is ResourceType.Shpk or ResourceType.Sklb or ResourceType.Pbd;
=> ForceProtected
|| Internal
|| Type is ResourceType.Shpk or ResourceType.Sklb or ResourceType.Skp or ResourceType.Phyb or ResourceType.Kdb or ResourceType.Pbd;
internal ResourceNode(ResourceType type, nint objectAddress, nint resourceHandle, ulong length, ResolveContext? resolveContext)
{

View file

@ -121,7 +121,7 @@ public class ResourceTree(
}
}
AddSkeleton(Nodes, genericContext, model->EID, model->Skeleton, model->BonePhysicsModule);
AddSkeleton(Nodes, genericContext, model);
AddMaterialAnimationSkeleton(Nodes, genericContext, model->MaterialAnimationSkeleton);
AddWeapons(globalContext, model);
@ -178,8 +178,7 @@ public class ResourceTree(
}
}
AddSkeleton(weaponNodes, genericContext, subObject->EID, subObject->Skeleton, subObject->BonePhysicsModule,
$"Weapon #{weaponIndex}, ");
AddSkeleton(weaponNodes, genericContext, subObject, $"Weapon #{weaponIndex}, ");
AddMaterialAnimationSkeleton(weaponNodes, genericContext, subObject->MaterialAnimationSkeleton,
$"Weapon #{weaponIndex}, ");
@ -242,8 +241,11 @@ public class ResourceTree(
}
}
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, CharacterBase* model, string prefix = "")
=> AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, *(void**)((nint)model + 0x160), prefix);
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics,
string prefix = "")
void* kineDriver, string prefix = "")
{
var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid);
if (eidNode != null)
@ -259,7 +261,9 @@ public class ResourceTree(
for (var i = 0; i < skeleton->PartialSkeletonCount; ++i)
{
var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null;
if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, (uint)i) is { } sklbNode)
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1562)
var kdbHandle = kineDriver != null ? *(ResourceHandle**)((nint)kineDriver + 0x20 + 0x18 * i) : null;
if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode)
{
if (context.Global.WithUiData)
sklbNode.FallbackName = $"{prefix}Skeleton #{i}";

View file

@ -64,6 +64,15 @@ internal static class StructExtensions
return ToOwnedByteString(character.ResolvePhybPath(pathBuffer, partialSkeletonIndex));
}
public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
{
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1561)
var vf80 = (delegate* unmanaged<CharacterBase*, byte*, nuint, uint, byte*>)((nint*)character.VirtualTable)[80];
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(vf80((CharacterBase*)Unsafe.AsPointer(ref character), pathBuffer, CharacterBase.PathBufferSize,
partialSkeletonIndex));
}
private static unsafe CiByteString ToOwnedByteString(CStringPointer str)
=> str.HasValue ? new CiByteString(str.Value).Clone() : CiByteString.Empty;

View file

@ -1,7 +1,10 @@
using System.Collections.Frozen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Penumbra.Collections.Cache;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files.AtchStructs;
using Penumbra.GameData.Interop;
using Penumbra.GameData.Structs;
using Penumbra.Util;
using ImcEntry = Penumbra.GameData.Structs.ImcEntry;
@ -40,6 +43,165 @@ public class MetaDictionary
foreach (var geqp in cache.GlobalEqp.Keys)
Add(geqp);
}
public static unsafe Wrapper Filtered(MetaCache cache, Actor actor)
{
if (!actor.IsCharacter)
return new Wrapper(cache);
var model = actor.Model;
if (!model.IsHuman)
return new Wrapper(cache);
var headId = model.GetModelId(HumanSlot.Head);
var bodyId = model.GetModelId(HumanSlot.Body);
var equipIdSet = ((IEnumerable<PrimaryId>)
[
headId,
bodyId,
model.GetModelId(HumanSlot.Hands),
model.GetModelId(HumanSlot.Legs),
model.GetModelId(HumanSlot.Feet),
]).ToFrozenSet();
var earsId = model.GetModelId(HumanSlot.Ears);
var neckId = model.GetModelId(HumanSlot.Neck);
var wristId = model.GetModelId(HumanSlot.Wrists);
var rFingerId = model.GetModelId(HumanSlot.RFinger);
var lFingerId = model.GetModelId(HumanSlot.LFinger);
var wrapper = new Wrapper();
// Check for all relevant primary IDs due to slot overlap.
foreach (var (eqp, value) in cache.Eqp)
{
if (eqp.Slot.IsEquipment())
{
if (equipIdSet.Contains(eqp.SetId))
wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot));
}
else
{
switch (eqp.Slot)
{
case EquipSlot.Ears when eqp.SetId == earsId:
case EquipSlot.Neck when eqp.SetId == neckId:
case EquipSlot.Wrists when eqp.SetId == wristId:
case EquipSlot.RFinger when eqp.SetId == rFingerId:
case EquipSlot.LFinger when eqp.SetId == lFingerId:
wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot));
break;
}
}
}
// Check also for body IDs due to body occupying head.
foreach (var (gmp, value) in cache.Gmp)
{
if (gmp.SetId == headId || gmp.SetId == bodyId)
wrapper.Gmp.Add(gmp, value.Entry);
}
// Check for all races due to inheritance and all slots due to overlap.
foreach (var (eqdp, value) in cache.Eqdp)
{
if (eqdp.Slot.IsEquipment())
{
if (equipIdSet.Contains(eqdp.SetId))
wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot));
}
else
{
switch (eqdp.Slot)
{
case EquipSlot.Ears when eqdp.SetId == earsId:
case EquipSlot.Neck when eqdp.SetId == neckId:
case EquipSlot.Wrists when eqdp.SetId == wristId:
case EquipSlot.RFinger when eqdp.SetId == rFingerId:
case EquipSlot.LFinger when eqdp.SetId == lFingerId:
wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot));
break;
}
}
}
var genderRace = (GenderRace)model.AsHuman->RaceSexId;
var hairId = model.GetModelId(HumanSlot.Hair);
var faceId = model.GetModelId(HumanSlot.Face);
// We do not need to care for racial inheritance for ESTs.
foreach (var (est, value) in cache.Est)
{
switch (est.Slot)
{
case EstType.Hair when est.SetId == hairId && est.GenderRace == genderRace:
case EstType.Face when est.SetId == faceId && est.GenderRace == genderRace:
case EstType.Body when est.SetId == bodyId && est.GenderRace == genderRace:
case EstType.Head when (est.SetId == headId || est.SetId == bodyId) && est.GenderRace == genderRace:
wrapper.Est.Add(est, value.Entry);
break;
}
}
foreach (var (geqp, _) in cache.GlobalEqp)
{
switch (geqp.Type)
{
case GlobalEqpType.DoNotHideEarrings when geqp.Condition != earsId:
case GlobalEqpType.DoNotHideNecklace when geqp.Condition != neckId:
case GlobalEqpType.DoNotHideBracelets when geqp.Condition != wristId:
case GlobalEqpType.DoNotHideRingR when geqp.Condition != rFingerId:
case GlobalEqpType.DoNotHideRingL when geqp.Condition != lFingerId:
continue;
default: wrapper.Add(geqp); break;
}
}
var (_, _, main, off) = model.GetWeapons(actor);
foreach (var (imc, value) in cache.Imc)
{
switch (imc.ObjectType)
{
case ObjectType.Equipment when equipIdSet.Contains(imc.PrimaryId): wrapper.Imc.Add(imc, value.Entry); break;
case ObjectType.Weapon:
if (imc.PrimaryId == main.Skeleton && imc.SecondaryId == main.Weapon)
wrapper.Imc.Add(imc, value.Entry);
else if (imc.PrimaryId == off.Skeleton && imc.SecondaryId == off.Weapon)
wrapper.Imc.Add(imc, value.Entry);
break;
case ObjectType.Accessory:
switch (imc.EquipSlot)
{
case EquipSlot.Ears when imc.PrimaryId == earsId:
case EquipSlot.Neck when imc.PrimaryId == neckId:
case EquipSlot.Wrists when imc.PrimaryId == wristId:
case EquipSlot.RFinger when imc.PrimaryId == rFingerId:
case EquipSlot.LFinger when imc.PrimaryId == lFingerId:
wrapper.Imc.Add(imc, value.Entry);
break;
}
break;
}
}
var subRace = (SubRace)model.AsHuman->Customize[4];
foreach (var (rsp, value) in cache.Rsp)
{
if (rsp.SubRace == subRace)
wrapper.Rsp.Add(rsp, value.Entry);
}
// Keep all atch, atr and shp.
wrapper.Atch.EnsureCapacity(cache.Atch.Count);
wrapper.Shp.EnsureCapacity(cache.Shp.Count);
wrapper.Atr.EnsureCapacity(cache.Atr.Count);
foreach (var (atch, value) in cache.Atch)
wrapper.Atch.Add(atch, value.Entry);
foreach (var (shp, value) in cache.Shp)
wrapper.Shp.Add(shp, value.Entry);
foreach (var (atr, value) in cache.Atr)
wrapper.Atr.Add(atr, value.Entry);
return wrapper;
}
}
private Wrapper? _data;
@ -934,4 +1096,24 @@ public class MetaDictionary
_data = new Wrapper(cache);
Count = cache.Count;
}
public MetaDictionary(MetaCache? cache, Actor actor)
{
if (cache is null)
return;
_data = Wrapper.Filtered(cache, actor);
Count = _data.Count
+ _data.Eqp.Count
+ _data.Eqdp.Count
+ _data.Est.Count
+ _data.Gmp.Count
+ _data.Imc.Count
+ _data.Rsp.Count
+ _data.Atch.Count
+ _data.Atr.Count
+ _data.Shp.Count;
if (Count is 0)
_data = null;
}
}

View file

@ -107,8 +107,8 @@ public class PcpService : IApiService, IDisposable
}
Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying.");
var text = File.ReadAllText(file);
var jObj = JObject.Parse(text);
var text = File.ReadAllText(file);
var jObj = JObject.Parse(text);
var collection = ModCollection.Empty;
// Create collection.
if (_config.PcpSettings.CreateCollection)
@ -164,7 +164,7 @@ public class PcpService : IApiService, IDisposable
try
{
Penumbra.Log.Information($"[PCPService] Creating PCP file for game object {objectIndex.Index}.");
var (identifier, tree, collection) = await _framework.Framework.RunOnFrameworkThread(() =>
var (identifier, tree, meta) = await _framework.Framework.RunOnFrameworkThread(() =>
{
var (actor, identifier) = CheckActor(objectIndex);
cancel.ThrowIfCancellationRequested();
@ -178,13 +178,14 @@ public class PcpService : IApiService, IDisposable
if (_treeFactory.FromCharacter(actor, 0) is not { } tree)
throw new Exception($"Unable to fetch modded resources for {identifier}.");
return (identifier.CreatePermanent(), tree, collection);
var meta = new MetaDictionary(collection.ModCollection.MetaCache, actor.Address);
return (identifier.CreatePermanent(), tree, meta);
}
});
cancel.ThrowIfCancellationRequested();
var time = DateTime.Now;
var modDirectory = CreateMod(identifier, note, time);
await CreateDefaultMod(modDirectory, collection.ModCollection, tree, cancel);
await CreateDefaultMod(modDirectory, meta, tree, cancel);
await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel);
var file = ZipUp(modDirectory);
return (true, file);
@ -242,11 +243,15 @@ public class PcpService : IApiService, IDisposable
?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}.");
}
private async Task CreateDefaultMod(DirectoryInfo modDirectory, ModCollection collection, ResourceTree tree,
private async Task CreateDefaultMod(DirectoryInfo modDirectory, MetaDictionary meta, ResourceTree tree,
CancellationToken cancel = default)
{
var subDirectory = modDirectory.CreateSubdirectory("files");
var subMod = new DefaultSubMod(null!);
var subMod = new DefaultSubMod(null!)
{
Manipulations = meta,
};
foreach (var node in tree.FlatNodes)
{
cancel.ThrowIfCancellationRequested();
@ -269,7 +274,6 @@ public class PcpService : IApiService, IDisposable
}
cancel.ThrowIfCancellationRequested();
subMod.Manipulations = new MetaDictionary(collection.MetaCache);
var saveGroup = new ModSaveGroup(modDirectory, subMod, _config.ReplaceNonAsciiOnImport);
var filePath = _files.FileNames.OptionGroupFile(modDirectory.FullName, -1, string.Empty, _config.ReplaceNonAsciiOnImport);

View file

@ -1,7 +1,9 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility;
using Dalamud.Plugin.Services;
using OtterGui;
using OtterGui.Classes;
using OtterGui.Extensions;
@ -13,7 +15,6 @@ using Penumbra.Interop.ResourceTree;
using Penumbra.Services;
using Penumbra.String;
using Penumbra.UI.Classes;
using static System.Net.Mime.MediaTypeNames;
namespace Penumbra.UI.AdvancedWindow;
@ -26,7 +27,8 @@ public class ResourceTreeViewer(
Action onRefresh,
Action<ResourceNode, Vector2> drawActions,
CommunicatorService communicator,
PcpService pcpService)
PcpService pcpService,
IDataManager gameData)
{
private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags =
ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership;
@ -45,6 +47,7 @@ public class ResourceTreeViewer(
public void Draw()
{
DrawModifiedGameFilesWarning();
DrawControls();
_task ??= RefreshCharacterList();
@ -130,6 +133,24 @@ public class ResourceTreeViewer(
}
}
private void DrawModifiedGameFilesWarning()
{
if (!gameData.HasModifiedGameDataFiles)
return;
using var style = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudOrange);
ImUtf8.TextWrapped(
"Dalamud is reporting your FFXIV installation has modified game files. Any mods installed through TexTools will produce this message."u8);
ImUtf8.TextWrapped("Penumbra and some other plugins assume your FFXIV installation is unmodified in order to work."u8);
ImUtf8.TextWrapped(
"Data displayed here may be inaccurate because of this, which, in turn, can break functionality relying on it, such as Character Pack exports/imports, or mod synchronization functions provided by other plugins."u8);
ImUtf8.TextWrapped(
"Exit the game, open XIVLauncher, click the arrow next to Log In and select \"repair game files\" to resolve this issue. Afterwards, do not install any mods with TexTools. Your plugin configurations will remain, as will mods enabled in Penumbra."u8);
ImGui.Separator();
}
private void DrawControls()
{
var yOffset = (ChangedItemDrawer.TypeFilterIconSize.Y - ImGui.GetFrameHeight()) / 2f;

View file

@ -1,3 +1,4 @@
using Dalamud.Plugin.Services;
using OtterGui.Services;
using Penumbra.Interop.ResourceTree;
using Penumbra.Services;
@ -10,8 +11,9 @@ public class ResourceTreeViewerFactory(
ChangedItemDrawer changedItemDrawer,
IncognitoService incognito,
CommunicatorService communicator,
PcpService pcpService) : IService
PcpService pcpService,
IDataManager gameData) : IService
{
public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action<ResourceNode, Vector2> drawActions)
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService);
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData);
}

View file

@ -6,7 +6,7 @@
"Description": "Runtime mod loader and manager.",
"InternalName": "Penumbra",
"AssemblyVersion": "1.5.0.6",
"TestingAssemblyVersion": "1.5.0.8",
"TestingAssemblyVersion": "1.5.0.9",
"RepoUrl": "https://github.com/xivdev/Penumbra",
"ApplicableVersion": "any",
"DalamudApiLevel": 13,
@ -19,7 +19,7 @@
"LoadRequiredState": 2,
"LoadSync": true,
"DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.0.6/Penumbra.zip",
"DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.0.8/Penumbra.zip",
"DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.0.9/Penumbra.zip",
"DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.0.6/Penumbra.zip",
"IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png"
}