Compare commits

...

7 commits

Author SHA1 Message Date
Actions User
d302a17f1f [CI] Updating repo.json for testing_1.5.0.8
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-22 18:33:43 +00:00
Ottermandias
0d64384059 Add cleanup buttons to PCP, add option to turn off PCP IPC. 2025-08-22 20:31:40 +02:00
Ottermandias
10894d451a Add Pcp Events. 2025-08-22 18:08:22 +02:00
Actions User
fb34238530 [CI] Updating repo.json for testing_1.5.0.7 2025-08-22 13:51:50 +00:00
Ottermandias
8043e6fb6b Add option to disable PCP. 2025-08-22 15:49:15 +02:00
Ottermandias
e3b7f72893 Add initial PCP. 2025-08-22 15:44:33 +02:00
Ottermandias
b7f326e29c Fix bug with collection setting and empty collection. 2025-08-22 15:43:55 +02:00
19 changed files with 478 additions and 40 deletions

@ -1 +1 @@
Subproject commit c27a06004138f2ec80ccdb494bb6ddf6d39d2165
Subproject commit 0a970295b2398683b1e49c46fd613541e2486210

View file

@ -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<string, string> GetModList()
=> _modManager.ToDictionary(m => m.ModPath.Name, m => m.Name.Text);
@ -109,6 +108,18 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable
public event Action<string>? ModAdded;
public event Action<string, string>? ModMoved;
public event Action<JObject, ushort>? CreatingPcp
{
add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi);
remove => _communicator.PcpCreation.Unsubscribe(value!);
}
public event Action<JObject, string, Guid>? 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)

View file

@ -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()
{

View file

@ -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),

View file

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

View file

@ -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
{
/// <seealso cref="PcpService.OnModPathChange"/>
PcpService = int.MinValue,
/// <seealso cref="ModsApi.OnModPathChange"/>
ApiMods = int.MinValue,
ApiMods = int.MinValue + 1,
/// <seealso cref="ModSettingsApi.OnModPathChange"/>
ApiModSettings = int.MinValue,
ApiModSettings = int.MinValue + 1,
/// <seealso cref="EphemeralConfig.OnModPathChanged"/>
EphemeralConfig = -500,

View file

@ -0,0 +1,20 @@
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
namespace Penumbra.Communication;
/// <summary>
/// Triggered when the character.json file for a .pcp file is written.
/// <list type="number">
/// <item>Parameter is the JObject that gets written to file. </item>
/// <item>Parameter is the object index of the game object this is written for. </item>
/// </list>
/// </summary>
public sealed class PcpCreation() : EventWrapper<JObject, ushort, PcpCreation.Priority>(nameof(PcpCreation))
{
public enum Priority
{
/// <seealso cref="Api.Api.ModsApi"/>
ModsApi = int.MinValue,
}
}

View file

@ -0,0 +1,21 @@
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
namespace Penumbra.Communication;
/// <summary>
/// Triggered when the character.json file for a .pcp file is parsed and applied.
/// <list type="number">
/// <item>Parameter is parsed JObject that contains the data. </item>
/// <item>Parameter is the identifier of the created mod. </item>
/// <item>Parameter is the GUID of the created collection. </item>
/// </list>
/// </summary>
public sealed class PcpParsing() : EventWrapper<JObject, string, Guid, PcpParsing.Priority>(nameof(PcpParsing))
{
public enum Priority
{
/// <seealso cref="Api.Api.ModsApi"/>
ModsApi = int.MinValue,
}
}

View file

@ -68,6 +68,8 @@ 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;
@ -88,6 +90,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;

View file

@ -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();

View file

@ -36,7 +36,7 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic
/// <summary> Create the file containing the meta information about a mod from scratch. </summary>
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));
}

View file

@ -32,12 +32,12 @@ public partial class ModCreator(
public readonly Configuration Config = config;
/// <summary> Creates directory and files necessary for a new mod without adding it to the manager. </summary>
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;
}

View file

@ -81,6 +81,12 @@ public class CommunicatorService : IDisposable, IService
/// <inheritdoc cref="Communication.ResolvedFileChanged"/>
public readonly ResolvedFileChanged ResolvedFileChanged = new();
/// <inheritdoc cref="Communication.PcpCreation"/>
public readonly PcpCreation PcpCreation = new();
/// <inheritdoc cref="Communication.PcpParsing"/>
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();
}
}

