From b7f326e29c87c31dc1a790a8feb7608a08bacfca Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 22 Aug 2025 15:43:55 +0200 Subject: [PATCH 01/35] Fix bug with collection setting and empty collection. --- Penumbra/Collections/CollectionAutoSelector.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Penumbra/Collections/CollectionAutoSelector.cs b/Penumbra/Collections/CollectionAutoSelector.cs index 68dac914..f6e6bf72 100644 --- a/Penumbra/Collections/CollectionAutoSelector.cs +++ b/Penumbra/Collections/CollectionAutoSelector.cs @@ -59,8 +59,15 @@ public sealed class CollectionAutoSelector : IService, IDisposable return; var collection = _resolver.PlayerCollection(); - Penumbra.Log.Debug($"Setting current collection to {collection.Identity.Identifier} through automatic collection selection."); - _collections.SetCollection(collection, CollectionType.Current); + if (collection.Identity.Id == Guid.Empty) + { + Penumbra.Log.Debug($"Not setting current collection because character has no mods assigned."); + } + else + { + Penumbra.Log.Debug($"Setting current collection to {collection.Identity.Identifier} through automatic collection selection."); + _collections.SetCollection(collection, CollectionType.Current); + } } From e3b7f728932da3402f5e479319e459b23d018d74 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 22 Aug 2025 15:44:33 +0200 Subject: [PATCH 02/35] Add initial PCP. --- Penumbra.Api | 2 +- Penumbra/Communication/ModPathChanged.cs | 8 +- Penumbra/Configuration.cs | 1 + Penumbra/Import/TexToolsImport.cs | 2 +- Penumbra/Mods/Manager/ModDataEditor.cs | 3 +- Penumbra/Mods/ModCreator.cs | 4 +- Penumbra/Services/PcpService.cs | 259 ++++++++++++++++++ .../UI/AdvancedWindow/ResourceTreeViewer.cs | 56 +++- .../ResourceTreeViewerFactory.cs | 5 +- Penumbra/UI/ModsTab/ModFileSystemSelector.cs | 5 +- Penumbra/UI/Tabs/SettingsTab.cs | 20 +- 11 files changed, 338 insertions(+), 27 deletions(-) create mode 100644 Penumbra/Services/PcpService.cs diff --git a/Penumbra.Api b/Penumbra.Api index c27a0600..2e26d911 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit c27a06004138f2ec80ccdb494bb6ddf6d39d2165 +Subproject commit 2e26d9119249e67f03f415f8ebe1dcb7c28d5cf2 diff --git a/Penumbra/Communication/ModPathChanged.cs b/Penumbra/Communication/ModPathChanged.cs index 1e4f8d36..efe59482 100644 --- a/Penumbra/Communication/ModPathChanged.cs +++ b/Penumbra/Communication/ModPathChanged.cs @@ -3,6 +3,7 @@ using Penumbra.Api; using Penumbra.Api.Api; using Penumbra.Mods; using Penumbra.Mods.Manager; +using Penumbra.Services; namespace Penumbra.Communication; @@ -20,11 +21,14 @@ public sealed class ModPathChanged() { public enum Priority { + /// + PcpService = int.MinValue, + /// - ApiMods = int.MinValue, + ApiMods = int.MinValue + 1, /// - ApiModSettings = int.MinValue, + ApiModSettings = int.MinValue + 1, /// EphemeralConfig = -500, diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index 8c50dad7..e8f1d5ef 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -88,6 +88,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool OpenFoldersByDefault { get; set; } = false; public int SingleGroupRadioMax { get; set; } = 2; public string DefaultImportFolder { get; set; } = string.Empty; + public string PcpFolderName { get; set; } = "PCP"; public string QuickMoveFolder1 { get; set; } = string.Empty; public string QuickMoveFolder2 { get; set; } = string.Empty; public string QuickMoveFolder3 { get; set; } = string.Empty; diff --git a/Penumbra/Import/TexToolsImport.cs b/Penumbra/Import/TexToolsImport.cs index fed06573..8e4fea41 100644 --- a/Penumbra/Import/TexToolsImport.cs +++ b/Penumbra/Import/TexToolsImport.cs @@ -119,7 +119,7 @@ public partial class TexToolsImporter : IDisposable // Puts out warnings if extension does not correspond to data. private DirectoryInfo VerifyVersionAndImport(FileInfo modPackFile) { - if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".zip" or ".7z" or ".rar") + if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".pcp" or ".zip" or ".7z" or ".rar") return HandleRegularArchive(modPackFile); using var zfs = modPackFile.OpenRead(); diff --git a/Penumbra/Mods/Manager/ModDataEditor.cs b/Penumbra/Mods/Manager/ModDataEditor.cs index fc4fdadc..ffa73b76 100644 --- a/Penumbra/Mods/Manager/ModDataEditor.cs +++ b/Penumbra/Mods/Manager/ModDataEditor.cs @@ -36,7 +36,7 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic /// Create the file containing the meta information about a mod from scratch. public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version, - string? website) + string? website, params string[] tags) { var mod = new Mod(directory); mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name); @@ -44,6 +44,7 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic mod.Description = description ?? mod.Description; mod.Version = version ?? mod.Version; mod.Website = website ?? mod.Website; + mod.ModTags = tags; saveService.ImmediateSaveSync(new ModMeta(mod)); } diff --git a/Penumbra/Mods/ModCreator.cs b/Penumbra/Mods/ModCreator.cs index 1bb2a073..3a7bd105 100644 --- a/Penumbra/Mods/ModCreator.cs +++ b/Penumbra/Mods/ModCreator.cs @@ -32,12 +32,12 @@ public partial class ModCreator( public readonly Configuration Config = config; /// Creates directory and files necessary for a new mod without adding it to the manager. - public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null) + public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null, params string[] tags) { try { var newDir = CreateModFolder(basePath, newName, Config.ReplaceNonAsciiOnImport, true); - dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty); + dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty, tags); CreateDefaultFiles(newDir); return newDir; } diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs new file mode 100644 index 00000000..461045ba --- /dev/null +++ b/Penumbra/Services/PcpService.cs @@ -0,0 +1,259 @@ +using System.Buffers.Text; +using Dalamud.Game.ClientState.Objects.Types; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.ResourceTree; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Services; + +public class PcpService : IApiService, IDisposable +{ + public const string Extension = ".pcp"; + + private readonly Configuration _config; + private readonly SaveService _files; + private readonly ResourceTreeFactory _treeFactory; + private readonly ObjectManager _objectManager; + private readonly ActorManager _actors; + private readonly FrameworkManager _framework; + private readonly CollectionResolver _collectionResolver; + private readonly CollectionManager _collections; + private readonly ModCreator _modCreator; + private readonly ModExportManager _modExport; + private readonly CommunicatorService _communicator; + private readonly SHA1 _sha1 = SHA1.Create(); + private readonly ModFileSystem _fileSystem; + + public PcpService(Configuration config, + SaveService files, + ResourceTreeFactory treeFactory, + ObjectManager objectManager, + ActorManager actors, + FrameworkManager framework, + CollectionManager collections, + CollectionResolver collectionResolver, + ModCreator modCreator, + ModExportManager modExport, + CommunicatorService communicator, + ModFileSystem fileSystem) + { + _config = config; + _files = files; + _treeFactory = treeFactory; + _objectManager = objectManager; + _actors = actors; + _framework = framework; + _collectionResolver = collectionResolver; + _collections = collections; + _modCreator = modCreator; + _modExport = modExport; + _communicator = communicator; + _fileSystem = fileSystem; + + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.PcpService); + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) + { + if (type is not ModPathChangeType.Added || newDirectory is null) + return; + + try + { + var file = Path.Combine(newDirectory.FullName, "collection.json"); + if (!File.Exists(file)) + return; + + var text = File.ReadAllText(file); + var jObj = JObject.Parse(text); + var identifier = _actors.FromJson(jObj["Actor"] as JObject); + if (!identifier.IsValid) + return; + + if (jObj["Collection"]?.ToObject() is not { } collectionName) + return; + + var name = $"PCP/{collectionName}"; + if (!_collections.Storage.AddCollection(name, null)) + return; + + var collection = _collections.Storage[^1]; + _collections.Editor.SetModState(collection, mod, true); + + var identifierGroup = _collections.Active.Individuals.GetGroup(identifier); + _collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup); + if (_fileSystem.TryGetValue(mod, out var leaf)) + { + try + { + var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpFolderName); + _fileSystem.Move(leaf, folder); + } + catch + { + // ignored. + } + } + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error reading the collection.json file from {mod.Identifier}:\n{ex}"); + } + } + + public void Dispose() + => _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + + public async Task<(bool, string)> CreatePcp(ObjectIndex objectIndex, string note = "", CancellationToken cancel = default) + { + try + { + var (identifier, tree, collection) = await _framework.Framework.RunOnFrameworkThread(() => + { + var (actor, identifier) = CheckActor(objectIndex); + cancel.ThrowIfCancellationRequested(); + unsafe + { + var collection = _collectionResolver.IdentifyCollection((GameObject*)actor.Address, true); + if (!collection.Valid || !collection.ModCollection.HasCache) + throw new Exception($"Actor {identifier} has no mods applying, nothing to do."); + + cancel.ThrowIfCancellationRequested(); + if (_treeFactory.FromCharacter(actor, 0) is not { } tree) + throw new Exception($"Unable to fetch modded resources for {identifier}."); + + return (identifier.CreatePermanent(), tree, collection); + } + }); + cancel.ThrowIfCancellationRequested(); + var time = DateTime.Now; + var modDirectory = CreateMod(identifier, note, time); + await CreateDefaultMod(modDirectory, collection.ModCollection, tree, cancel); + await CreateCollectionInfo(modDirectory, identifier, note, time, cancel); + var file = ZipUp(modDirectory); + return (true, file); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + private static string ZipUp(DirectoryInfo directory) + { + var fileName = directory.FullName + Extension; + ZipFile.CreateFromDirectory(directory.FullName, fileName, CompressionLevel.Optimal, false); + directory.Delete(true); + return fileName; + } + + private static async Task CreateCollectionInfo(DirectoryInfo directory, ActorIdentifier actor, string note, DateTime time, + CancellationToken cancel = default) + { + var jObj = new JObject + { + ["Version"] = 1, + ["Actor"] = actor.ToJson(), + ["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(), + ["Time"] = time, + ["Note"] = note, + }; + if (note.Length > 0) + cancel.ThrowIfCancellationRequested(); + var filePath = Path.Combine(directory.FullName, "collection.json"); + await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); + await using var stream = new StreamWriter(file); + await using var json = new JsonTextWriter(stream); + json.Formatting = Formatting.Indented; + await jObj.WriteToAsync(json, cancel); + } + + private DirectoryInfo CreateMod(ActorIdentifier actor, string note, DateTime time) + { + var directory = _modExport.ExportDirectory; + directory.Create(); + var actorName = actor.ToName(); + var authorName = _actors.GetCurrentPlayer().ToName(); + var suffix = note.Length > 0 + ? note + : time.ToString("yyyy-MM-ddTHH\\:mm", CultureInfo.InvariantCulture); + var modName = $"{actorName} - {suffix}"; + var description = $"On-Screen Data for {actorName} as snapshotted on {time}."; + return _modCreator.CreateEmptyMod(directory, modName, description, authorName, "PCP") + ?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}."); + } + + private async Task CreateDefaultMod(DirectoryInfo modDirectory, ModCollection collection, ResourceTree tree, + CancellationToken cancel = default) + { + var subDirectory = modDirectory.CreateSubdirectory("files"); + var subMod = new DefaultSubMod(null!); + foreach (var node in tree.FlatNodes) + { + cancel.ThrowIfCancellationRequested(); + var gamePath = node.GamePath; + var fullPath = node.FullPath; + if (fullPath.IsRooted) + { + var hash = await _sha1.ComputeHashAsync(File.OpenRead(fullPath.FullName), cancel).ConfigureAwait(false); + cancel.ThrowIfCancellationRequested(); + var name = Convert.ToHexString(hash) + fullPath.Extension; + var newFile = Path.Combine(subDirectory.FullName, name); + if (!File.Exists(newFile)) + File.Copy(fullPath.FullName, newFile); + subMod.Files.TryAdd(gamePath, new FullPath(newFile)); + } + else if (gamePath.Path != fullPath.InternalName) + { + subMod.FileSwaps.TryAdd(gamePath, fullPath); + } + } + + 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); + cancel.ThrowIfCancellationRequested(); + await using var fileStream = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); + await using var writer = new StreamWriter(fileStream); + saveGroup.Save(writer); + } + + private (ICharacter Actor, ActorIdentifier Identifier) CheckActor(ObjectIndex objectIndex) + { + var actor = _objectManager[objectIndex]; + if (!actor.Valid) + throw new Exception($"No Actor at index {objectIndex} found."); + + if (!actor.Identifier(_actors, out var identifier)) + throw new Exception($"Could not create valid identifier for actor at index {objectIndex}."); + + if (!actor.IsCharacter) + throw new Exception($"Actor {identifier} at index {objectIndex} is not a valid character."); + + if (!actor.Model.Valid) + throw new Exception($"Actor {identifier} at index {objectIndex} has no model."); + + if (_objectManager.Objects.CreateObjectReference(actor.Address) is not ICharacter character) + throw new Exception($"Actor {identifier} at index {objectIndex} could not be converted to ICharacter"); + + return (character, identifier); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs index 440baa2f..a2309343 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -1,15 +1,19 @@ -using Dalamud.Interface; -using Dalamud.Interface.Utility; using Dalamud.Bindings.ImGui; -using OtterGui.Raii; +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility; using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; using OtterGui.Text; using Penumbra.Api.Enums; +using Penumbra.GameData.Structs; using Penumbra.Interop.ResourceTree; using Penumbra.Services; -using Penumbra.UI.Classes; using Penumbra.String; -using OtterGui.Extensions; +using Penumbra.UI.Classes; +using static System.Net.Mime.MediaTypeNames; namespace Penumbra.UI.AdvancedWindow; @@ -21,12 +25,13 @@ public class ResourceTreeViewer( int actionCapacity, Action onRefresh, Action drawActions, - CommunicatorService communicator) + CommunicatorService communicator, + PcpService pcpService) { private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags = ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; - private readonly HashSet _unfolded = []; + private readonly HashSet _unfolded = []; private readonly Dictionary _filterCache = []; @@ -34,6 +39,7 @@ public class ResourceTreeViewer( private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags; private string _nameFilter = string.Empty; private string _nodeFilter = string.Empty; + private string _note = string.Empty; private Task? _task; @@ -83,7 +89,28 @@ public class ResourceTreeViewer( using var id = ImRaii.PushId(index); - ImGui.TextUnformatted($"Collection: {(incognito.IncognitoMode ? tree.AnonymizedCollectionName : tree.CollectionName)}"); + ImUtf8.TextFrameAligned($"Collection: {(incognito.IncognitoMode ? tree.AnonymizedCollectionName : tree.CollectionName)}"); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Export Character Pack"u8, + "Note that this recomputes the current data of the actor if it still exists, and does not use the cached data."u8)) + { + pcpService.CreatePcp((ObjectIndex)tree.GameObjectIndex, _note).ContinueWith(t => + { + + var (success, text) = t.Result; + + if (success) + Penumbra.Messager.NotificationMessage($"Created {text}.", NotificationType.Success, false); + else + Penumbra.Messager.NotificationMessage(text, NotificationType.Error, false); + }); + _note = string.Empty; + } + + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8); + using var table = ImRaii.Table("##ResourceTree", actionCapacity > 0 ? 4 : 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); @@ -263,7 +290,8 @@ public class ResourceTreeViewer( using var group = ImUtf8.Group(); using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value())) { - ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); } ImGui.SameLine(); @@ -272,7 +300,8 @@ public class ResourceTreeViewer( } else { - ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); } if (ImGui.IsItemClicked()) @@ -365,9 +394,10 @@ public class ResourceTreeViewer( private static string GetPathStatusDescription(ResourceNode.PathStatus status) => status switch { - ResourceNode.PathStatus.External => "The actual path to this file is unavailable, because it is managed by external tools.", - ResourceNode.PathStatus.NonExistent => "The actual path to this file is unavailable, because it seems to have been moved or deleted since it was loaded.", - _ => "The actual path to this file is unavailable.", + ResourceNode.PathStatus.External => "The actual path to this file is unavailable, because it is managed by external tools.", + ResourceNode.PathStatus.NonExistent => + "The actual path to this file is unavailable, because it seems to have been moved or deleted since it was loaded.", + _ => "The actual path to this file is unavailable.", }; [Flags] diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs index 10a4aea2..ac06fe1a 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs @@ -9,8 +9,9 @@ public class ResourceTreeViewerFactory( ResourceTreeFactory treeFactory, ChangedItemDrawer changedItemDrawer, IncognitoService incognito, - CommunicatorService communicator) : IService + CommunicatorService communicator, + PcpService pcpService) : IService { public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action drawActions) - => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator); + => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService); } diff --git a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs index 16ff7b41..3f3c82aa 100644 --- a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs +++ b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs @@ -126,6 +126,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector + "Mod Packs{.ttmp,.ttmp2,.pmp,.pcp},TexTools Mod Packs{.ttmp,.ttmp2},Penumbra Mod Packs{.pmp,.pcp},Archives{.zip,.7z,.rar},Penumbra Character Packs{.pcp}", (s, f) => { if (!s) return; @@ -445,7 +446,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector Draw input for the default folder to sort put newly imported mods into. + private void DrawPcpFolder() + { + var tmp = _config.PcpFolderName; + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImUtf8.InputText("##pcpFolder"u8, ref tmp)) + _config.PcpFolderName = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _config.Save(); + + ImGuiUtil.LabeledHelpMarker("Default PCP Organizational Folder", + "The folder any penumbra character packs are moved to on import.\nLeave blank to import into Root."); + } + /// Draw all settings pertaining to advanced editing of mods. private void DrawModEditorSettings() @@ -1055,7 +1069,7 @@ public class SettingsTab : ITab, IUiService if (ImGui.Button("Show Changelogs", new Vector2(width, 0))) _penumbra.ForceChangelogOpen(); - ImGui.SetCursorPos(new Vector2(xPos, 5 * ImGui.GetFrameHeightWithSpacing())); + ImGui.SetCursorPos(new Vector2(xPos, 5 * ImGui.GetFrameHeightWithSpacing())); CustomGui.DrawKofiPatreonButton(Penumbra.Messager, new Vector2(width, 0)); } From 8043e6fb6be5751deb5a33657638a73542728c35 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 22 Aug 2025 15:49:15 +0200 Subject: [PATCH 03/35] Add option to disable PCP. --- Penumbra/Configuration.cs | 1 + Penumbra/Services/PcpService.cs | 2 +- Penumbra/UI/Tabs/SettingsTab.cs | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index e8f1d5ef..b9a0d9ce 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -68,6 +68,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool DefaultTemporaryMode { get; set; } = false; public bool EnableCustomShapes { get; set; } = true; + public bool DisablePcpHandling { get; set; } = false; public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; public int OptionGroupCollapsibleMin { get; set; } = 5; diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index 461045ba..5f4a844d 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -71,7 +71,7 @@ public class PcpService : IApiService, IDisposable private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) { - if (type is not ModPathChangeType.Added || newDirectory is null) + if (type is not ModPathChangeType.Added || _config.DisablePcpHandling || newDirectory is null) return; try diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 4bed1ef2..143709f4 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -598,6 +598,9 @@ public class SettingsTab : ITab, IUiService Checkbox("Always Open Import at Default Directory", "Open the import window at the location specified here every time, forgetting your previous path.", _config.AlwaysOpenDefaultImport, v => _config.AlwaysOpenDefaultImport = v); + Checkbox("Handle PCP Files", + "When encountering specific mods, usually but not necessarily denoted by a .pcp file ending, Penumbra will automatically try to create an associated collection and assign it to a specific character for this mod package. This can turn this behaviour off if unwanted.", + !_config.DisablePcpHandling, v => _config.DisablePcpHandling = !v); DrawDefaultModImportPath(); DrawDefaultModAuthor(); DrawDefaultModImportFolder(); From fb34238530f08918fdc18c63e553ca134f4f8fee Mon Sep 17 00:00:00 2001 From: Actions User Date: Fri, 22 Aug 2025 13:51:50 +0000 Subject: [PATCH 04/35] [CI] Updating repo.json for testing_1.5.0.7 --- repo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo.json b/repo.json index a452dc94..cf4fe6cb 100644 --- a/repo.json +++ b/repo.json @@ -6,7 +6,7 @@ "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", "AssemblyVersion": "1.5.0.6", - "TestingAssemblyVersion": "1.5.0.6", + "TestingAssemblyVersion": "1.5.0.7", "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/1.5.0.6/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.0.7/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" } From 10894d451a525e504422c4b16a3d3022601e8dfe Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 22 Aug 2025 18:08:22 +0200 Subject: [PATCH 05/35] Add Pcp Events. --- Penumbra.Api | 2 +- Penumbra/Api/Api/ModsApi.cs | 25 +++++++++++++++++------- Penumbra/Api/Api/PenumbraApi.cs | 2 +- Penumbra/Api/IpcProviders.cs | 2 ++ Penumbra/Communication/PcpCreation.cs | 20 +++++++++++++++++++ Penumbra/Communication/PcpParsing.cs | 21 ++++++++++++++++++++ Penumbra/Configuration.cs | 1 + Penumbra/Services/CommunicatorService.cs | 8 ++++++++ Penumbra/Services/PcpService.cs | 24 ++++++++++++++++------- 9 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 Penumbra/Communication/PcpCreation.cs create mode 100644 Penumbra/Communication/PcpParsing.cs diff --git a/Penumbra.Api b/Penumbra.Api index 2e26d911..0a970295 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 2e26d9119249e67f03f415f8ebe1dcb7c28d5cf2 +Subproject commit 0a970295b2398683b1e49c46fd613541e2486210 diff --git a/Penumbra/Api/Api/ModsApi.cs b/Penumbra/Api/Api/ModsApi.cs index 78c62953..55f1e259 100644 --- a/Penumbra/Api/Api/ModsApi.cs +++ b/Penumbra/Api/Api/ModsApi.cs @@ -1,3 +1,4 @@ +using Newtonsoft.Json.Linq; using OtterGui.Compression; using OtterGui.Services; using Penumbra.Api.Enums; @@ -33,12 +34,8 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable { switch (type) { - case ModPathChangeType.Deleted when oldDirectory != null: - ModDeleted?.Invoke(oldDirectory.Name); - break; - case ModPathChangeType.Added when newDirectory != null: - ModAdded?.Invoke(newDirectory.Name); - break; + case ModPathChangeType.Deleted when oldDirectory != null: ModDeleted?.Invoke(oldDirectory.Name); break; + case ModPathChangeType.Added when newDirectory != null: ModAdded?.Invoke(newDirectory.Name); break; case ModPathChangeType.Moved when newDirectory != null && oldDirectory != null: ModMoved?.Invoke(oldDirectory.Name, newDirectory.Name); break; @@ -46,7 +43,9 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable } public void Dispose() - => _communicator.ModPathChanged.Unsubscribe(OnModPathChanged); + { + _communicator.ModPathChanged.Unsubscribe(OnModPathChanged); + } public Dictionary GetModList() => _modManager.ToDictionary(m => m.ModPath.Name, m => m.Name.Text); @@ -109,6 +108,18 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable public event Action? ModAdded; public event Action? ModMoved; + public event Action? CreatingPcp + { + add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi); + remove => _communicator.PcpCreation.Unsubscribe(value!); + } + + public event Action? ParsingPcp + { + add => _communicator.PcpParsing.Subscribe(value!, PcpParsing.Priority.ModsApi); + remove => _communicator.PcpParsing.Unsubscribe(value!); + } + public (PenumbraApiEc, string, bool, bool) GetModPath(string modDirectory, string modName) { if (!_modManager.TryGetMod(modDirectory, modName, out var mod) diff --git a/Penumbra/Api/Api/PenumbraApi.cs b/Penumbra/Api/Api/PenumbraApi.cs index 7ca41324..9e7eb964 100644 --- a/Penumbra/Api/Api/PenumbraApi.cs +++ b/Penumbra/Api/Api/PenumbraApi.cs @@ -17,7 +17,7 @@ public class PenumbraApi( UiApi ui) : IDisposable, IApiService, IPenumbraApi { public const int BreakingVersion = 5; - public const int FeatureVersion = 10; + public const int FeatureVersion = 11; public void Dispose() { diff --git a/Penumbra/Api/IpcProviders.cs b/Penumbra/Api/IpcProviders.cs index 7dcee375..0c80626f 100644 --- a/Penumbra/Api/IpcProviders.cs +++ b/Penumbra/Api/IpcProviders.cs @@ -54,6 +54,8 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.ModDeleted.Provider(pi, api.Mods), IpcSubscribers.ModAdded.Provider(pi, api.Mods), IpcSubscribers.ModMoved.Provider(pi, api.Mods), + IpcSubscribers.CreatingPcp.Provider(pi, api.Mods), + IpcSubscribers.ParsingPcp.Provider(pi, api.Mods), IpcSubscribers.GetModPath.Provider(pi, api.Mods), IpcSubscribers.SetModPath.Provider(pi, api.Mods), IpcSubscribers.GetChangedItems.Provider(pi, api.Mods), diff --git a/Penumbra/Communication/PcpCreation.cs b/Penumbra/Communication/PcpCreation.cs new file mode 100644 index 00000000..cb11b3c3 --- /dev/null +++ b/Penumbra/Communication/PcpCreation.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered when the character.json file for a .pcp file is written. +/// +/// Parameter is the JObject that gets written to file. +/// Parameter is the object index of the game object this is written for. +/// +/// +public sealed class PcpCreation() : EventWrapper(nameof(PcpCreation)) +{ + public enum Priority + { + /// + ModsApi = int.MinValue, + } +} diff --git a/Penumbra/Communication/PcpParsing.cs b/Penumbra/Communication/PcpParsing.cs new file mode 100644 index 00000000..95b78951 --- /dev/null +++ b/Penumbra/Communication/PcpParsing.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered when the character.json file for a .pcp file is parsed and applied. +/// +/// Parameter is parsed JObject that contains the data. +/// Parameter is the identifier of the created mod. +/// Parameter is the GUID of the created collection. +/// +/// +public sealed class PcpParsing() : EventWrapper(nameof(PcpParsing)) +{ + public enum Priority + { + /// + ModsApi = int.MinValue, + } +} diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index b9a0d9ce..d9a9f5fe 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -69,6 +69,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool DefaultTemporaryMode { get; set; } = false; public bool EnableCustomShapes { get; set; } = true; public bool DisablePcpHandling { get; set; } = false; + public bool AllowPcpIpc { get; set; } = true; public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; public int OptionGroupCollapsibleMin { get; set; } = 5; diff --git a/Penumbra/Services/CommunicatorService.cs b/Penumbra/Services/CommunicatorService.cs index 5d745419..35f15e9e 100644 --- a/Penumbra/Services/CommunicatorService.cs +++ b/Penumbra/Services/CommunicatorService.cs @@ -81,6 +81,12 @@ public class CommunicatorService : IDisposable, IService /// public readonly ResolvedFileChanged ResolvedFileChanged = new(); + /// + public readonly PcpCreation PcpCreation = new(); + + /// + public readonly PcpParsing PcpParsing = new(); + public void Dispose() { CollectionChange.Dispose(); @@ -105,5 +111,7 @@ public class CommunicatorService : IDisposable, IService ChangedItemClick.Dispose(); SelectTab.Dispose(); ResolvedFileChanged.Dispose(); + PcpCreation.Dispose(); + PcpParsing.Dispose(); } } diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index 5f4a844d..32eca652 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -1,4 +1,3 @@ -using System.Buffers.Text; using Dalamud.Game.ClientState.Objects.Types; using FFXIVClientStructs.FFXIV.Client.Game.Object; using Newtonsoft.Json; @@ -76,9 +75,16 @@ public class PcpService : IApiService, IDisposable try { - var file = Path.Combine(newDirectory.FullName, "collection.json"); + var file = Path.Combine(newDirectory.FullName, "character.json"); if (!File.Exists(file)) - return; + { + // First version had collection.json, changed. + var oldFile = Path.Combine(newDirectory.FullName, "collection.json"); + if (File.Exists(oldFile)) + File.Move(oldFile, file, true); + else + return; + } var text = File.ReadAllText(file); var jObj = JObject.Parse(text); @@ -110,10 +116,12 @@ public class PcpService : IApiService, IDisposable // ignored. } } + if (_config.AllowPcpIpc) + _communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id); } catch (Exception ex) { - Penumbra.Log.Error($"Error reading the collection.json file from {mod.Identifier}:\n{ex}"); + Penumbra.Log.Error($"Error reading the character.json file from {mod.Identifier}:\n{ex}"); } } @@ -145,7 +153,7 @@ public class PcpService : IApiService, IDisposable var time = DateTime.Now; var modDirectory = CreateMod(identifier, note, time); await CreateDefaultMod(modDirectory, collection.ModCollection, tree, cancel); - await CreateCollectionInfo(modDirectory, identifier, note, time, cancel); + await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel); var file = ZipUp(modDirectory); return (true, file); } @@ -163,7 +171,7 @@ public class PcpService : IApiService, IDisposable return fileName; } - private static async Task CreateCollectionInfo(DirectoryInfo directory, ActorIdentifier actor, string note, DateTime time, + private async Task CreateCollectionInfo(DirectoryInfo directory, ObjectIndex index, ActorIdentifier actor, string note, DateTime time, CancellationToken cancel = default) { var jObj = new JObject @@ -176,7 +184,9 @@ public class PcpService : IApiService, IDisposable }; if (note.Length > 0) cancel.ThrowIfCancellationRequested(); - var filePath = Path.Combine(directory.FullName, "collection.json"); + if (_config.AllowPcpIpc) + await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index)); + var filePath = Path.Combine(directory.FullName, "character.json"); await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); await using var stream = new StreamWriter(file); await using var json = new JsonTextWriter(stream); From 0d643840592bac17b67a09dc66f971fed8dc35a1 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 22 Aug 2025 20:31:40 +0200 Subject: [PATCH 06/35] Add cleanup buttons to PCP, add option to turn off PCP IPC. --- Penumbra/Services/PcpService.cs | 26 +++++++++++++++++++++++++- Penumbra/UI/Tabs/SettingsTab.cs | 21 ++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index 32eca652..73c61cdb 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -38,6 +38,7 @@ public class PcpService : IApiService, IDisposable private readonly CommunicatorService _communicator; private readonly SHA1 _sha1 = SHA1.Create(); private readonly ModFileSystem _fileSystem; + private readonly ModManager _mods; public PcpService(Configuration config, SaveService files, @@ -50,7 +51,8 @@ public class PcpService : IApiService, IDisposable ModCreator modCreator, ModExportManager modExport, CommunicatorService communicator, - ModFileSystem fileSystem) + ModFileSystem fileSystem, + ModManager mods) { _config = config; _files = files; @@ -64,10 +66,27 @@ public class PcpService : IApiService, IDisposable _modExport = modExport; _communicator = communicator; _fileSystem = fileSystem; + _mods = mods; _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.PcpService); } + public void CleanPcpMods() + { + var mods = _mods.Where(m => m.ModTags.Contains("PCP")).ToList(); + Penumbra.Log.Information($"[PCPService] Deleting {mods.Count} mods containing the tag PCP."); + foreach (var mod in mods) + _mods.DeleteMod(mod); + } + + public void CleanPcpCollections() + { + var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList(); + Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} mods containing the tag PCP."); + foreach (var collection in collections) + _collections.Storage.Delete(collection); + } + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) { if (type is not ModPathChangeType.Added || _config.DisablePcpHandling || newDirectory is null) @@ -80,12 +99,14 @@ public class PcpService : IApiService, IDisposable { // First version had collection.json, changed. var oldFile = Path.Combine(newDirectory.FullName, "collection.json"); + Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json."); if (File.Exists(oldFile)) File.Move(oldFile, file, true); else return; } + Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying."); var text = File.ReadAllText(file); var jObj = JObject.Parse(text); var identifier = _actors.FromJson(jObj["Actor"] as JObject); @@ -116,6 +137,7 @@ public class PcpService : IApiService, IDisposable // ignored. } } + if (_config.AllowPcpIpc) _communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id); } @@ -132,6 +154,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 (actor, identifier) = CheckActor(objectIndex); @@ -178,6 +201,7 @@ public class PcpService : IApiService, IDisposable { ["Version"] = 1, ["Actor"] = actor.ToJson(), + ["Mod"] = directory.Name, ["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(), ["Time"] = time, ["Note"] = note, diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 143709f4..a6d03593 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -52,6 +52,7 @@ public class SettingsTab : ITab, IUiService private readonly CollectionAutoSelector _autoSelector; private readonly CleanupService _cleanupService; private readonly AttributeHook _attributeHook; + private readonly PcpService _pcpService; private int _minimumX = int.MaxValue; private int _minimumY = int.MaxValue; @@ -64,7 +65,7 @@ public class SettingsTab : ITab, IUiService DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, - AttributeHook attributeHook) + AttributeHook attributeHook, PcpService pcpService) { _pluginInterface = pluginInterface; _config = config; @@ -90,6 +91,7 @@ public class SettingsTab : ITab, IUiService _autoSelector = autoSelector; _cleanupService = cleanupService; _attributeHook = attributeHook; + _pcpService = pcpService; } public void DrawHeader() @@ -601,6 +603,23 @@ public class SettingsTab : ITab, IUiService Checkbox("Handle PCP Files", "When encountering specific mods, usually but not necessarily denoted by a .pcp file ending, Penumbra will automatically try to create an associated collection and assign it to a specific character for this mod package. This can turn this behaviour off if unwanted.", !_config.DisablePcpHandling, v => _config.DisablePcpHandling = !v); + + var active = _config.DeleteModModifier.IsActive(); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Delete all PCP Mods"u8, "Deletes all mods tagged with 'PCP' from the mod list."u8, disabled: !active)) + _pcpService.CleanPcpMods(); + if (!active) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8, disabled: !active)) + _pcpService.CleanPcpCollections(); + if (!active) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); + + Checkbox("Allow Other Plugins Access to PCP Handling", + "When creating or importing PCP files, other plugins can add and interpret their own data to the character.json file.", + _config.AllowPcpIpc, v => _config.AllowPcpIpc = v); DrawDefaultModImportPath(); DrawDefaultModAuthor(); DrawDefaultModImportFolder(); From d302a17f1f2ca2f792acc8f28cf4722f3ded9be6 Mon Sep 17 00:00:00 2001 From: Actions User Date: Fri, 22 Aug 2025 18:33:43 +0000 Subject: [PATCH 07/35] [CI] Updating repo.json for testing_1.5.0.8 --- repo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo.json b/repo.json index cf4fe6cb..48d5b97f 100644 --- a/repo.json +++ b/repo.json @@ -6,7 +6,7 @@ "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", "AssemblyVersion": "1.5.0.6", - "TestingAssemblyVersion": "1.5.0.7", + "TestingAssemblyVersion": "1.5.0.8", "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.7/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.0.8/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" } From 6079103505c3b0b99c26e041f59c26c41b13a543 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Sat, 23 Aug 2025 14:46:19 +0200 Subject: [PATCH 08/35] Add collection PCP settings. --- Penumbra.Api | 2 +- Penumbra/Api/Api/ModsApi.cs | 2 +- Penumbra/Communication/PcpCreation.cs | 3 +- Penumbra/Configuration.cs | 19 ++++++---- Penumbra/Services/PcpService.cs | 51 ++++++++++++++++----------- Penumbra/UI/Tabs/SettingsTab.cs | 19 +++++++--- 6 files changed, 61 insertions(+), 35 deletions(-) diff --git a/Penumbra.Api b/Penumbra.Api index 0a970295..297941bc 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 0a970295b2398683b1e49c46fd613541e2486210 +Subproject commit 297941bc22300f4a8368f4d0177f62943eb69727 diff --git a/Penumbra/Api/Api/ModsApi.cs b/Penumbra/Api/Api/ModsApi.cs index 55f1e259..1f4f1cf4 100644 --- a/Penumbra/Api/Api/ModsApi.cs +++ b/Penumbra/Api/Api/ModsApi.cs @@ -108,7 +108,7 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable public event Action? ModAdded; public event Action? ModMoved; - public event Action? CreatingPcp + public event Action? CreatingPcp { add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi); remove => _communicator.PcpCreation.Unsubscribe(value!); diff --git a/Penumbra/Communication/PcpCreation.cs b/Penumbra/Communication/PcpCreation.cs index cb11b3c3..ca0cfcf6 100644 --- a/Penumbra/Communication/PcpCreation.cs +++ b/Penumbra/Communication/PcpCreation.cs @@ -8,9 +8,10 @@ namespace Penumbra.Communication; /// /// Parameter is the JObject that gets written to file. /// Parameter is the object index of the game object this is written for. +/// Parameter is the full path to the directory being set up for the PCP creation. /// /// -public sealed class PcpCreation() : EventWrapper(nameof(PcpCreation)) +public sealed class PcpCreation() : EventWrapper(nameof(PcpCreation)) { public enum Priority { diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index d9a9f5fe..f9cad217 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -18,6 +18,15 @@ using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs; namespace Penumbra; +public record PcpSettings +{ + public bool CreateCollection { get; set; } = true; + public bool AssignCollection { get; set; } = true; + public bool AllowIpc { get; set; } = true; + public bool DisableHandling { get; set; } = false; + public string FolderName { get; set; } = "PCP"; +} + [Serializable] public class Configuration : IPluginConfiguration, ISavable, IService { @@ -68,11 +77,10 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool DefaultTemporaryMode { get; set; } = false; public bool EnableCustomShapes { get; set; } = true; - public bool DisablePcpHandling { get; set; } = false; - public bool AllowPcpIpc { get; set; } = true; - public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; - public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; - public int OptionGroupCollapsibleMin { get; set; } = 5; + public PcpSettings PcpSettings = new(); + public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; + public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; + public int OptionGroupCollapsibleMin { get; set; } = 5; public Vector2 MinimumSize = new(Constants.MinimumSizeX, Constants.MinimumSizeY); @@ -90,7 +98,6 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool OpenFoldersByDefault { get; set; } = false; public int SingleGroupRadioMax { get; set; } = 2; public string DefaultImportFolder { get; set; } = string.Empty; - public string PcpFolderName { get; set; } = "PCP"; public string QuickMoveFolder1 { get; set; } = string.Empty; public string QuickMoveFolder2 { get; set; } = string.Empty; public string QuickMoveFolder3 { get; set; } = string.Empty; diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index 73c61cdb..b9d472aa 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -89,7 +89,7 @@ public class PcpService : IApiService, IDisposable private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) { - if (type is not ModPathChangeType.Added || _config.DisablePcpHandling || newDirectory is null) + if (type is not ModPathChangeType.Added || _config.PcpSettings.DisableHandling || newDirectory is null) return; try @@ -107,29 +107,37 @@ 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 identifier = _actors.FromJson(jObj["Actor"] as JObject); - if (!identifier.IsValid) - return; + var text = File.ReadAllText(file); + var jObj = JObject.Parse(text); + var collection = ModCollection.Empty; + // Create collection. + if (_config.PcpSettings.CreateCollection) + { + var identifier = _actors.FromJson(jObj["Actor"] as JObject); + if (identifier.IsValid && jObj["Collection"]?.ToObject() is { } collectionName) + { + var name = $"PCP/{collectionName}"; + if (_collections.Storage.AddCollection(name, null)) + { + collection = _collections.Storage[^1]; + _collections.Editor.SetModState(collection, mod, true); - if (jObj["Collection"]?.ToObject() is not { } collectionName) - return; + // Assign collection. + if (_config.PcpSettings.AssignCollection) + { + var identifierGroup = _collections.Active.Individuals.GetGroup(identifier); + _collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup); + } + } + } + } - var name = $"PCP/{collectionName}"; - if (!_collections.Storage.AddCollection(name, null)) - return; - - var collection = _collections.Storage[^1]; - _collections.Editor.SetModState(collection, mod, true); - - var identifierGroup = _collections.Active.Individuals.GetGroup(identifier); - _collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup); + // Move to folder. if (_fileSystem.TryGetValue(mod, out var leaf)) { try { - var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpFolderName); + var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpSettings.FolderName); _fileSystem.Move(leaf, folder); } catch @@ -138,7 +146,8 @@ public class PcpService : IApiService, IDisposable } } - if (_config.AllowPcpIpc) + // Invoke IPC. + if (_config.PcpSettings.AllowIpc) _communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id); } catch (Exception ex) @@ -208,8 +217,8 @@ public class PcpService : IApiService, IDisposable }; if (note.Length > 0) cancel.ThrowIfCancellationRequested(); - if (_config.AllowPcpIpc) - await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index)); + if (_config.PcpSettings.AllowIpc) + await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index, directory.FullName)); var filePath = Path.Combine(directory.FullName, "character.json"); await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); await using var stream = new StreamWriter(file); diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index a6d03593..ded56bb1 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -602,7 +602,7 @@ public class SettingsTab : ITab, IUiService _config.AlwaysOpenDefaultImport, v => _config.AlwaysOpenDefaultImport = v); Checkbox("Handle PCP Files", "When encountering specific mods, usually but not necessarily denoted by a .pcp file ending, Penumbra will automatically try to create an associated collection and assign it to a specific character for this mod package. This can turn this behaviour off if unwanted.", - !_config.DisablePcpHandling, v => _config.DisablePcpHandling = !v); + !_config.PcpSettings.DisableHandling, v => _config.PcpSettings.DisableHandling = !v); var active = _config.DeleteModModifier.IsActive(); ImGui.SameLine(); @@ -612,14 +612,23 @@ public class SettingsTab : ITab, IUiService ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); ImGui.SameLine(); - if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8, disabled: !active)) + if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8, + disabled: !active)) _pcpService.CleanPcpCollections(); if (!active) ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); Checkbox("Allow Other Plugins Access to PCP Handling", "When creating or importing PCP files, other plugins can add and interpret their own data to the character.json file.", - _config.AllowPcpIpc, v => _config.AllowPcpIpc = v); + _config.PcpSettings.AllowIpc, v => _config.PcpSettings.AllowIpc = v); + + Checkbox("Create PCP Collections", + "When importing PCP files, create the associated collection.", + _config.PcpSettings.CreateCollection, v => _config.PcpSettings.CreateCollection = v); + + Checkbox("Assign PCP Collections", + "When importing PCP files and creating the associated collection, assign it to the associated character.", + _config.PcpSettings.AssignCollection, v => _config.PcpSettings.AssignCollection = v); DrawDefaultModImportPath(); DrawDefaultModAuthor(); DrawDefaultModImportFolder(); @@ -736,10 +745,10 @@ public class SettingsTab : ITab, IUiService /// Draw input for the default folder to sort put newly imported mods into. private void DrawPcpFolder() { - var tmp = _config.PcpFolderName; + var tmp = _config.PcpSettings.FolderName; ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); if (ImUtf8.InputText("##pcpFolder"u8, ref tmp)) - _config.PcpFolderName = tmp; + _config.PcpSettings.FolderName = tmp; if (ImGui.IsItemDeactivatedAfterEdit()) _config.Save(); From c8b6325a8733cfdbae82cb90b4eb2d903c6c1ca6 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sun, 24 Aug 2025 08:34:54 +0200 Subject: [PATCH 09/35] Add game integrity message to On-Screen --- .../UI/AdvancedWindow/ResourceTreeViewer.cs | 25 +++++++++++++++++-- .../ResourceTreeViewerFactory.cs | 6 +++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs index a2309343..617ba30f 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -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 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; diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs index ac06fe1a..43b60716 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs @@ -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 drawActions) - => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService); + => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData); } From 1fca78fa71239882dc7f88df72c31440a8660cb4 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sun, 24 Aug 2025 06:39:38 +0200 Subject: [PATCH 10/35] Add Kdb files to ResourceTree --- Penumbra.GameData | 2 +- .../Processing/SkinMtrlPathEarlyProcessing.cs | 2 +- .../ResolveContext.PathResolution.cs | 28 +++++++++++++++++++ .../Interop/ResourceTree/ResolveContext.cs | 23 ++++++++++++++- Penumbra/Interop/ResourceTree/ResourceNode.cs | 4 ++- Penumbra/Interop/ResourceTree/ResourceTree.cs | 14 ++++++---- Penumbra/Interop/Structs/StructExtensions.cs | 9 ++++++ 7 files changed, 73 insertions(+), 9 deletions(-) diff --git a/Penumbra.GameData b/Penumbra.GameData index 15e7c8eb..73010350 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 15e7c8eb41867e6bbd3fe6a8885404df087bc7e7 +Subproject commit 73010350338ecd7b98ad85d127bed08d7d8718d4 diff --git a/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs index 4487eb7f..6be1b959 100644 --- a/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs +++ b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs @@ -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; diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs index b6d04769..c204f141 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs @@ -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; diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.cs b/Penumbra/Interop/ResourceTree/ResolveContext.cs index b2364e33..bbe9b8ce 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.cs @@ -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)'/'); diff --git a/Penumbra/Interop/ResourceTree/ResourceNode.cs b/Penumbra/Interop/ResourceTree/ResourceNode.cs index 3699ae0b..08dee818 100644 --- a/Penumbra/Interop/ResourceTree/ResourceNode.cs +++ b/Penumbra/Interop/ResourceTree/ResourceNode.cs @@ -45,7 +45,9 @@ public class ResourceNode : ICloneable /// Whether to treat the file as protected (require holding the Mod Deletion Modifier to make a quick import). 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) { diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs index ddef347d..23fe26b8 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTree.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -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 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 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}"; diff --git a/Penumbra/Interop/Structs/StructExtensions.cs b/Penumbra/Interop/Structs/StructExtensions.cs index 031d24b1..5a29bb6f 100644 --- a/Penumbra/Interop/Structs/StructExtensions.cs +++ b/Penumbra/Interop/Structs/StructExtensions.cs @@ -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)((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; From f51f8a7bf80f5560c9a88251cad8766a71e17692 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Sun, 24 Aug 2025 15:24:50 +0200 Subject: [PATCH 11/35] Try to filter meta entries for relevance. --- Penumbra/Meta/Manipulations/MetaDictionary.cs | 182 ++++++++++++++++++ Penumbra/Services/PcpService.cs | 20 +- 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/Penumbra/Meta/Manipulations/MetaDictionary.cs b/Penumbra/Meta/Manipulations/MetaDictionary.cs index c2c9e777..8b448ec6 100644 --- a/Penumbra/Meta/Manipulations/MetaDictionary.cs +++ b/Penumbra/Meta/Manipulations/MetaDictionary.cs @@ -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) + [ + 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; + } } diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index b9d472aa..f75d3b5e 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -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); From 1e07e434985ce55cd47d783d1e6dc7f48e29c7b9 Mon Sep 17 00:00:00 2001 From: Actions User Date: Sun, 24 Aug 2025 13:51:43 +0000 Subject: [PATCH 12/35] [CI] Updating repo.json for testing_1.5.0.9 --- repo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo.json b/repo.json index 48d5b97f..446932b5 100644 --- a/repo.json +++ b/repo.json @@ -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" } From a14347f73a39ae0579d721f9b77b05f3e989c8b0 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Mon, 25 Aug 2025 10:13:31 +0200 Subject: [PATCH 13/35] Update temporary collection creation. --- Penumbra.Api | 2 +- Penumbra/Api/Api/IdentityChecker.cs | 7 +++++++ Penumbra/Api/Api/TemporaryApi.cs | 12 ++++++++++-- Penumbra/Api/IpcTester/TemporaryIpcTester.cs | 4 +++- 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 Penumbra/Api/Api/IdentityChecker.cs diff --git a/Penumbra.Api b/Penumbra.Api index 297941bc..af41b178 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 297941bc22300f4a8368f4d0177f62943eb69727 +Subproject commit af41b1787acef9df7dc83619fe81e63a36443ee5 diff --git a/Penumbra/Api/Api/IdentityChecker.cs b/Penumbra/Api/Api/IdentityChecker.cs new file mode 100644 index 00000000..e090053e --- /dev/null +++ b/Penumbra/Api/Api/IdentityChecker.cs @@ -0,0 +1,7 @@ +namespace Penumbra.Api.Api; + +public static class IdentityChecker +{ + public static bool Check(string identity) + => true; +} diff --git a/Penumbra/Api/Api/TemporaryApi.cs b/Penumbra/Api/Api/TemporaryApi.cs index a997ded8..7567acd3 100644 --- a/Penumbra/Api/Api/TemporaryApi.cs +++ b/Penumbra/Api/Api/TemporaryApi.cs @@ -20,8 +20,16 @@ public class TemporaryApi( ApiHelpers apiHelpers, ModManager modManager) : IPenumbraApiTemporary, IApiService { - public Guid CreateTemporaryCollection(string name) - => tempCollections.CreateTemporaryCollection(name); + public (PenumbraApiEc, Guid) CreateTemporaryCollection(string identity, string name) + { + if (!IdentityChecker.Check(identity)) + return (PenumbraApiEc.InvalidCredentials, Guid.Empty); + + var collection = tempCollections.CreateTemporaryCollection(name); + if (collection == Guid.Empty) + return (PenumbraApiEc.UnknownError, collection); + return (PenumbraApiEc.Success, collection); + } public PenumbraApiEc DeleteTemporaryCollection(Guid collectionId) => tempCollections.RemoveTemporaryCollection(collectionId) diff --git a/Penumbra/Api/IpcTester/TemporaryIpcTester.cs b/Penumbra/Api/IpcTester/TemporaryIpcTester.cs index 64adf256..d46c5728 100644 --- a/Penumbra/Api/IpcTester/TemporaryIpcTester.cs +++ b/Penumbra/Api/IpcTester/TemporaryIpcTester.cs @@ -38,6 +38,7 @@ public class TemporaryIpcTester( private string _tempGamePath = "test/game/path.mtrl"; private string _tempFilePath = "test/success.mtrl"; private string _tempManipulation = string.Empty; + private string _identity = string.Empty; private PenumbraApiEc _lastTempError; private int _tempActorIndex; private bool _forceOverwrite; @@ -48,6 +49,7 @@ public class TemporaryIpcTester( if (!_) return; + ImGui.InputTextWithHint("##identity", "Identity...", ref _identity, 128); ImGui.InputTextWithHint("##tempCollection", "Collection Name...", ref _tempCollectionName, 128); ImGuiUtil.GuidInput("##guid", "Collection GUID...", string.Empty, ref _tempGuid, ref _tempCollectionGuidName); ImGui.InputInt("##tempActorIndex", ref _tempActorIndex, 0, 0); @@ -73,7 +75,7 @@ public class TemporaryIpcTester( IpcTester.DrawIntro(CreateTemporaryCollection.Label, "Create Temporary Collection"); if (ImGui.Button("Create##Collection")) { - LastCreatedCollectionId = new CreateTemporaryCollection(pi).Invoke(_tempCollectionName); + _lastTempError = new CreateTemporaryCollection(pi).Invoke(_identity, _tempCollectionName, out LastCreatedCollectionId); if (_tempGuid == null) { _tempGuid = LastCreatedCollectionId; From bf90725dd2db6b300577fa0c64d309b5277eedee Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Mon, 25 Aug 2025 10:13:39 +0200 Subject: [PATCH 14/35] Fix resolvecontext issue. --- Penumbra/Interop/ResourceTree/ResolveContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.cs b/Penumbra/Interop/ResourceTree/ResolveContext.cs index bbe9b8ce..501bbc56 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.cs @@ -440,7 +440,7 @@ internal unsafe partial record ResolveContext( if (Global.Nodes.TryGetValue((path, (nint)kdbHandle), out var cached)) return cached; - var node = CreateNode(ResourceType.Phyb, 0, kdbHandle, path, false); + var node = CreateNode(ResourceType.Kdb, 0, kdbHandle, path, false); if (Global.WithUiData) node.FallbackName = "KineDriver Module"; Global.Nodes.Add((path, (nint)kdbHandle), node); From 79a4fc5904501fb30dd879ec37d8513c328ea120 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Mon, 25 Aug 2025 10:13:48 +0200 Subject: [PATCH 15/35] Fix wrong logging. --- Penumbra/Services/PcpService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index f75d3b5e..63b8eab3 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -99,9 +99,11 @@ public class PcpService : IApiService, IDisposable { // First version had collection.json, changed. var oldFile = Path.Combine(newDirectory.FullName, "collection.json"); - Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json."); if (File.Exists(oldFile)) + { + Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json."); File.Move(oldFile, file, true); + } else return; } From e16800f21649447cc316fa9ce8c7d88518ad19dd Mon Sep 17 00:00:00 2001 From: Actions User Date: Mon, 25 Aug 2025 08:16:04 +0000 Subject: [PATCH 16/35] [CI] Updating repo.json for testing_1.5.0.10 --- repo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo.json b/repo.json index 446932b5..dea56357 100644 --- a/repo.json +++ b/repo.json @@ -6,7 +6,7 @@ "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", "AssemblyVersion": "1.5.0.6", - "TestingAssemblyVersion": "1.5.0.9", + "TestingAssemblyVersion": "1.5.0.10", "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.9/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.0.10/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" } From da47c19aeb30fcc293308652503b5cf1985a390d Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Mon, 25 Aug 2025 10:25:05 +0200 Subject: [PATCH 17/35] Woops, increment version. --- Penumbra/Api/Api/PenumbraApi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra/Api/Api/PenumbraApi.cs b/Penumbra/Api/Api/PenumbraApi.cs index 9e7eb964..7304c9c7 100644 --- a/Penumbra/Api/Api/PenumbraApi.cs +++ b/Penumbra/Api/Api/PenumbraApi.cs @@ -17,7 +17,7 @@ public class PenumbraApi( UiApi ui) : IDisposable, IApiService, IPenumbraApi { public const int BreakingVersion = 5; - public const int FeatureVersion = 11; + public const int FeatureVersion = 12; public void Dispose() { From c0120f81af3a713f861f275ad379a18ed14c0091 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Mon, 25 Aug 2025 10:37:38 +0200 Subject: [PATCH 18/35] 1.5.1.0 --- Penumbra/Penumbra.json | 2 +- Penumbra/UI/Changelog.cs | 22 ++++++++++++++++-- repo.json | 48 ++++++++++++++++++++-------------------- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/Penumbra/Penumbra.json b/Penumbra/Penumbra.json index bd9a2479..32032282 100644 --- a/Penumbra/Penumbra.json +++ b/Penumbra/Penumbra.json @@ -1,5 +1,5 @@ { - "Author": "Ottermandias, Adam, Wintermute", + "Author": "Ottermandias, Nylfae, Adam, Wintermute", "Name": "Penumbra", "Punchline": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.", diff --git a/Penumbra/UI/Changelog.cs b/Penumbra/UI/Changelog.cs index 4b487104..306dcc79 100644 --- a/Penumbra/UI/Changelog.cs +++ b/Penumbra/UI/Changelog.cs @@ -63,10 +63,28 @@ public class PenumbraChangelog : IUiService Add1_3_6_4(Changelog); Add1_4_0_0(Changelog); Add1_5_0_0(Changelog); - } - + Add1_5_1_0(Changelog); + } + #region Changelogs + private static void Add1_5_1_0(Changelog log) + => log.NextVersion("Version 1.5.1.0") + .RegisterHighlight("Added the option to export a characters current data as a .pcp modpack in the On-Screen tab.") + .RegisterEntry("Other plugins can attach to this functionality and package and interpret their own data.", 1) + .RegisterEntry("When a .pcp modpack is installed, it can create and assign collections for the corresponding character it was created for.", 1) + .RegisterEntry("This basically provides an easier way to manually synchronize other players, but does not contain any automation.", 1) + .RegisterEntry("The settings provide some fine control about what happens when a PCP is installed, as well as buttons to cleanup any PCP-created data.", 1) + .RegisterEntry("Added a warning message when the game's integrity is corrupted to the On-Screen tab.") + .RegisterEntry("Added .kdb files to the On-Screen tab and associated functionality (thanks Ny!).") + .RegisterEntry("Updated the creation of temporary collections to require a passed identity.") + .RegisterEntry("Added the option to change the skin material suffix in models using the stockings shader by adding specific attributes (thanks Ny!).") + .RegisterEntry("Added predefined tag utility to the multi-mod selection.") + .RegisterEntry("Fixed an issue with the automatic collection selection on character login when no mods are assigned.") + .RegisterImportant( + "Fixed issue with new deformer data that makes modded deformers not containing this data work implicitly. Updates are still recommended (1.5.0.5).") + .RegisterEntry("Fixed various issues after patch (1.5.0.1 - 1.5.0.4)."); + private static void Add1_5_0_0(Changelog log) => log.NextVersion("Version 1.5.0.0") .RegisterImportant("Updated for game version 7.30 and Dalamud API13, which uses a new GUI backend. Some things may not work as expected. Please let me know any issues you encounter.") diff --git a/repo.json b/repo.json index dea56357..4675bccf 100644 --- a/repo.json +++ b/repo.json @@ -1,26 +1,26 @@ [ - { - "Author": "Ottermandias, Adam, Wintermute", - "Name": "Penumbra", - "Punchline": "Runtime mod loader and manager.", - "Description": "Runtime mod loader and manager.", - "InternalName": "Penumbra", - "AssemblyVersion": "1.5.0.6", - "TestingAssemblyVersion": "1.5.0.10", - "RepoUrl": "https://github.com/xivdev/Penumbra", - "ApplicableVersion": "any", - "DalamudApiLevel": 13, - "TestingDalamudApiLevel": 13, - "IsHide": "False", - "IsTestingExclusive": "False", - "DownloadCount": 0, - "LastUpdate": 0, - "LoadPriority": 69420, - "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.10/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" - } + { + "Author": "Ottermandias, Nylfae, Adam, Wintermute", + "Name": "Penumbra", + "Punchline": "Runtime mod loader and manager.", + "Description": "Runtime mod loader and manager.", + "InternalName": "Penumbra", + "AssemblyVersion": "1.5.0.6", + "TestingAssemblyVersion": "1.5.0.10", + "RepoUrl": "https://github.com/xivdev/Penumbra", + "ApplicableVersion": "any", + "DalamudApiLevel": 13, + "TestingDalamudApiLevel": 13, + "IsHide": "False", + "IsTestingExclusive": "False", + "DownloadCount": 0, + "LastUpdate": 0, + "LoadPriority": 69420, + "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.10/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" + } ] From 71e24c13c7915e4741fe20fa86cc6dbebf1d2355 Mon Sep 17 00:00:00 2001 From: Actions User Date: Mon, 25 Aug 2025 08:39:42 +0000 Subject: [PATCH 19/35] [CI] Updating repo.json for 1.5.1.0 --- repo.json | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/repo.json b/repo.json index 4675bccf..e9a52799 100644 --- a/repo.json +++ b/repo.json @@ -1,26 +1,26 @@ [ - { - "Author": "Ottermandias, Nylfae, Adam, Wintermute", - "Name": "Penumbra", - "Punchline": "Runtime mod loader and manager.", - "Description": "Runtime mod loader and manager.", - "InternalName": "Penumbra", - "AssemblyVersion": "1.5.0.6", - "TestingAssemblyVersion": "1.5.0.10", - "RepoUrl": "https://github.com/xivdev/Penumbra", - "ApplicableVersion": "any", - "DalamudApiLevel": 13, - "TestingDalamudApiLevel": 13, - "IsHide": "False", - "IsTestingExclusive": "False", - "DownloadCount": 0, - "LastUpdate": 0, - "LoadPriority": 69420, - "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.10/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" - } + { + "Author": "Ottermandias, Nylfae, Adam, Wintermute", + "Name": "Penumbra", + "Punchline": "Runtime mod loader and manager.", + "Description": "Runtime mod loader and manager.", + "InternalName": "Penumbra", + "AssemblyVersion": "1.5.1.0", + "TestingAssemblyVersion": "1.5.1.0", + "RepoUrl": "https://github.com/xivdev/Penumbra", + "ApplicableVersion": "any", + "DalamudApiLevel": 13, + "TestingDalamudApiLevel": 13, + "IsHide": "False", + "IsTestingExclusive": "False", + "DownloadCount": 0, + "LastUpdate": 0, + "LoadPriority": 69420, + "LoadRequiredState": 2, + "LoadSync": true, + "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.0/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.0/Penumbra.zip", + "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.0/Penumbra.zip", + "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" + } ] From a04a5a071c99585f4d4bd749fc6b4f8b9d4dce99 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Thu, 28 Aug 2025 18:51:57 +0200 Subject: [PATCH 20/35] Add warning in file redirections if extension doesn't match. --- Penumbra.Api | 2 +- .../UI/AdvancedWindow/ModEditWindow.Files.cs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Penumbra.Api b/Penumbra.Api index af41b178..953dd227 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit af41b1787acef9df7dc83619fe81e63a36443ee5 +Subproject commit 953dd227afda6b3943b0b88cc965d8aee8a879b5 diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs index 87d7487b..63c99b8a 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs @@ -287,6 +287,17 @@ public partial class ModEditWindow using var font = ImRaii.PushFont(UiBuilder.IconFont); ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); } + else if (tmp.Length > 0 && Path.GetExtension(tmp) != registry.File.Extension) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF00B0B0, FontAwesomeIcon.ExclamationCircle.ToIconString()); + } + + ImUtf8.HoverTooltip("The game path and the file do not have the same extension."u8); + } } private void PrintNewGamePath(int i, FileRegistry registry, IModDataContainer subMod) @@ -319,6 +330,17 @@ public partial class ModEditWindow using var font = ImRaii.PushFont(UiBuilder.IconFont); ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); } + else if (tmp.Length > 0 && Path.GetExtension(tmp) != registry.File.Extension) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF00B0B0, FontAwesomeIcon.ExclamationCircle.ToIconString()); + } + + ImUtf8.HoverTooltip("The game path and the file do not have the same extension."u8); + } } private void DrawButtonHeader() From f7cf5503bbd4c31b59c081f91b966afbc291b1f3 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Thu, 28 Aug 2025 18:52:06 +0200 Subject: [PATCH 21/35] Fix deleting PCP collections. --- Penumbra/Services/PcpService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index 63b8eab3..bdf1adc5 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -84,7 +84,7 @@ public class PcpService : IApiService, IDisposable var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList(); Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} mods containing the tag PCP."); foreach (var collection in collections) - _collections.Storage.Delete(collection); + _collections.Storage.RemoveCollection(collection); } private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) From 912020cc3f9a08324bb2515b0a35f22b720051cc Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Fri, 29 Aug 2025 16:36:42 +0200 Subject: [PATCH 22/35] Update for staging and wrong tooltip. --- Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs | 5 ++--- Penumbra/Services/PcpService.cs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs index 6be1b959..bd066d83 100644 --- a/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs +++ b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs @@ -38,10 +38,9 @@ public static unsafe class SkinMtrlPathEarlyProcessing if (character is null) return null; - if (character->TempSlotData is not null) + if (character->PerSlotStagingArea is not null) { - // TODO ClientStructs-ify (aers/FFXIVClientStructs#1564) - var handle = *(ModelResourceHandle**)((nint)character->TempSlotData + 0xE0 * slotIndex + 0x8); + var handle = character->PerSlotStagingArea[slotIndex].ModelResourceHandle; if (handle != null) return handle; } diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs index bdf1adc5..17646564 100644 --- a/Penumbra/Services/PcpService.cs +++ b/Penumbra/Services/PcpService.cs @@ -82,7 +82,7 @@ public class PcpService : IApiService, IDisposable public void CleanPcpCollections() { var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList(); - Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} mods containing the tag PCP."); + Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} collections starting with PCP/."); foreach (var collection in collections) _collections.Storage.RemoveCollection(collection); } From 8c25ef4b47486df7b79c63d66c78fcf7710f2112 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sat, 30 Aug 2025 16:53:12 +0200 Subject: [PATCH 23/35] Make the save button ResourceTreeViewer baseline --- .../ModEditWindow.QuickImport.cs | 62 +---------- Penumbra/UI/AdvancedWindow/ModEditWindow.cs | 2 +- .../UI/AdvancedWindow/ResourceTreeViewer.cs | 104 ++++++++++++++---- .../ResourceTreeViewerFactory.cs | 11 +- 4 files changed, 95 insertions(+), 84 deletions(-) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs index 72350857..f55ae576 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs @@ -17,7 +17,6 @@ public partial class ModEditWindow private readonly FileDialogService _fileDialog; private readonly ResourceTreeFactory _resourceTreeFactory; private readonly ResourceTreeViewer _quickImportViewer; - private readonly Dictionary _quickImportWritables = new(); private readonly Dictionary<(Utf8GamePath, IWritable?), QuickImportAction> _quickImportActions = new(); private HashSet GetPlayerResourcesOfType(ResourceType type) @@ -56,52 +55,11 @@ public partial class ModEditWindow private void OnQuickImportRefresh() { - _quickImportWritables.Clear(); _quickImportActions.Clear(); } - private void DrawQuickImportActions(ResourceNode resourceNode, Vector2 buttonSize) + private void DrawQuickImportActions(ResourceNode resourceNode, IWritable? writable, Vector2 buttonSize) { - if (!_quickImportWritables!.TryGetValue(resourceNode.FullPath, out var writable)) - { - var path = resourceNode.FullPath.ToPath(); - if (resourceNode.FullPath.IsRooted) - { - writable = new RawFileWritable(path); - } - else - { - var file = _gameData.GetFile(path); - writable = file is null ? null : new RawGameFileWritable(file); - } - - _quickImportWritables.Add(resourceNode.FullPath, writable); - } - - if (ImUtf8.IconButton(FontAwesomeIcon.Save, "Export this file."u8, buttonSize, - resourceNode.FullPath.FullName.Length is 0 || writable is null)) - { - var fullPathStr = resourceNode.FullPath.FullName; - var ext = resourceNode.PossibleGamePaths.Length == 1 - ? Path.GetExtension(resourceNode.GamePath.ToString()) - : Path.GetExtension(fullPathStr); - _fileDialog.OpenSavePicker($"Export {Path.GetFileName(fullPathStr)} to...", ext, Path.GetFileNameWithoutExtension(fullPathStr), ext, - (success, name) => - { - if (!success) - return; - - try - { - _editor.Compactor.WriteAllBytes(name, writable!.Write()); - } - catch (Exception e) - { - Penumbra.Log.Error($"Could not export {fullPathStr}:\n{e}"); - } - }, null, false); - } - ImGui.SameLine(); if (!_quickImportActions!.TryGetValue((resourceNode.GamePath, writable), out var quickImport)) { @@ -121,24 +79,6 @@ public partial class ModEditWindow } } - private record RawFileWritable(string Path) : IWritable - { - public bool Valid - => true; - - public byte[] Write() - => File.ReadAllBytes(Path); - } - - private record RawGameFileWritable(FileResource FileResource) : IWritable - { - public bool Valid - => true; - - public byte[] Write() - => FileResource.Data; - } - public class QuickImportAction { public const string FallbackOptionName = "the current option"; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 952d8489..5a0fb849 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -667,7 +667,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService _center = new CombinedTexture(_left, _right); _textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex)); _resourceTreeFactory = resourceTreeFactory; - _quickImportViewer = resourceTreeViewerFactory.Create(2, OnQuickImportRefresh, DrawQuickImportActions); + _quickImportViewer = resourceTreeViewerFactory.Create(1, OnQuickImportRefresh, DrawQuickImportActions); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow); IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true }; if (IsOpen && selection.Mod != null) diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs index 617ba30f..00003451 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -4,16 +4,20 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.Utility; using Dalamud.Plugin.Services; +using Lumina.Data; using OtterGui; using OtterGui.Classes; +using OtterGui.Compression; using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using Penumbra.Api.Enums; +using Penumbra.GameData.Files; using Penumbra.GameData.Structs; using Penumbra.Interop.ResourceTree; using Penumbra.Services; using Penumbra.String; +using Penumbra.String.Classes; using Penumbra.UI.Classes; namespace Penumbra.UI.AdvancedWindow; @@ -25,17 +29,20 @@ public class ResourceTreeViewer( IncognitoService incognito, int actionCapacity, Action onRefresh, - Action drawActions, + Action drawActions, CommunicatorService communicator, PcpService pcpService, - IDataManager gameData) + IDataManager gameData, + FileDialogService fileDialog, + FileCompactor compactor) { private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags = ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; private readonly HashSet _unfolded = []; - private readonly Dictionary _filterCache = []; + private readonly Dictionary _filterCache = []; + private readonly Dictionary _writableCache = []; private TreeCategory _categoryFilter = AllCategories; private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags; @@ -115,7 +122,7 @@ public class ResourceTreeViewer( ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8); - using var table = ImRaii.Table("##ResourceTree", actionCapacity > 0 ? 4 : 3, + using var table = ImRaii.Table("##ResourceTree", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); if (!table) continue; @@ -123,9 +130,8 @@ public class ResourceTreeViewer( ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthStretch, 0.2f); ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthStretch, 0.3f); ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f); - if (actionCapacity > 0) - ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, - (actionCapacity - 1) * 3 * ImGuiHelpers.GlobalScale + actionCapacity * ImGui.GetFrameHeight()); + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, + actionCapacity * 3 * ImGuiHelpers.GlobalScale + (actionCapacity + 1) * ImGui.GetFrameHeight()); ImGui.TableHeadersRow(); DrawNodes(tree.Nodes, 0, unchecked(tree.DrawObjectAddress * 31), 0); @@ -211,6 +217,7 @@ public class ResourceTreeViewer( finally { _filterCache.Clear(); + _writableCache.Clear(); _unfolded.Clear(); onRefresh(); } @@ -221,7 +228,6 @@ public class ResourceTreeViewer( { var debugMode = config.DebugMode; var frameHeight = ImGui.GetFrameHeight(); - var cellHeight = actionCapacity > 0 ? frameHeight : 0.0f; foreach (var (resourceNode, index) in resourceNodes.WithIndex()) { @@ -291,7 +297,7 @@ public class ResourceTreeViewer( 0 => "(none)", 1 => resourceNode.GamePath.ToString(), _ => "(multiple)", - }, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + }, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); if (hasGamePaths) { var allPaths = string.Join('\n', resourceNode.PossibleGamePaths); @@ -312,7 +318,7 @@ public class ResourceTreeViewer( using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value())) { ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap, - new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); } ImGui.SameLine(); @@ -322,7 +328,7 @@ public class ResourceTreeViewer( else { ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, - new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); } if (ImGui.IsItemClicked()) @@ -336,20 +342,17 @@ public class ResourceTreeViewer( else { ImUtf8.Selectable(GetPathStatusLabel(resourceNode.FullPathStatus), false, ImGuiSelectableFlags.Disabled, - new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); ImGuiUtil.HoverTooltip( $"{GetPathStatusDescription(resourceNode.FullPathStatus)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); } mutedColor.Dispose(); - if (actionCapacity > 0) - { - ImGui.TableNextColumn(); - using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, - ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale }); - drawActions(resourceNode, new Vector2(frameHeight)); - } + ImGui.TableNextColumn(); + using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, + ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale }); + DrawActions(resourceNode, new Vector2(frameHeight)); if (unfolded) DrawNodes(resourceNode.Children, level + 1, unchecked(nodePathHash * 31), filterIcon); @@ -402,6 +405,51 @@ public class ResourceTreeViewer( || node.FullPath.InternalName.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase) || Array.Exists(node.PossibleGamePaths, path => path.Path.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase)); } + + void DrawActions(ResourceNode resourceNode, Vector2 buttonSize) + { + if (!_writableCache!.TryGetValue(resourceNode.FullPath, out var writable)) + { + var path = resourceNode.FullPath.ToPath(); + if (resourceNode.FullPath.IsRooted) + { + writable = new RawFileWritable(path); + } + else + { + var file = gameData.GetFile(path); + writable = file is null ? null : new RawGameFileWritable(file); + } + + _writableCache.Add(resourceNode.FullPath, writable); + } + + if (ImUtf8.IconButton(FontAwesomeIcon.Save, "Export this file."u8, buttonSize, + resourceNode.FullPath.FullName.Length is 0 || writable is null)) + { + var fullPathStr = resourceNode.FullPath.FullName; + var ext = resourceNode.PossibleGamePaths.Length == 1 + ? Path.GetExtension(resourceNode.GamePath.ToString()) + : Path.GetExtension(fullPathStr); + fileDialog.OpenSavePicker($"Export {Path.GetFileName(fullPathStr)} to...", ext, Path.GetFileNameWithoutExtension(fullPathStr), ext, + (success, name) => + { + if (!success) + return; + + try + { + compactor.WriteAllBytes(name, writable!.Write()); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not export {fullPathStr}:\n{e}"); + } + }, null, false); + } + + drawActions(resourceNode, writable, new Vector2(frameHeight)); + } } private static ReadOnlySpan GetPathStatusLabel(ResourceNode.PathStatus status) @@ -465,4 +513,22 @@ public class ResourceTreeViewer( Visible = 1, DescendentsOnly = 2, } + + private record RawFileWritable(string Path) : IWritable + { + public bool Valid + => true; + + public byte[] Write() + => File.ReadAllBytes(Path); + } + + private record RawGameFileWritable(FileResource FileResource) : IWritable + { + public bool Valid + => true; + + public byte[] Write() + => FileResource.Data; + } } diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs index 43b60716..6518ae67 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs @@ -1,5 +1,7 @@ using Dalamud.Plugin.Services; +using OtterGui.Compression; using OtterGui.Services; +using Penumbra.GameData.Files; using Penumbra.Interop.ResourceTree; using Penumbra.Services; @@ -12,8 +14,11 @@ public class ResourceTreeViewerFactory( IncognitoService incognito, CommunicatorService communicator, PcpService pcpService, - IDataManager gameData) : IService + IDataManager gameData, + FileDialogService fileDialog, + FileCompactor compactor) : IService { - public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action drawActions) - => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData); + public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action drawActions) + => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData, + fileDialog, compactor); } From b3379a97105d37f685dd0686d89d0bf27c1c0807 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sat, 30 Aug 2025 16:55:20 +0200 Subject: [PATCH 24/35] Stop redacting external paths --- Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs index 00003451..cb765fcf 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -37,7 +37,7 @@ public class ResourceTreeViewer( FileCompactor compactor) { private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags = - ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; + ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; private readonly HashSet _unfolded = []; From f3ec4b2e081a4cb477f7c85189ac1525586f97c7 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sat, 30 Aug 2025 19:19:07 +0200 Subject: [PATCH 25/35] Only display the file name and last dir for externals --- Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs index cb765fcf..ae450bec 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -325,6 +325,18 @@ public class ResourceTreeViewer( ImGui.SetCursorPosX(textPos); ImUtf8.Text(resourceNode.ModRelativePath); } + else if (resourceNode.FullPath.IsRooted) + { + var path = resourceNode.FullPath.FullName; + var lastDirectorySeparator = path.LastIndexOf('\\'); + var secondLastDirectorySeparator = lastDirectorySeparator > 0 + ? path.LastIndexOf('\\', lastDirectorySeparator - 1) + : -1; + if (secondLastDirectorySeparator >= 0) + path = $"…{path.AsSpan(secondLastDirectorySeparator)}"; + ImGui.Selectable(path.AsSpan(), false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } else { ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, From 5503bb32e059ed1438ebb139c5da6306e870f3b2 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sun, 31 Aug 2025 04:13:56 +0200 Subject: [PATCH 26/35] CloudApi testing in Debug tab --- Penumbra/Interop/CloudApi.cs | 29 ++++++++++++++++++++++ Penumbra/UI/Tabs/Debug/DebugTab.cs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 Penumbra/Interop/CloudApi.cs diff --git a/Penumbra/Interop/CloudApi.cs b/Penumbra/Interop/CloudApi.cs new file mode 100644 index 00000000..9ec29fa5 --- /dev/null +++ b/Penumbra/Interop/CloudApi.cs @@ -0,0 +1,29 @@ +namespace Penumbra.Interop; + +public static unsafe partial class CloudApi +{ + private const int CfSyncRootInfoBasic = 0; + + public static bool IsCloudSynced(string path) + { + var buffer = stackalloc long[1]; + var hr = CfGetSyncRootInfoByPath(path, CfSyncRootInfoBasic, buffer, sizeof(long), out var length); + Penumbra.Log.Information($"{nameof(CfGetSyncRootInfoByPath)} returned HRESULT {hr}"); + if (hr < 0) + return false; + + if (length != sizeof(long)) + { + Penumbra.Log.Warning($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes"); + return false; + } + + Penumbra.Log.Information($"{nameof(CfGetSyncRootInfoByPath)} returned {{ SyncRootFileId = 0x{*buffer:X16} }}"); + + return true; + } + + [LibraryImport("cldapi.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial int CfGetSyncRootInfoByPath(string filePath, int infoClass, void* infoBuffer, uint infoBufferLength, + out uint returnedLength); +} diff --git a/Penumbra/UI/Tabs/Debug/DebugTab.cs b/Penumbra/UI/Tabs/Debug/DebugTab.cs index d41dd25a..05f77e29 100644 --- a/Penumbra/UI/Tabs/Debug/DebugTab.cs +++ b/Penumbra/UI/Tabs/Debug/DebugTab.cs @@ -9,6 +9,7 @@ using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.System.Resource; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Colors; using Microsoft.Extensions.DependencyInjection; using OtterGui; using OtterGui.Classes; @@ -41,6 +42,7 @@ using Penumbra.GameData.Data; using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.ResourceLoading; using Penumbra.GameData.Files.StainMapStructs; +using Penumbra.Interop; using Penumbra.String.Classes; using Penumbra.UI.AdvancedWindow.Materials; @@ -206,6 +208,7 @@ public class DebugTab : Window, ITab, IUiService _hookOverrides.Draw(); DrawPlayerModelInfo(); _globalVariablesDrawer.Draw(); + DrawCloudApi(); DrawDebugTabIpc(); } @@ -1199,6 +1202,42 @@ public class DebugTab : Window, ITab, IUiService } + private string _cloudTesterPath = string.Empty; + private bool? _cloudTesterReturn; + private Exception? _cloudTesterError; + + private void DrawCloudApi() + { + if (!ImUtf8.CollapsingHeader("Cloud API"u8)) + return; + + using var id = ImRaii.PushId("CloudApiTester"u8); + + if (ImUtf8.InputText("Path"u8, ref _cloudTesterPath, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + { + try + { + _cloudTesterReturn = CloudApi.IsCloudSynced(_cloudTesterPath); + _cloudTesterError = null; + } + catch (Exception e) + { + _cloudTesterReturn = null; + _cloudTesterError = e; + } + } + + if (_cloudTesterReturn.HasValue) + ImUtf8.Text($"Is Cloud Synced? {_cloudTesterReturn}"); + + if (_cloudTesterError is not null) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + ImUtf8.Text($"{_cloudTesterError}"); + } + } + + /// Draw information about IPC options and availability. private void DrawDebugTabIpc() { From d59be1e660e26adce11664ffdbef5631e2511aeb Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sun, 31 Aug 2025 05:25:37 +0200 Subject: [PATCH 27/35] Refine IsCloudSynced --- Penumbra/Interop/CloudApi.cs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Penumbra/Interop/CloudApi.cs b/Penumbra/Interop/CloudApi.cs index 9ec29fa5..603d4c9f 100644 --- a/Penumbra/Interop/CloudApi.cs +++ b/Penumbra/Interop/CloudApi.cs @@ -4,21 +4,39 @@ public static unsafe partial class CloudApi { private const int CfSyncRootInfoBasic = 0; + /// Determines whether a file or directory is cloud-synced using OneDrive or other providers that use the Cloud API. + /// Can be expensive. Callers should cache the result when relevant. public static bool IsCloudSynced(string path) { - var buffer = stackalloc long[1]; - var hr = CfGetSyncRootInfoByPath(path, CfSyncRootInfoBasic, buffer, sizeof(long), out var length); - Penumbra.Log.Information($"{nameof(CfGetSyncRootInfoByPath)} returned HRESULT {hr}"); + var buffer = stackalloc long[1]; + int hr; + uint length; + try + { + hr = CfGetSyncRootInfoByPath(path, CfSyncRootInfoBasic, buffer, sizeof(long), out length); + } + catch (DllNotFoundException) + { + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw DllNotFoundException"); + return false; + } + catch (EntryPointNotFoundException) + { + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw EntryPointNotFoundException"); + return false; + } + + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned HRESULT 0x{hr:X8}"); if (hr < 0) return false; if (length != sizeof(long)) { - Penumbra.Log.Warning($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes"); + Penumbra.Log.Debug($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes"); return false; } - Penumbra.Log.Information($"{nameof(CfGetSyncRootInfoByPath)} returned {{ SyncRootFileId = 0x{*buffer:X16} }}"); + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned {{ SyncRootFileId = 0x{*buffer:X16} }}"); return true; } From 2cf60b78cd73f01b6207325a2359663b39745079 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sun, 31 Aug 2025 06:42:45 +0200 Subject: [PATCH 28/35] Reject and warn about cloud-synced base directories --- Penumbra/Mods/Manager/ModManager.cs | 4 ++++ Penumbra/Penumbra.cs | 13 ++++++++----- Penumbra/UI/Tabs/SettingsTab.cs | 13 +++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Penumbra/Mods/Manager/ModManager.cs b/Penumbra/Mods/Manager/ModManager.cs index 32dac049..77385bbd 100644 --- a/Penumbra/Mods/Manager/ModManager.cs +++ b/Penumbra/Mods/Manager/ModManager.cs @@ -1,5 +1,6 @@ using OtterGui.Services; using Penumbra.Communication; +using Penumbra.Interop; using Penumbra.Mods.Editor; using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Services; @@ -303,6 +304,9 @@ public sealed class ModManager : ModStorage, IDisposable, IService if (!firstTime && _config.ModDirectory != BasePath.FullName) TriggerModDirectoryChange(BasePath.FullName, Valid); } + + if (CloudApi.IsCloudSynced(BasePath.FullName)) + Penumbra.Log.Warning($"Mod base directory {BasePath.FullName} is cloud-synced. This may cause issues."); } private void TriggerModDirectoryChange(string newPath, bool valid) diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index b22d049d..f036adc7 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -23,6 +23,7 @@ using Dalamud.Plugin.Services; using Lumina.Excel.Sheets; using Penumbra.GameData; using Penumbra.GameData.Data; +using Penumbra.Interop; using Penumbra.Interop.Hooks; using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.ResourceLoading; @@ -211,10 +212,11 @@ public class Penumbra : IDalamudPlugin public string GatherSupportInformation() { - var sb = new StringBuilder(10240); - var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); - var hdrEnabler = _services.GetService(); - var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null; + var sb = new StringBuilder(10240); + var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); + var cloudSynced = exists && CloudApi.IsCloudSynced(_config.ModDirectory); + var hdrEnabler = _services.GetService(); + var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null; sb.AppendLine("**Settings**"); sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n"); sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n"); @@ -223,7 +225,8 @@ public class Penumbra : IDalamudPlugin sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n"); if (Dalamud.Utility.Util.IsWine()) sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n"); - sb.Append($"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}\n"); + sb.Append( + $"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}{(cloudSynced ? ", Cloud-Synced" : "")}\n"); sb.Append( $"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n"); sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n"); diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index ded56bb1..308cc471 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -14,6 +14,7 @@ using OtterGui.Text; using OtterGui.Widgets; using Penumbra.Api; using Penumbra.Collections; +using Penumbra.Interop; using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Services; using Penumbra.Mods.Manager; @@ -59,6 +60,9 @@ public class SettingsTab : ITab, IUiService private readonly TagButtons _sharedTags = new(); + private string _lastCloudSyncTestedPath = string.Empty; + private bool _lastCloudSyncTestResult = false; + public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, HttpApi httpApi, @@ -208,6 +212,15 @@ public class SettingsTab : ITab, IUiService if (IsSubPathOf(gameDir, newName)) return ("Path is not allowed to be inside your game folder.", false); + if (_lastCloudSyncTestedPath != newName) + { + _lastCloudSyncTestResult = CloudApi.IsCloudSynced(newName); + _lastCloudSyncTestedPath = newName; + } + + if (_lastCloudSyncTestResult) + return ("Path is not allowed to be cloud-synced.", false); + return selected ? ($"Press Enter or Click Here to Save (Current Directory: {old})", true) : ($"Click Here to Save (Current Directory: {old})", true); From ad1659caf637c6919f4cb3f03e918496cf5fc23b Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Tue, 2 Sep 2025 11:29:58 +0200 Subject: [PATCH 29/35] Update libraries. --- OtterGui | 2 +- Penumbra.Api | 2 +- Penumbra.CrashHandler/Penumbra.CrashHandler.csproj | 2 +- Penumbra.GameData | 2 +- Penumbra.String | 2 +- Penumbra/Penumbra.csproj | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/OtterGui b/OtterGui index 4a9b71a9..f3544447 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 4a9b71a93e76aa5eed818542288329e34ec0dd89 +Subproject commit f354444776591ae423e2d8374aae346308d81424 diff --git a/Penumbra.Api b/Penumbra.Api index 953dd227..dd141317 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 953dd227afda6b3943b0b88cc965d8aee8a879b5 +Subproject commit dd14131793e5ae47cc8e9232f46469216017b5aa diff --git a/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj index abcb8e3d..1b1f0a28 100644 --- a/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj +++ b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/Penumbra.GameData b/Penumbra.GameData index 73010350..3450df1f 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 73010350338ecd7b98ad85d127bed08d7d8718d4 +Subproject commit 3450df1f377543a226ded705e3db9e77ed2a0510 diff --git a/Penumbra.String b/Penumbra.String index 878acce4..c8611a0c 160000 --- a/Penumbra.String +++ b/Penumbra.String @@ -1 +1 @@ -Subproject commit 878acce46e286867d6ef1f8ecedb390f7bac34fd +Subproject commit c8611a0c546b6b2ec29214ab319fc2c38fe74793 diff --git a/Penumbra/Penumbra.csproj b/Penumbra/Penumbra.csproj index 3159b736..fa45ffbf 100644 --- a/Penumbra/Penumbra.csproj +++ b/Penumbra/Penumbra.csproj @@ -1,4 +1,4 @@ - + Penumbra absolute gangstas From 4e788f7c2bfb5bf04f8e22d6ac56b489ff6ad942 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Tue, 2 Sep 2025 11:51:59 +0200 Subject: [PATCH 30/35] Update sig. --- Penumbra.GameData | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra.GameData b/Penumbra.GameData index 3450df1f..27893a85 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 3450df1f377543a226ded705e3db9e77ed2a0510 +Subproject commit 27893a85adb57a301dd93fd2c7d318bfd4c12a0f From f5f6dd3246202a186ca205afec4d4673219a673a Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Tue, 2 Sep 2025 16:12:01 +0200 Subject: [PATCH 31/35] Handle some TODOs. --- Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs | 3 +-- .../Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs | 2 +- Penumbra/Interop/ResourceTree/ResourceTree.cs | 4 ++-- Penumbra/Interop/Structs/StructExtensions.cs | 5 +---- Penumbra/Mods/Editor/ModMerger.cs | 1 - 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs b/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs index e0eb7ec5..cdd82b95 100644 --- a/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs +++ b/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs @@ -63,8 +63,7 @@ public sealed unsafe class LoadTimelineResources : FastHook**)timeline)[0][29](timeline); + var idx = timeline->GetOwningGameObjectIndex(); if (idx >= 0 && idx < objects.TotalCount) { var obj = objects[idx]; diff --git a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs index b9c21556..dd708e51 100644 --- a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs +++ b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs @@ -434,7 +434,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic private static MaterialResourceHandle* GetMaterialResourceHandle(ModelRendererStructs.UnkPayload* unkPayload) { // TODO ClientStructs-ify - var unkPointer = *(nint*)((nint)unkPayload->ModelResourceHandle + 0xE8) + unkPayload->UnkIndex * 0x24; + var unkPointer = unkPayload->ModelResourceHandle.*(nint*)((nint)unkPayload->ModelResourceHandle + 0xE8) + unkPayload->UnkIndex * 0x24; var materialIndex = *(ushort*)(unkPointer + 8); var material = unkPayload->Params->Model->Materials[materialIndex]; if (material == null) diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs index 23fe26b8..345dd0fd 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTree.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -242,10 +242,10 @@ public class ResourceTree( } private unsafe void AddSkeleton(List nodes, ResolveContext context, CharacterBase* model, string prefix = "") - => AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, *(void**)((nint)model + 0x160), prefix); + => AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, model->BoneKineDriverModule, prefix); private unsafe void AddSkeleton(List nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics, - void* kineDriver, string prefix = "") + BoneKineDriverModule* kineDriver, string prefix = "") { var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid); if (eidNode != null) diff --git a/Penumbra/Interop/Structs/StructExtensions.cs b/Penumbra/Interop/Structs/StructExtensions.cs index 5a29bb6f..7349f6cc 100644 --- a/Penumbra/Interop/Structs/StructExtensions.cs +++ b/Penumbra/Interop/Structs/StructExtensions.cs @@ -66,11 +66,8 @@ internal static class StructExtensions public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) { - // TODO ClientStructs-ify (aers/FFXIVClientStructs#1561) - var vf80 = (delegate* unmanaged)((nint*)character.VirtualTable)[80]; var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(vf80((CharacterBase*)Unsafe.AsPointer(ref character), pathBuffer, CharacterBase.PathBufferSize, - partialSkeletonIndex)); + return ToOwnedByteString(character.ResolveKdbPath(pathBuffer, CharacterBase.PathBufferSize, partialSkeletonIndex)); } private static unsafe CiByteString ToOwnedByteString(CStringPointer str) diff --git a/Penumbra/Mods/Editor/ModMerger.cs b/Penumbra/Mods/Editor/ModMerger.cs index bb84173a..eb270e13 100644 --- a/Penumbra/Mods/Editor/ModMerger.cs +++ b/Penumbra/Mods/Editor/ModMerger.cs @@ -372,7 +372,6 @@ public class ModMerger : IDisposable, IService } else { - // TODO DataContainer <> Option. var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name); var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName()); var folder = Path.Combine(dir.FullName, group!.Name, option!.Name); From 5a6e06df3ba6a7ed056199b03f540ac567a52be9 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Tue, 2 Sep 2025 16:22:02 +0200 Subject: [PATCH 32/35] git is stupid --- .../Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs | 2 +- Penumbra/Interop/ResourceTree/ResourceTree.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs index dd708e51..b9c21556 100644 --- a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs +++ b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs @@ -434,7 +434,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic private static MaterialResourceHandle* GetMaterialResourceHandle(ModelRendererStructs.UnkPayload* unkPayload) { // TODO ClientStructs-ify - var unkPointer = unkPayload->ModelResourceHandle.*(nint*)((nint)unkPayload->ModelResourceHandle + 0xE8) + unkPayload->UnkIndex * 0x24; + var unkPointer = *(nint*)((nint)unkPayload->ModelResourceHandle + 0xE8) + unkPayload->UnkIndex * 0x24; var materialIndex = *(ushort*)(unkPointer + 8); var material = unkPayload->Params->Model->Materials[materialIndex]; if (material == null) diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs index 345dd0fd..1ebfe53d 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTree.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -261,8 +261,7 @@ public class ResourceTree( for (var i = 0; i < skeleton->PartialSkeletonCount; ++i) { var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null; - // TODO ClientStructs-ify (aers/FFXIVClientStructs#1562) - var kdbHandle = kineDriver != null ? *(ResourceHandle**)((nint)kineDriver + 0x20 + 0x18 * i) : null; + var kdbHandle = kineDriver != null ? kineDriver->PartialSkeletonEntries[i].KineDriverResourceHandle : null; if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode) { if (context.Global.WithUiData) From 6348c4a639811786d2302ac021914dcd89a65a2b Mon Sep 17 00:00:00 2001 From: Actions User Date: Tue, 2 Sep 2025 14:25:55 +0000 Subject: [PATCH 33/35] [CI] Updating repo.json for 1.5.1.2 --- repo.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/repo.json b/repo.json index e9a52799..9ff227b6 100644 --- a/repo.json +++ b/repo.json @@ -5,8 +5,8 @@ "Punchline": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", - "AssemblyVersion": "1.5.1.0", - "TestingAssemblyVersion": "1.5.1.0", + "AssemblyVersion": "1.5.1.2", + "TestingAssemblyVersion": "1.5.1.2", "RepoUrl": "https://github.com/xivdev/Penumbra", "ApplicableVersion": "any", "DalamudApiLevel": 13, @@ -18,9 +18,9 @@ "LoadPriority": 69420, "LoadRequiredState": 2, "LoadSync": true, - "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.0/Penumbra.zip", - "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.0/Penumbra.zip", - "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.0/Penumbra.zip", + "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.2/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.2/Penumbra.zip", + "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.2/Penumbra.zip", "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" } ] From 97c8d82b338be04c513df4d15f1ef72a6fbbed4c Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Sun, 7 Sep 2025 10:45:28 +0200 Subject: [PATCH 34/35] Prevent default-named collection from being renamed and always put it at the top of the selector. --- Penumbra/UI/CollectionTab/CollectionPanel.cs | 44 ++++++++++--------- .../UI/CollectionTab/CollectionSelector.cs | 3 +- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Penumbra/UI/CollectionTab/CollectionPanel.cs b/Penumbra/UI/CollectionTab/CollectionPanel.cs index 26fa2b14..e41ceade 100644 --- a/Penumbra/UI/CollectionTab/CollectionPanel.cs +++ b/Penumbra/UI/CollectionTab/CollectionPanel.cs @@ -11,6 +11,7 @@ using OtterGui; using OtterGui.Classes; using OtterGui.Extensions; using OtterGui.Raii; +using OtterGui.Text; using Penumbra.Collections; using Penumbra.Collections.Manager; using Penumbra.GameData.Actors; @@ -222,26 +223,31 @@ public sealed class CollectionPanel( ImGui.EndGroup(); ImGui.SameLine(); ImGui.BeginGroup(); - using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); - var name = _newName ?? collection.Identity.Name; - var identifier = collection.Identity.Identifier; - var width = ImGui.GetContentRegionAvail().X; - var fileName = saveService.FileNames.CollectionFile(collection); - ImGui.SetNextItemWidth(width); - if (ImGui.InputText("##name", ref name, 128)) - _newName = name; - if (ImGui.IsItemDeactivatedAfterEdit() && _newName != null && _newName != collection.Identity.Name) + var width = ImGui.GetContentRegionAvail().X; + using (ImRaii.Disabled(_collections.DefaultNamed == collection)) { - collection.Identity.Name = _newName; - saveService.QueueSave(new ModCollectionSave(mods, collection)); - selector.RestoreCollections(); - _newName = null; - } - else if (ImGui.IsItemDeactivated()) - { - _newName = null; + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + var name = _newName ?? collection.Identity.Name; + ImGui.SetNextItemWidth(width); + if (ImGui.InputText("##name", ref name, 128)) + _newName = name; + if (ImGui.IsItemDeactivatedAfterEdit() && _newName != null && _newName != collection.Identity.Name) + { + collection.Identity.Name = _newName; + saveService.QueueSave(new ModCollectionSave(mods, collection)); + selector.RestoreCollections(); + _newName = null; + } + else if (ImGui.IsItemDeactivated()) + { + _newName = null; + } } + if (_collections.DefaultNamed == collection) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "The Default collection can not be renamed."u8); + var identifier = collection.Identity.Identifier; + var fileName = saveService.FileNames.CollectionFile(collection); using (ImRaii.PushFont(UiBuilder.MonoFont)) { if (ImGui.Button(collection.Identity.Identifier, new Vector2(width, 0))) @@ -375,9 +381,7 @@ public sealed class CollectionPanel( ImGuiUtil.TextWrapped(type.ToDescription()); switch (type) { - case CollectionType.Default: - ImGui.TextUnformatted("Overruled by any other Assignment."); - break; + case CollectionType.Default: ImGui.TextUnformatted("Overruled by any other Assignment."); break; case CollectionType.Yourself: ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); break; diff --git a/Penumbra/UI/CollectionTab/CollectionSelector.cs b/Penumbra/UI/CollectionTab/CollectionSelector.cs index e54f994e..79254090 100644 --- a/Penumbra/UI/CollectionTab/CollectionSelector.cs +++ b/Penumbra/UI/CollectionTab/CollectionSelector.cs @@ -116,7 +116,8 @@ public sealed class CollectionSelector : ItemSelector, IDisposabl public void RestoreCollections() { Items.Clear(); - foreach (var c in _storage.OrderBy(c => c.Identity.Name)) + Items.Add(_storage.DefaultNamed); + foreach (var c in _storage.OrderBy(c => c.Identity.Name).Where(c => c != _storage.DefaultNamed)) Items.Add(c); SetFilterDirty(); SetCurrent(_active.Current); From e9f67a009be51377226186d61b10340683f5d3f3 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Fri, 19 Sep 2025 03:50:28 +0200 Subject: [PATCH 35/35] Lift "shaders known" restriction for saving materials --- Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs index e15d1c90..2c7c889e 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs @@ -216,7 +216,7 @@ public sealed partial class MtrlTab : IWritable, IDisposable } public bool Valid - => _shadersKnown && Mtrl.Valid; + => Mtrl.Valid; // FIXME This should be _shadersKnown && Mtrl.Valid but the algorithm for _shadersKnown is flawed as of 7.2. public byte[] Write() {