View file

@ -0,0 +1,293 @@
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;
private readonly ModManager _mods;
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,
ModManager mods)
{
_config = config;
_files = files;
_treeFactory = treeFactory;
_objectManager = objectManager;
_actors = actors;
_framework = framework;
_collectionResolver = collectionResolver;
_collections = collections;
_modCreator = modCreator;
_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)
return;
try
{
var file = Path.Combine(newDirectory.FullName, "character.json");
if (!File.Exists(file))
{
// 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);
if (!identifier.IsValid)
return;
if (jObj["Collection"]?.ToObject<string>() 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.
}
}
if (_config.AllowPcpIpc)
_communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id);
}
catch (Exception ex)
{
Penumbra.Log.Error($"Error reading the character.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
{
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);
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, objectIndex, 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 async Task CreateCollectionInfo(DirectoryInfo directory, ObjectIndex index, ActorIdentifier actor, string note, DateTime time,
CancellationToken cancel = default)
{
var jObj = new JObject
{
["Version"] = 1,
["Actor"] = actor.ToJson(),
["Mod"] = directory.Name,
["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(),
["Time"] = time,
["Note"] = note,
};
if (note.Length > 0)
cancel.ThrowIfCancellationRequested();
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);
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);
}
}

View file

@ -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<ResourceNode, Vector2> drawActions,
CommunicatorService communicator)
CommunicatorService communicator,
PcpService pcpService)
{
private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags =
ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership;
private readonly HashSet<nint> _unfolded = [];
private readonly HashSet<nint> _unfolded = [];
private readonly Dictionary<nint, NodeVisibility> _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<ResourceTree[]>? _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]

View file

@ -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<ResourceNode, Vector2> drawActions)
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator);
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService);
}

View file

@ -126,6 +126,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
".ttmp",
".ttmp2",
".pmp",
".pcp",
".zip",
".rar",
".7z",
@ -380,7 +381,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
: null;
_fileDialog.OpenFilePicker("Import Mod Pack",
"Mod Packs{.ttmp,.ttmp2,.pmp},TexTools Mod Packs{.ttmp,.ttmp2},Penumbra Mod Packs{.pmp},Archives{.zip,.7z,.rar}", (s, f) =>
"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<Mod, ModFileSyste
ImUtf8.Text("Mod Management"u8);
ImUtf8.BulletText("You can create empty mods or import mods with the buttons in this row."u8);
using var indent = ImRaii.PushIndent();
ImUtf8.BulletText("Supported formats for import are: .ttmp, .ttmp2, .pmp."u8);
ImUtf8.BulletText("Supported formats for import are: .ttmp, .ttmp2, .pmp, .pcp."u8);
ImUtf8.BulletText(
"You can also support .zip, .7z or .rar archives, but only if they already contain Penumbra-styled mods with appropriate metadata."u8);
indent.Pop(1);

View file

@ -12,7 +12,6 @@ using OtterGui.Raii;
using OtterGui.Services;
using OtterGui.Text;
using OtterGui.Widgets;
using OtterGuiInternal.Enums;
using Penumbra.Api;
using Penumbra.Collections;
using Penumbra.Interop.Hooks.PostProcessing;
@ -21,7 +20,6 @@ using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.UI.Classes;
using Penumbra.UI.ModsTab;
using ImGuiId = OtterGuiInternal.Enums.ImGuiId;
namespace Penumbra.UI.Tabs;
@ -54,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;
@ -66,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;
@ -92,6 +91,7 @@ public class SettingsTab : ITab, IUiService
_autoSelector = autoSelector;
_cleanupService = cleanupService;
_attributeHook = attributeHook;
_pcpService = pcpService;
}
public void DrawHeader()
@ -600,9 +600,30 @@ 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);
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();
DrawPcpFolder();
DrawDefaultModExportPath();
}
@ -712,6 +733,21 @@ public class SettingsTab : ITab, IUiService
"Set the default Penumbra mod folder to place newly imported mods into.\nLeave blank to import into Root.");
}
/// <summary> Draw input for the default folder to sort put newly imported mods into. </summary>
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.");
}
/// <summary> Draw all settings pertaining to advanced editing of mods. </summary>
private void DrawModEditorSettings()
@ -1055,7 +1091,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));
}

View file

@ -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.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/1.5.0.6/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"
}