Compare commits

..

90 commits

Author SHA1 Message Date
Karou
ccb5b01290 Api version bump and remove redundant framework thread call
Some checks failed
.NET Build / build (push) Has been cancelled
2025-12-05 13:39:19 +01:00
Actions User
5dd74297c6 [CI] Updating repo.json for 1.5.1.8
Some checks failed
.NET Build / build (push) Has been cancelled
2025-11-28 22:10:17 +00:00
Karou
ce54aa5d25 Added IPC call to allow for redrawing only members of specified collections
Some checks failed
.NET Build / build (push) Has been cancelled
2025-11-03 15:15:40 +01:00
Actions User
c4b6e4e00b [CI] Updating repo.json for testing_1.5.1.7
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-23 21:50:20 +00:00
Ottermandias
912c183fc6 Improve file watcher. 2025-10-23 23:45:20 +02:00
Ottermandias
5bf901d0c4 Update actorobjectmanager when setting cutscene index.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-10-23 17:30:29 +02:00
Ottermandias
cbedc878b9 Slight cleanup and autoformat. 2025-10-22 21:56:16 +02:00
Ottermandias
c8cf560fc1 Merge branch 'refs/heads/StoiaCode/fileWatcher' 2025-10-22 21:48:42 +02:00
Stoia
f05cb52da2 Add Option to notify instead of auto install.
And General Fixes
2025-10-22 18:20:44 +02:00
Ottermandias
7ed81a9823 Update OtterGui.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-10-22 17:53:02 +02:00
Stoia
60aa23efcd
Merge branch 'xivdev:master' into fileWatcher 2025-10-22 14:28:08 +02:00
Ottermandias
ebbe957c95 Remove login screen log spam.
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-11 20:13:51 +02:00
Actions User
300e0e6d84 [CI] Updating repo.json for 1.5.1.6
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-07 10:45:04 +00:00
Ottermandias
049baa4fe4 Again. 2025-10-07 12:42:54 +02:00
Ottermandias
0881dfde8a Update signatures. 2025-10-07 12:27:35 +02:00
Actions User
23c0506cb8 [CI] Updating repo.json for testing_1.5.1.5
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-28 10:43:01 +00:00
Ottermandias
699745413e Make priority an int. 2025-09-28 12:40:52 +02:00
Actions User
eb53f04c6b [CI] Updating repo.json for testing_1.5.1.4
Some checks are pending
.NET Build / build (push) Waiting to run
2025-09-27 12:03:35 +00:00
Ottermandias
c6b596169c Add default constructor. 2025-09-27 14:01:21 +02:00
Actions User
a0c3e820b0 [CI] Updating repo.json for testing_1.5.1.3
Some checks are pending
.NET Build / build (push) Waiting to run
2025-09-27 11:02:39 +00:00
Ottermandias
a59689ebfe CS API update and add http API routes. 2025-09-27 13:00:18 +02:00
Exter-N
e9f67a009b Lift "shaders known" restriction for saving materials
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-19 11:18:39 +02:00
Ottermandias
97c8d82b33 Prevent default-named collection from being renamed and always put it at the top of the selector.
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-07 10:45:28 +02:00
Stoia
c3b00ff426 Integrate FileWatcher
HEAVY WIP
2025-09-06 14:22:18 +02:00
Actions User
6348c4a639 [CI] Updating repo.json for 1.5.1.2
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-02 14:25:55 +00:00
Ottermandias
5a6e06df3b git is stupid 2025-09-02 16:22:02 +02:00
Ottermandias
f5f6dd3246 Handle some TODOs. 2025-09-02 16:12:01 +02:00
Ottermandias
4e788f7c2b Update sig. 2025-09-02 11:51:59 +02:00
Ottermandias
ad1659caf6 Update libraries. 2025-09-02 11:29:58 +02:00
Ottermandias
18a6ce2a5f Merge branch 'refs/heads/Exter-N/cldapi'
Some checks are pending
.NET Build / build (push) Waiting to run
2025-09-01 15:59:26 +02:00
Ottermandias
e68e821b2a Merge branch 'master' into Exter-N/cldapi 2025-09-01 15:58:22 +02:00
Ottermandias
96764b34ca Merge branch 'refs/heads/Exter-N/restree-stuff' 2025-09-01 15:57:06 +02:00
Exter-N
2cf60b78cd Reject and warn about cloud-synced base directories 2025-08-31 06:42:45 +02:00
Exter-N
d59be1e660 Refine IsCloudSynced 2025-08-31 05:25:37 +02:00
Exter-N
5503bb32e0 CloudApi testing in Debug tab 2025-08-31 04:13:56 +02:00
Exter-N
f3ec4b2e08 Only display the file name and last dir for externals 2025-08-30 19:19:07 +02:00
Exter-N
b3379a9710 Stop redacting external paths 2025-08-30 16:55:20 +02:00
Exter-N
8c25ef4b47 Make the save button ResourceTreeViewer baseline 2025-08-30 16:53:12 +02:00
Ottermandias
912020cc3f Update for staging and wrong tooltip.
Some checks failed
.NET Build / build (push) Has been cancelled
2025-08-29 16:36:42 +02:00
Ottermandias
be8987a451 Merge branch 'master' of github.com:xivDev/Penumbra
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-28 18:52:29 +02:00
Ottermandias
f7cf5503bb Fix deleting PCP collections. 2025-08-28 18:52:06 +02:00
Ottermandias
a04a5a071c Add warning in file redirections if extension doesn't match. 2025-08-28 18:51:57 +02:00
Actions User
71e24c13c7 [CI] Updating repo.json for 1.5.1.0
Some checks failed
.NET Build / build (push) Has been cancelled
2025-08-25 08:39:42 +00:00
Ottermandias
c0120f81af 1.5.1.0 2025-08-25 10:37:38 +02:00
Ottermandias
da47c19aeb Woops, increment version. 2025-08-25 10:25:05 +02:00
Actions User
e16800f216 [CI] Updating repo.json for testing_1.5.0.10 2025-08-25 08:16:04 +00:00
Ottermandias
79a4fc5904 Fix wrong logging. 2025-08-25 10:13:48 +02:00
Ottermandias
bf90725dd2 Fix resolvecontext issue. 2025-08-25 10:13:39 +02:00
Ottermandias
a14347f73a Update temporary collection creation. 2025-08-25 10:13:31 +02:00
Actions User
1e07e43498 [CI] Updating repo.json for testing_1.5.0.9
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-24 13:51:43 +00:00
Ottermandias
f51f8a7bf8 Try to filter meta entries for relevance. 2025-08-24 15:24:57 +02:00
Exter-N
1fca78fa71 Add Kdb files to ResourceTree 2025-08-24 14:09:02 +02:00
Exter-N
c8b6325a87 Add game integrity message to On-Screen 2025-08-24 14:06:39 +02:00
Ottermandias
6079103505 Add collection PCP settings.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-23 14:46:27 +02:00
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
Ottermandias
dad01e1af8 Update GameData. 2025-08-20 15:24:00 +02:00
Ottermandias
10b71930a1 Merge branch 'refs/heads/Exter-N/stockings-skin-slot' 2025-08-18 15:41:22 +02:00
Ottermandias
23257f94a4 Some cleanup and add option to disable skin material attribute scanning. 2025-08-18 15:41:10 +02:00
Ottermandias
83a36ed4cb Merge branch 'master' into Exter-N/stockings-skin-slot 2025-08-18 15:31:52 +02:00
Ottermandias
8304579d29 Add predefined tags to the multi mod selector. 2025-08-17 13:54:36 +02:00
Actions User
24cbc6c5e1 [CI] Updating repo.json for 1.5.0.6 2025-08-17 08:46:26 +00:00
Exter-N
41edc23820 Allow changing the skin mtrl suffix 2025-08-17 03:11:11 +02:00
Exter-N
aa920b5e9b Fix ImGui texture usage issue 2025-08-17 01:41:49 +02:00
Ottermandias
87ace28bcf Update OtterGui. 2025-08-16 11:56:24 +02:00
Ottermandias
5917f5fad1 Small fixes. 2025-08-13 17:42:45 +02:00
Actions User
f69c264317 [CI] Updating repo.json for 1.5.0.5 2025-08-13 14:53:11 +00:00
Ottermandias
a7246b9d98 Add PBD Post-Processor that appends EPBD data if the loaded PBD does not contain it. 2025-08-13 16:50:26 +02:00
Actions User
9aff388e21 [CI] Updating repo.json for 1.5.0.4 2025-08-12 12:53:33 +00:00
Ottermandias
091aff1b8a Merge branch 'master' of github.com:xivDev/Penumbra 2025-08-12 14:47:50 +02:00
Ottermandias
9f8185f67b Add new parameter to LoadWeapon hook. 2025-08-12 14:47:35 +02:00
Actions User
b112d75a27 [CI] Updating repo.json for 1.5.0.3 2025-08-12 10:31:13 +00:00
Ottermandias
7af81a6c18 Fix issue with removing default metadata. 2025-08-12 12:29:09 +02:00
Ottermandias
12a218bb2b Protect against empty requested paths. 2025-08-12 12:28:56 +02:00
Ottermandias
f6bac93db7 Update ChangedEquipData. 2025-08-11 19:58:24 +02:00
Actions User
155d3d49aa [CI] Updating repo.json for 1.5.0.2 2025-08-09 16:40:42 +00:00
Exter-N
9aae2210a2 Fix nullptr crashes 2025-08-09 14:57:15 +02:00
Actions User
3785a629ce [CI] Updating repo.json for 1.5.0.1 2025-08-09 11:03:24 +00:00
Ottermandias
02af52671f Need staging again ... 2025-08-09 13:00:40 +02:00
Ottermandias
391c9d727e Fix shifted timeline vfunc offset. 2025-08-09 12:51:39 +02:00
Ottermandias
ff2b2be953 Fix popups not working early. 2025-08-09 12:11:29 +02:00
Ottermandias
6242b30f93 Fix resizable child. 2025-08-09 11:58:35 +02:00
Exter-N
11cd08a9de ClientStructs-ify stuff 2025-08-09 10:29:29 +02:00
Ottermandias
46cfbcb115 Set Repo API level to 13 and remove stg from future releases. 2025-08-08 23:13:23 +02:00
Actions User
66543cc671 [CI] Updating repo.json for 1.5.0.0 2025-08-08 21:12:00 +00:00
72 changed files with 1917 additions and 284 deletions

@ -1 +1 @@
Subproject commit 9523b7ac725656b21fa98faef96962652e86e64f
Subproject commit a63f6735cf4bed4f7502a022a10378607082b770

@ -1 +1 @@
Subproject commit c27a06004138f2ec80ccdb494bb6ddf6d39d2165
Subproject commit 3d6cee1a11922ccd426f36060fd026bc1a698adf

View file

@ -1,4 +1,4 @@
<Project Sdk="Dalamud.NET.Sdk/13.0.0">
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

@ -1 +1 @@
Subproject commit fd875c43ee910350107b2609809335285bd4ac0f
Subproject commit d889f9ef918514a46049725052d378b441915b00

@ -1 +1 @@
Subproject commit 878acce46e286867d6ef1f8ecedb390f7bac34fd
Subproject commit c8611a0c546b6b2ec29214ab319fc2c38fe74793

View file

@ -0,0 +1,7 @@
namespace Penumbra.Api.Api;
public static class IdentityChecker
{
public static bool Check(string identity)
=> true;
}

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, string>? 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 = 13;
public void Dispose()
{

View file

@ -2,11 +2,14 @@ using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services;
using OtterGui.Services;
using Penumbra.Api.Enums;
using Penumbra.Collections;
using Penumbra.Collections.Manager;
using Penumbra.GameData.Interop;
using Penumbra.Interop.Services;
namespace Penumbra.Api.Api;
public class RedrawApi(RedrawService redrawService, IFramework framework) : IPenumbraApiRedraw, IApiService
public class RedrawApi(RedrawService redrawService, IFramework framework, CollectionManager collections, ObjectManager objects, ApiHelpers helpers) : IPenumbraApiRedraw, IApiService
{
public void RedrawObject(int gameObjectIndex, RedrawType setting)
{
@ -28,6 +31,24 @@ public class RedrawApi(RedrawService redrawService, IFramework framework) : IPen
framework.RunOnFrameworkThread(() => redrawService.RedrawAll(setting));
}
public void RedrawCollectionMembers(Guid collectionId, RedrawType setting)
{
if (!collections.Storage.ById(collectionId, out var collection))
collection = ModCollection.Empty;
framework.RunOnFrameworkThread(() =>
{
foreach (var actor in objects.Objects)
{
helpers.AssociatedCollection(actor.ObjectIndex, out var modCollection);
if (collection == modCollection)
{
redrawService.RedrawObject(actor.ObjectIndex, setting);
}
}
});
}
public event GameObjectRedrawnDelegate? GameObjectRedrawn
{
add => redrawService.GameObjectRedrawn += value;

View file

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

View file

@ -5,6 +5,7 @@ using EmbedIO.WebApi;
using OtterGui.Services;
using Penumbra.Api.Api;
using Penumbra.Api.Enums;
using Penumbra.Mods.Settings;
namespace Penumbra.Api;
@ -13,13 +14,15 @@ public class HttpApi : IDisposable, IApiService
private partial class Controller : WebApiController
{
// @formatter:off
[Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods();
[Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw();
[Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll();
[Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod();
[Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod();
[Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow();
[Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod();
[Route( HttpVerbs.Get, "/moddirectory" )] public partial string GetModDirectory();
[Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods();
[Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw();
[Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll();
[Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod();
[Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod();
[Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow();
[Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod();
[Route( HttpVerbs.Post, "/setmodsettings")] public partial Task SetModSettings();
// @formatter:on
}
@ -65,6 +68,12 @@ public class HttpApi : IDisposable, IApiService
private partial class Controller(IPenumbraApi api, IFramework framework)
{
public partial string GetModDirectory()
{
Penumbra.Log.Debug($"[HTTP] {nameof(GetModDirectory)} triggered.");
return api.PluginState.GetModDirectory();
}
public partial object? GetMods()
{
Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered.");
@ -116,6 +125,7 @@ public class HttpApi : IDisposable, IApiService
Penumbra.Log.Debug($"[HTTP] {nameof(OpenWindow)} triggered.");
api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty);
}
public async partial Task FocusMod()
{
var data = await HttpContext.GetRequestDataAsync<ModFocusData>().ConfigureAwait(false);
@ -124,6 +134,30 @@ public class HttpApi : IDisposable, IApiService
api.Ui.OpenMainWindow(TabType.Mods, data.Path, data.Name);
}
public async partial Task SetModSettings()
{
var data = await HttpContext.GetRequestDataAsync<SetModSettingsData>().ConfigureAwait(false);
Penumbra.Log.Debug($"[HTTP] {nameof(SetModSettings)} triggered.");
await framework.RunOnFrameworkThread(() =>
{
var collection = data.CollectionId ?? api.Collection.GetCollection(ApiCollectionType.Current)!.Value.Id;
if (data.Inherit.HasValue)
{
api.ModSettings.TryInheritMod(collection, data.ModPath, data.ModName, data.Inherit.Value);
if (data.Inherit.Value)
return;
}
if (data.State.HasValue)
api.ModSettings.TrySetMod(collection, data.ModPath, data.ModName, data.State.Value);
if (data.Priority.HasValue)
api.ModSettings.TrySetModPriority(collection, data.ModPath, data.ModName, data.Priority.Value);
foreach (var (group, settings) in data.Settings ?? [])
api.ModSettings.TrySetModSettings(collection, data.ModPath, data.ModName, group, settings);
}
).ConfigureAwait(false);
}
private record ModReloadData(string Path, string Name)
{
public ModReloadData()
@ -151,5 +185,19 @@ public class HttpApi : IDisposable, IApiService
: this(string.Empty, RedrawType.Redraw, -1)
{ }
}
private record SetModSettingsData(
Guid? CollectionId,
string ModPath,
string ModName,
bool? Inherit,
bool? State,
int? Priority,
Dictionary<string, List<string>>? Settings)
{
public SetModSettingsData()
: this(null, string.Empty, string.Empty, null, null, null, null)
{}
}
}
}

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),
@ -86,6 +88,7 @@ public sealed class IpcProviders : IDisposable, IApiService
IpcSubscribers.RedrawObject.Provider(pi, api.Redraw),
IpcSubscribers.RedrawAll.Provider(pi, api.Redraw),
IpcSubscribers.GameObjectRedrawn.Provider(pi, api.Redraw),
IpcSubscribers.RedrawCollectionMembers.Provider(pi, api.Redraw),
IpcSubscribers.ResolveDefaultPath.Provider(pi, api.Resolve),
IpcSubscribers.ResolveInterfacePath.Provider(pi, api.Resolve),

View file

@ -121,6 +121,10 @@ public class CollectionsIpcTester(IDalamudPluginInterface pi) : IUiService
}).ToArray();
ImGui.OpenPopup("Changed Item List");
}
IpcTester.DrawIntro(RedrawCollectionMembers.Label, "Redraw Collection Members");
if (ImGui.Button("Redraw##ObjectCollection"))
new RedrawCollectionMembers(pi).Invoke(collectionList[0].Id, RedrawType.Redraw);
}
private void DrawChangedItemPopup()

View file

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

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,21 @@
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>
/// <item>Parameter is the full path to the directory being set up for the PCP creation. </item>
/// </list>
/// </summary>
public sealed class PcpCreation() : EventWrapper<JObject, ushort, string, 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

@ -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
{
@ -44,6 +53,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public string ModDirectory { get; set; } = string.Empty;
public string ExportDirectory { get; set; } = string.Empty;
public string WatchDirectory { get; set; } = string.Empty;
public bool? UseCrashHandler { get; set; } = null;
public bool OpenWindowAtStart { get; set; } = false;
@ -67,10 +77,13 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public bool HideRedrawBar { get; set; } = false;
public bool HideMachinistOffhandFromChangedItems { get; set; } = true;
public bool DefaultTemporaryMode { get; set; } = false;
public bool EnableDirectoryWatch { get; set; } = false;
public bool EnableAutomaticModImport { get; set; } = false;
public bool EnableCustomShapes { 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);

View file

@ -2,5 +2,6 @@ namespace Penumbra;
public class DebugConfiguration
{
public static bool WriteImcBytesToLog = false;
public static bool WriteImcBytesToLog = false;
public static bool UseSkinMaterialProcessing = true;
}

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

@ -0,0 +1,47 @@
namespace Penumbra.Interop;
public static unsafe partial class CloudApi
{
private const int CfSyncRootInfoBasic = 0;
/// <summary> Determines whether a file or directory is cloud-synced using OneDrive or other providers that use the Cloud API. </summary>
/// <remarks> Can be expensive. Callers should cache the result when relevant. </remarks>
public static bool IsCloudSynced(string path)
{
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.Debug($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes");
return false;
}
Penumbra.Log.Debug($"{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);
}

View file

@ -59,9 +59,6 @@ public class GameState : IService
private readonly ThreadLocal<ResolveData> _characterSoundData = new(() => ResolveData.Invalid, true);
public ResolveData SoundData
=> _characterSoundData.Value;
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public ResolveData SetSoundData(ResolveData data)
{

View file

@ -35,14 +35,14 @@ public sealed unsafe class WeaponReload : EventWrapperPtr<DrawDataContainer, Cha
public bool Finished
=> _task.IsCompletedSuccessfully;
private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g);
private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h);
private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g)
private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h)
{
var gameObject = drawData->OwnerObject;
Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}.");
Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}, {h}.");
Invoke(drawData, gameObject, (CharacterWeapon*)(&weapon));
_task.Result.Original(drawData, slot, weapon, d, e, f, g);
_task.Result.Original(drawData, slot, weapon, d, e, f, g, h);
_postEvent.Invoke(drawData, gameObject);
}

View file

@ -1,4 +1,5 @@
using Dalamud.Hooking;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.System.Resource;
@ -85,7 +86,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, nint unk7, uint unk8);
private delegate ResourceHandle* GetResourceAsyncPrototype(ResourceManager* resourceManager, ResourceCategory* pCategoryId,
ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8, uint unk9);
ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8,
uint unk9);
[Signature(Sigs.GetResourceSync, DetourName = nameof(GetResourceSyncDetour))]
private readonly Hook<GetResourceSyncPrototype> _getResourceSyncHook = null!;
@ -118,18 +120,26 @@ public unsafe class ResourceService : IDisposable, IRequiredService
unk9);
}
var original = gamePath;
if (gamePath.IsEmpty)
{
Penumbra.Log.Error($"[ResourceService] Empty resource path requested with category {*categoryId}, type {*resourceType}, hash {*resourceHash}.");
return null;
}
var original = gamePath;
ResourceHandle* returnValue = null;
ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, original, pGetResParams, ref isSync,
ref returnValue);
if (returnValue != null)
return returnValue;
return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8, unk9);
return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8,
unk9);
}
/// <summary> Call the original GetResource function. </summary>
public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path, Utf8GamePath original,
public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path,
Utf8GamePath original,
GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0)
{
var previous = _currentGetResourcePath.Value;
@ -141,7 +151,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
resourceParameters, unk8, unk9)
: _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path,
resourceParameters, unk, unk8, unk9);
} finally
}
finally
{
_currentGetResourcePath.Value = previous;
}
@ -163,7 +174,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
/// <param name="syncOriginal">The original game path of the resource, if loaded synchronously.</param>
/// <param name="previousState">The previous state of the resource.</param>
/// <param name="returnValue">The return value to use.</param>
public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal, (byte UnkState, LoadState LoadState) previousState, ref uint returnValue);
public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal,
(byte UnkState, LoadState LoadState) previousState, ref uint returnValue);
/// <summary>
/// <inheritdoc cref="ResourceStateUpdatingDelegate"/> <para/>
@ -185,7 +197,7 @@ public unsafe class ResourceService : IDisposable, IRequiredService
private uint UpdateResourceStateDetour(ResourceHandle* handle, byte offFileThread)
{
var previousState = (handle->UnkState, handle->LoadState);
var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty;
var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty;
ResourceStateUpdating?.Invoke(handle, syncOriginal);
var ret = _updateResourceStateHook.OriginalDisposeSafe(handle, offFileThread);
ResourceStateUpdated?.Invoke(handle, syncOriginal, previousState, ref ret);

View file

@ -6,6 +6,7 @@ using Penumbra.Collections;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs;
using Penumbra.Interop.PathResolving;
using Penumbra.Interop.Processing;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.ActionEffectHandler;
namespace Penumbra.Interop.Hooks.Resources;
@ -159,7 +160,13 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
=> ResolvePath(drawObject, _resolveMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, mtrlFileName));
private nint ResolveSkinMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex)
=> ResolvePath(drawObject, _resolveSkinMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex));
{
var finalPathBuffer = _resolveSkinMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex);
if (DebugConfiguration.UseSkinMaterialProcessing && finalPathBuffer != nint.Zero && finalPathBuffer == pathBuffer)
SkinMtrlPathEarlyProcessing.Process(new Span<byte>((void*)pathBuffer, (int)pathBufferSize), (CharacterBase*)drawObject, slotIndex);
return ResolvePath(drawObject, finalPathBuffer);
}
private nint ResolvePap(nint drawObject, nint pathBuffer, nint pathBufferSize, uint unkAnimationIndex, nint animationName)
=> ResolvePath(drawObject, _resolvePapPathHook.Original(drawObject, pathBuffer, pathBufferSize, unkAnimationIndex, animationName));

View file

@ -137,7 +137,7 @@ public sealed unsafe class CollectionResolver(
{
var item = charaEntry.Value;
var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId);
Penumbra.Log.Verbose(
Penumbra.Log.Excessive(
$"Identified {identifier.Incognito(null)} in cutscene for actor {idx + 200} at 0x{(ulong)gameObject:X} of race {(gameObject->IsCharacter() ? ((Character*)gameObject)->DrawData.CustomizeData.Race.ToString() : "Unknown")}.");
if (identifier.IsValid && CollectionByIdentifier(identifier) is { } coll)
{

View file

@ -75,6 +75,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable
return false;
_copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx;
_objects.InvokeRequiredUpdates();
return true;
}

View file

@ -0,0 +1,119 @@
using Dalamud.Game;
using Dalamud.Plugin.Services;
using Penumbra.Api.Enums;
using Penumbra.GameData;
using Penumbra.GameData.Data;
using Penumbra.Interop.Structs;
using Penumbra.Meta.Files;
using Penumbra.String;
namespace Penumbra.Interop.Processing;
public sealed class PbdFilePostProcessor : IFilePostProcessor
{
private readonly IFileAllocator _allocator;
private byte[] _epbdData;
private unsafe delegate* unmanaged<ResourceHandle*, void> _loadEpbdData;
public ResourceType Type
=> ResourceType.Pbd;
public unsafe PbdFilePostProcessor(IDataManager dataManager, XivFileAllocator allocator, ISigScanner scanner)
{
_allocator = allocator;
_epbdData = SetEpbdData(dataManager);
_loadEpbdData = (delegate* unmanaged<ResourceHandle*, void>)scanner.ScanText(Sigs.LoadEpbdData);
}
public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan<byte> additionalData)
{
if (_epbdData.Length is 0)
return;
if (resource->LoadState is not LoadState.Success)
{
Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} failed load ({resource->LoadState}).");
return;
}
var (data, length) = resource->GetData();
if (length is 0 || data == nint.Zero)
{
Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} succeeded load but has no data.");
return;
}
var span = new ReadOnlySpan<byte>((void*)data, (int)resource->FileSize);
var reader = new PackReader(span);
if (reader.HasData)
{
Penumbra.Log.Excessive($"[ResourceLoader] Successfully loaded PBD at {resource->FileName()} with EPBD data.");
return;
}
var newData = AppendData(span);
fixed (byte* ptr = newData)
{
// Set the appended data and the actual file size, then re-load the EPBD data via game function call.
if (resource->SetData((nint)ptr, newData.Length))
{
resource->FileSize = (uint)newData.Length;
resource->CsHandle.FileSize2 = (uint)newData.Length;
resource->CsHandle.FileSize3 = (uint)newData.Length;
_loadEpbdData(resource);
// Free original data.
_allocator.Release((void*)data, length);
Penumbra.Log.Debug($"[ResourceLoader] Loaded {resource->FileName()} from file and appended default EPBD data.");
}
else
{
Penumbra.Log.Warning(
$"[ResourceLoader] Failed to append EPBD data to custom PBD at {resource->FileName()}.");
}
}
}
/// <summary> Combine the given data with the default PBD data using the game's file allocator. </summary>
private unsafe ReadOnlySpan<byte> AppendData(ReadOnlySpan<byte> data)
{
// offset has to be set, otherwise not called.
var newLength = data.Length + _epbdData.Length;
var memory = _allocator.Allocate(newLength);
var span = new Span<byte>(memory, newLength);
data.CopyTo(span);
_epbdData.CopyTo(span[data.Length..]);
return span;
}
/// <summary> Fetch the default EPBD data from the .pbd file of the game's installation. </summary>
private static byte[] SetEpbdData(IDataManager dataManager)
{
try
{
var file = dataManager.GetFile(GamePaths.Pbd.Path);
if (file is null || file.Data.Length is 0)
{
Penumbra.Log.Warning("Default PBD file has no data.");
return [];
}
ReadOnlySpan<byte> span = file.Data;
var reader = new PackReader(span);
if (!reader.HasData)
{
Penumbra.Log.Warning("Default PBD file has no EPBD section.");
return [];
}
var offset = span.Length - (int)reader.PackLength;
var ret = span[offset..];
Penumbra.Log.Verbose($"Default PBD file has EPBD section of length {ret.Length} at offset {offset}.");
return ret.ToArray();
}
catch (Exception ex)
{
Penumbra.Log.Error($"Unknown error getting default EPBD data:\n{ex}");
return [];
}
}
}

View file

@ -0,0 +1,63 @@
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
namespace Penumbra.Interop.Processing;
public static unsafe class SkinMtrlPathEarlyProcessing
{
public static void Process(Span<byte> path, CharacterBase* character, uint slotIndex)
{
var end = path.IndexOf(MaterialExtension());
if (end < 0)
return;
var suffixPos = path[..end].LastIndexOf((byte)'_');
if (suffixPos < 0)
return;
var handle = GetModelResourceHandle(character, slotIndex);
if (handle == null)
return;
var skinSuffix = GetSkinSuffix(handle);
if (skinSuffix.IsEmpty || skinSuffix.Length > path.Length - suffixPos - 7)
return;
++suffixPos;
skinSuffix.CopyTo(path[suffixPos..]);
suffixPos += skinSuffix.Length;
MaterialExtension().CopyTo(path[suffixPos..]);
return;
static ReadOnlySpan<byte> MaterialExtension()
=> ".mtrl\0"u8;
}
private static ModelResourceHandle* GetModelResourceHandle(CharacterBase* character, uint slotIndex)
{
if (character is null)
return null;
if (character->PerSlotStagingArea is not null)
{
var handle = character->PerSlotStagingArea[slotIndex].ModelResourceHandle;
if (handle != null)
return handle;
}
var model = character->Models[slotIndex];
return model is null ? null : model->ModelResourceHandle;
}
private static ReadOnlySpan<byte> GetSkinSuffix(ModelResourceHandle* handle)
{
foreach (var (attribute, _) in handle->Attributes)
{
var attributeSpan = attribute.AsSpan();
if (attributeSpan.Length > 12 && attributeSpan[..11].SequenceEqual("skin_suffix"u8) && attributeSpan[11] is (byte)'=' or (byte)'_')
return attributeSpan[12..];
}
return [];
}
}

View file

@ -338,6 +338,34 @@ internal partial record ResolveContext
return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private Utf8GamePath ResolveKineDriverModulePath(uint partialSkeletonIndex)
{
// Correctness and Safety:
// Resolving a KineDriver module path through the game's code can use EST metadata for human skeletons.
// Additionally, it can dereference null pointers for human equipment skeletons.
return ModelType switch
{
ModelType.Human => ResolveHumanKineDriverModulePath(partialSkeletonIndex),
_ => ResolveKineDriverModulePathNative(partialSkeletonIndex),
};
}
private Utf8GamePath ResolveHumanKineDriverModulePath(uint partialSkeletonIndex)
{
var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex);
if (set.Id is 0)
return Utf8GamePath.Empty;
var path = GamePaths.Kdb.Customization(raceCode, slot, set);
return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private unsafe Utf8GamePath ResolveKineDriverModulePathNative(uint partialSkeletonIndex)
{
var path = CharacterBase->ResolveKdbPathAsByteString(partialSkeletonIndex);
return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private unsafe Utf8GamePath ResolveMaterialAnimationPath(ResourceHandle* imc)
{
var animation = ResolveImcData(imc).MaterialAnimationId;

View file

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

View file

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

View file

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

View file

@ -421,9 +421,9 @@ public sealed unsafe partial class RedrawService : IDisposable
return;
foreach (ref var f in currentTerritory->Furniture)
foreach (ref var f in currentTerritory->FurnitureManager.FurnitureMemory)
{
var gameObject = f.Index >= 0 ? currentTerritory->HousingObjectManager.Objects[f.Index].Value : null;
var gameObject = f.Index >= 0 ? currentTerritory->FurnitureManager.ObjectManager.ObjectArray.Objects[f.Index].Value : null;
if (gameObject == null)
continue;

View file

@ -36,10 +36,8 @@ internal static class StructExtensions
public static unsafe CiByteString ResolveSkinMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex)
{
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1474)
var vf91 = (delegate* unmanaged<CharacterBase*, byte*, nuint, uint, byte*>)((nint*)character.VirtualTable)[91];
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(vf91((CharacterBase*)Unsafe.AsPointer(ref character), pathBuffer, CharacterBase.PathBufferSize, slotIndex));
return ToOwnedByteString(character.ResolveSkinMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex));
}
public static CiByteString ResolveMaterialPapPathAsByteString(ref this CharacterBase character, uint slotIndex, uint unkSId)
@ -66,6 +64,12 @@ internal static class StructExtensions
return ToOwnedByteString(character.ResolvePhybPath(pathBuffer, partialSkeletonIndex));
}
public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
{
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveKdbPath(pathBuffer, CharacterBase.PathBufferSize, partialSkeletonIndex));
}
private static unsafe CiByteString ToOwnedByteString(CStringPointer str)
=> str.HasValue ? new CiByteString(str.Value).Clone() : CiByteString.Empty;

View file

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

View file

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

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

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

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

@ -21,8 +21,8 @@ using Penumbra.UI;
using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager;
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 +211,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<RenderTargetHdrEnabler>();
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<RenderTargetHdrEnabler>();
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 +224,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");

View file

@ -1,4 +1,4 @@
<Project Sdk="Dalamud.NET.Sdk/13.0.0">
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup>
<AssemblyTitle>Penumbra</AssemblyTitle>
<Company>absolute gangstas</Company>

View file

@ -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.",

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,209 @@
using OtterGui.Services;
using Penumbra.Mods.Manager;
namespace Penumbra.Services;
public class FileWatcher : IDisposable, IService
{
// TODO: use ConcurrentSet when it supports comparers in Luna.
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private readonly ModImportManager _modImportManager;
private readonly MessageService _messageService;
private readonly Configuration _config;
private bool _pausedConsumer;
private FileSystemWatcher? _fsw;
private CancellationTokenSource? _cts = new();
private Task? _consumer;
public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config)
{
_modImportManager = modImportManager;
_messageService = messageService;
_config = config;
if (_config.EnableDirectoryWatch)
{
SetupFileWatcher(_config.WatchDirectory);
SetupConsumerTask();
}
}
public void Toggle(bool value)
{
if (_config.EnableDirectoryWatch == value)
return;
_config.EnableDirectoryWatch = value;
_config.Save();
if (value)
{
SetupFileWatcher(_config.WatchDirectory);
SetupConsumerTask();
}
else
{
EndFileWatcher();
EndConsumerTask();
}
}
internal void PauseConsumer(bool pause)
=> _pausedConsumer = pause;
private void EndFileWatcher()
{
if (_fsw is null)
return;
_fsw.Dispose();
_fsw = null;
}
private void SetupFileWatcher(string directory)
{
EndFileWatcher();
_fsw = new FileSystemWatcher
{
IncludeSubdirectories = false,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
InternalBufferSize = 32 * 1024,
};
// Only wake us for the exact patterns we care about
_fsw.Filters.Add("*.pmp");
_fsw.Filters.Add("*.pcp");
_fsw.Filters.Add("*.ttmp");
_fsw.Filters.Add("*.ttmp2");
_fsw.Created += OnPath;
_fsw.Renamed += OnPath;
UpdateDirectory(directory);
}
private void EndConsumerTask()
{
if (_cts is not null)
{
_cts.Cancel();
_cts = null;
}
_consumer = null;
}
private void SetupConsumerTask()
{
EndConsumerTask();
_cts = new CancellationTokenSource();
_consumer = Task.Factory.StartNew(
() => ConsumerLoopAsync(_cts.Token),
_cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap();
}
public void UpdateDirectory(string newPath)
{
if (_config.WatchDirectory != newPath)
{
_config.WatchDirectory = newPath;
_config.Save();
}
if (_fsw is null)
return;
_fsw.EnableRaisingEvents = false;
if (!Directory.Exists(newPath) || newPath.Length is 0)
{
_fsw.Path = string.Empty;
}
else
{
_fsw.Path = newPath;
_fsw.EnableRaisingEvents = true;
}
}
private void OnPath(object? sender, FileSystemEventArgs e)
=> _pending.TryAdd(e.FullPath, 0);
private async Task ConsumerLoopAsync(CancellationToken token)
{
while (true)
{
var (path, _) = _pending.FirstOrDefault();
if (path is null || _pausedConsumer)
{
await Task.Delay(500, token).ConfigureAwait(false);
continue;
}
try
{
await ProcessOneAsync(path, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Penumbra.Log.Debug("[FileWatcher] Canceled via Token.");
}
catch (Exception ex)
{
Penumbra.Log.Warning($"[FileWatcher] Error during Processing: {ex}");
}
finally
{
_pending.TryRemove(path, out _);
}
}
}
private async Task ProcessOneAsync(string path, CancellationToken token)
{
// Downloads often finish via rename; file may be locked briefly.
// Wait until it exists and is readable; also require two stable size checks.
const int maxTries = 40;
long lastLen = -1;
for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++)
{
if (!File.Exists(path))
{
await Task.Delay(100, token);
continue;
}
try
{
var fi = new FileInfo(path);
var len = fi.Length;
if (len > 0 && len == lastLen)
{
if (_config.EnableAutomaticModImport)
_modImportManager.AddUnpack(path);
else
_messageService.AddMessage(new InstallNotification(_modImportManager, path), false);
return;
}
lastLen = len;
}
catch (IOException)
{
Penumbra.Log.Debug($"[FileWatcher] File is still being written to.");
}
catch (UnauthorizedAccessException)
{
Penumbra.Log.Debug($"[FileWatcher] File is locked.");
}
await Task.Delay(150, token);
}
}
public void Dispose()
{
EndConsumerTask();
EndFileWatcher();
}
}

View file

@ -0,0 +1,39 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.EventArgs;
using OtterGui.Text;
using Penumbra.Mods.Manager;
namespace Penumbra.Services;
public class InstallNotification(ModImportManager modImportManager, string filePath) : OtterGui.Classes.MessageService.IMessage
{
public string Message
=> "A new mod has been found!";
public NotificationType NotificationType
=> NotificationType.Info;
public uint NotificationDuration
=> uint.MaxValue;
public string NotificationTitle { get; } = Path.GetFileNameWithoutExtension(filePath);
public string LogMessage
=> $"A new mod has been found: {Path.GetFileName(filePath)}";
public void OnNotificationActions(INotificationDrawArgs args)
{
var region = ImGui.GetContentRegionAvail();
var buttonSize = new Vector2((region.X - ImGui.GetStyle().ItemSpacing.X) / 2, 0);
if (ImUtf8.ButtonEx("Install"u8, ""u8, buttonSize))
{
modImportManager.AddUnpack(filePath);
args.Notification.DismissNow();
}
ImGui.SameLine();
if (ImUtf8.ButtonEx("Ignore"u8, ""u8, buttonSize))
args.Notification.DismissNow();
}
}

View file

@ -0,0 +1,308 @@
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} collections starting with PCP/.");
foreach (var collection in collections)
_collections.Storage.RemoveCollection(collection);
}
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory)
{
if (type is not ModPathChangeType.Added || _config.PcpSettings.DisableHandling || 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");
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;
}
Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying.");
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<string>() is { } collectionName)
{
var name = $"PCP/{collectionName}";
if (_collections.Storage.AddCollection(name, null))
{
collection = _collections.Storage[^1];
_collections.Editor.SetModState(collection, mod, true);
// Assign collection.
if (_config.PcpSettings.AssignCollection)
{
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.PcpSettings.FolderName);
_fileSystem.Move(leaf, folder);
}
catch
{
// ignored.
}
}
// Invoke IPC.
if (_config.PcpSettings.AllowIpc)
_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, meta) = 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}.");
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, meta, 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.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);
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, MetaDictionary meta, ResourceTree tree,
CancellationToken cancel = default)
{
var subDirectory = modDirectory.CreateSubdirectory("files");
var subMod = new DefaultSubMod(null!)
{
Manipulations = meta,
};
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();
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

@ -8,6 +8,7 @@ using OtterGui.Compression;
using OtterGui.Raii;
using OtterGui.Text;
using OtterGui.Widgets;
using Penumbra.GameData.Data;
using Penumbra.GameData.Files;
using Penumbra.Mods.Editor;
using Penumbra.Services;
@ -80,7 +81,7 @@ public class FileEditor<T>(
private Exception? _currentException;
private bool _changed;
private string _defaultPath = string.Empty;
private string _defaultPath = typeof(T) == typeof(ModEditWindow.PbdTab) ? GamePaths.Pbd.Path : string.Empty;
private bool _inInput;
private Utf8GamePath _defaultPathUtf8;
private bool _isDefaultPathUtf8Valid;

View file

@ -131,7 +131,7 @@ public sealed unsafe class MaterialTemplatePickers : IUiService
if (texture == null)
continue;
var handle = _textureArraySlicer.GetImGuiHandle(texture, sliceIndex);
if (handle == 0)
if (handle.IsNull)
continue;
var position = regionStart with { X = regionStart.X + (itemSize.X + itemSpacing) * j };

View file

@ -67,7 +67,7 @@ public partial class MtrlTab
private static void DrawLegacyColorTableHeader(bool hasDyeTable)
{
ImGui.TableNextColumn();
ImUtf8.TableHeader(default(ReadOnlySpan<byte>));
ImUtf8.TableHeader(""u8);
ImGui.TableNextColumn();
ImUtf8.TableHeader("Row"u8);
ImGui.TableNextColumn();

View file

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

View file

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

View file

@ -17,7 +17,6 @@ public partial class ModEditWindow
private readonly FileDialogService _fileDialog;
private readonly ResourceTreeFactory _resourceTreeFactory;
private readonly ResourceTreeViewer _quickImportViewer;
private readonly Dictionary<FullPath, IWritable?> _quickImportWritables = new();
private readonly Dictionary<(Utf8GamePath, IWritable?), QuickImportAction> _quickImportActions = new();
private HashSet<string> 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";

View file

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

View file

@ -1,15 +1,24 @@
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using Dalamud.Bindings.ImGui;
using OtterGui.Raii;
using Dalamud.Interface;
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.UI.Classes;
using Penumbra.String;
using OtterGui.Extensions;
using Penumbra.String.Classes;
using Penumbra.UI.Classes;
namespace Penumbra.UI.AdvancedWindow;
@ -20,25 +29,32 @@ public class ResourceTreeViewer(
IncognitoService incognito,
int actionCapacity,
Action onRefresh,
Action<ResourceNode, Vector2> drawActions,
CommunicatorService communicator)
Action<ResourceNode, IWritable?, Vector2> drawActions,
CommunicatorService communicator,
PcpService pcpService,
IDataManager gameData,
FileDialogService fileDialog,
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<nint> _unfolded = [];
private readonly HashSet<nint> _unfolded = [];
private readonly Dictionary<nint, NodeVisibility> _filterCache = [];
private readonly Dictionary<nint, NodeVisibility> _filterCache = [];
private readonly Dictionary<FullPath, IWritable?> _writableCache = [];
private TreeCategory _categoryFilter = AllCategories;
private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags;
private string _nameFilter = string.Empty;
private string _nodeFilter = string.Empty;
private string _note = string.Empty;
private Task<ResourceTree[]>? _task;
public void Draw()
{
DrawModifiedGameFilesWarning();
DrawControls();
_task ??= RefreshCharacterList();
@ -83,9 +99,30 @@ 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 =>
{
using var table = ImRaii.Table("##ResourceTree", actionCapacity > 0 ? 4 : 3,
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", 4,
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg);
if (!table)
continue;
@ -93,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);
@ -103,6 +139,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;
@ -163,6 +217,7 @@ public class ResourceTreeViewer(
finally
{
_filterCache.Clear();
_writableCache.Clear();
_unfolded.Clear();
onRefresh();
}
@ -173,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())
{
@ -243,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);
@ -263,16 +317,30 @@ 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, frameHeight));
}
ImGui.SameLine();
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, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap,
new Vector2(ImGui.GetContentRegionAvail().X, frameHeight));
}
if (ImGui.IsItemClicked())
@ -286,20 +354,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);
@ -352,6 +417,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<byte> GetPathStatusLabel(ResourceNode.PathStatus status)
@ -365,9 +475,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]
@ -414,4 +525,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;
}
}

View file

@ -1,4 +1,7 @@
using Dalamud.Plugin.Services;
using OtterGui.Compression;
using OtterGui.Services;
using Penumbra.GameData.Files;
using Penumbra.Interop.ResourceTree;
using Penumbra.Services;
@ -9,8 +12,13 @@ public class ResourceTreeViewerFactory(
ResourceTreeFactory treeFactory,
ChangedItemDrawer changedItemDrawer,
IncognitoService incognito,
CommunicatorService communicator) : IService
CommunicatorService communicator,
PcpService pcpService,
IDataManager gameData,
FileDialogService fileDialog,
FileCompactor compactor) : IService
{
public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action<ResourceNode, Vector2> drawActions)
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator);
public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action<ResourceNode, IWritable?, Vector2> drawActions)
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData,
fileDialog, compactor);
}

View file

@ -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.")

View file

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

View file

@ -116,7 +116,8 @@ public sealed class CollectionSelector : ItemSelector<ModCollection>, 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);

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

@ -30,7 +30,7 @@ public class ModPanelDescriptionTab(
ImGui.Dummy(ImGuiHelpers.ScaledVector2(2));
ImGui.Dummy(ImGuiHelpers.ScaledVector2(2));
var (predefinedTagsEnabled, predefinedTagButtonOffset) = predefinedTagsConfig.Count > 0
var (predefinedTagsEnabled, predefinedTagButtonOffset) = predefinedTagsConfig.Enabled
? (true, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.X + (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize : 0))
: (false, 0);
var tagIdx = _localTags.Draw("Local Tags: ",

View file

@ -69,7 +69,7 @@ public class ModPanelEditTab(
FeatureChecker.DrawFeatureFlagInput(modManager.DataEditor, _mod, UiHelpers.InputTextWidth.X);
UiHelpers.DefaultLineSpace();
var sharedTagsEnabled = predefinedTagManager.Count > 0;
var sharedTagsEnabled = predefinedTagManager.Enabled;
var sharedTagButtonOffset = sharedTagsEnabled ? ImGui.GetFrameHeight() + ImGui.GetStyle().FramePadding.X : 0;
var tagIdx = _modTags.Draw("Mod Tags: ", "Edit tags by clicking them, or add new tags. Empty tags are removed.", _mod.ModTags,
out var editedTag, rightEndOffset: sharedTagButtonOffset);

View file

@ -2,6 +2,7 @@ using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using OtterGui.Extensions;
using OtterGui.Filesystem;
using OtterGui.Raii;
using OtterGui.Services;
using OtterGui.Text;
@ -10,7 +11,7 @@ using Penumbra.Mods.Manager;
namespace Penumbra.UI.ModsTab;
public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor) : IUiService
public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor, PredefinedTagManager tagManager) : IUiService
{
public void Draw()
{
@ -97,7 +98,12 @@ public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor)
var width = ImGuiHelpers.ScaledVector2(150, 0);
ImUtf8.TextFrameAligned("Multi Tagger:"u8);
ImGui.SameLine();
ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X - 2 * (width.X + ImGui.GetStyle().ItemSpacing.X));
var predefinedTagsEnabled = tagManager.Enabled;
var inputWidth = predefinedTagsEnabled
? ImGui.GetContentRegionAvail().X - 2 * width.X - 3 * ImGui.GetStyle().ItemInnerSpacing.X - ImGui.GetFrameHeight()
: ImGui.GetContentRegionAvail().X - 2 * (width.X + ImGui.GetStyle().ItemInnerSpacing.X);
ImGui.SetNextItemWidth(inputWidth);
ImUtf8.InputText("##tag"u8, ref _tag, "Local Tag Name..."u8);
UpdateTagCache();
@ -109,7 +115,7 @@ public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor)
? "No tag specified."
: $"All mods selected already contain the tag \"{_tag}\", either locally or as mod data."
: $"Add the tag \"{_tag}\" to {_addMods.Count} mods as a local tag:\n\n\t{string.Join("\n\t", _addMods.Select(m => m.Name.Text))}";
ImGui.SameLine();
ImUtf8.SameLineInner();
if (ImUtf8.ButtonEx(label, tooltip, width, _addMods.Count == 0))
foreach (var mod in _addMods)
editor.ChangeLocalTag(mod, mod.LocalTags.Count, _tag);
@ -122,10 +128,18 @@ public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor)
? "No tag specified."
: $"No selected mod contains the tag \"{_tag}\" locally."
: $"Remove the local tag \"{_tag}\" from {_removeMods.Count} mods:\n\n\t{string.Join("\n\t", _removeMods.Select(m => m.Item1.Name.Text))}";
ImGui.SameLine();
ImUtf8.SameLineInner();
if (ImUtf8.ButtonEx(label, tooltip, width, _removeMods.Count == 0))
foreach (var (mod, index) in _removeMods)
editor.ChangeLocalTag(mod, index, string.Empty);
if (predefinedTagsEnabled)
{
ImUtf8.SameLineInner();
tagManager.DrawToggleButton();
tagManager.DrawListMulti(selector.SelectedPaths.OfType<ModFileSystem.Leaf>().Select(l => l.Value));
}
ImGui.Separator();
}

View file

@ -8,6 +8,8 @@ using OtterGui.Classes;
using OtterGui.Extensions;
using OtterGui.Raii;
using OtterGui.Services;
using OtterGui.Text;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.UI.Classes;
@ -52,6 +54,9 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList<string>, ISer
jObj.WriteTo(jWriter);
}
public bool Enabled
=> Count > 0;
public void Save()
=> _saveService.DelaySave(this, TimeSpan.FromSeconds(5));
@ -98,9 +103,9 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList<string>, ISer
}
public void DrawAddFromSharedTagsAndUpdateTags(IReadOnlyCollection<string> localTags, IReadOnlyCollection<string> modTags, bool editLocal,
Mods.Mod mod)
Mod mod)
{
DrawToggleButton();
DrawToggleButtonTopRight();
if (!DrawList(localTags, modTags, editLocal, out var changedTag, out var index))
return;
@ -110,17 +115,22 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList<string>, ISer
_modManager.DataEditor.ChangeModTag(mod, index, changedTag);
}
private void DrawToggleButton()
public void DrawToggleButton()
{
ImGui.SameLine(ImGui.GetContentRegionMax().X
- ImGui.GetFrameHeight()
- (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ItemInnerSpacing.X : 0));
using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.ButtonActive), _isListOpen);
if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Tags.ToIconString(), new Vector2(ImGui.GetFrameHeight()),
"Add Predefined Tags...", false, true))
_isListOpen = !_isListOpen;
}
private void DrawToggleButtonTopRight()
{
ImGui.SameLine(ImGui.GetContentRegionMax().X
- ImGui.GetFrameHeight()
- (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ItemInnerSpacing.X : 0));
DrawToggleButton();
}
private bool DrawList(IReadOnlyCollection<string> localTags, IReadOnlyCollection<string> modTags, bool editLocal, out string changedTag,
out int changedIndex)
{
@ -130,7 +140,7 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList<string>, ISer
if (!_isListOpen)
return false;
ImGui.TextUnformatted("Predefined Tags");
ImUtf8.Text("Predefined Tags"u8);
ImGui.Separator();
var ret = false;
@ -155,6 +165,101 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList<string>, ISer
return ret;
}
private readonly List<Mod> _selectedMods = [];
private readonly List<(int Index, int DataIndex)> _countedMods = [];
private void PrepareLists(IEnumerable<Mod> selection)
{
_selectedMods.Clear();
_selectedMods.AddRange(selection);
_countedMods.EnsureCapacity(_selectedMods.Count);
while (_countedMods.Count < _selectedMods.Count)
_countedMods.Add((-1, -1));
}
public void DrawListMulti(IEnumerable<Mod> selection)
{
if (!_isListOpen)
return;
ImUtf8.Text("Predefined Tags"u8);
PrepareLists(selection);
_enabledColor = ColorId.PredefinedTagAdd.Value();
_disabledColor = ColorId.PredefinedTagRemove.Value();
using var color = new ImRaii.Color();
foreach (var (tag, idx) in _predefinedTags.Keys.WithIndex())
{
var alreadyContained = 0;
var inModData = 0;
var missing = 0;
foreach (var (modIndex, mod) in _selectedMods.Index())
{
var tagIdx = mod.LocalTags.IndexOf(tag);
if (tagIdx >= 0)
{
++alreadyContained;
_countedMods[modIndex] = (tagIdx, -1);
}
else
{
var dataIdx = mod.ModTags.IndexOf(tag);
if (dataIdx >= 0)
{
++inModData;
_countedMods[modIndex] = (-1, dataIdx);
}
else
{
++missing;
_countedMods[modIndex] = (-1, -1);
}
}
}
using var id = ImRaii.PushId(idx);
var buttonWidth = CalcTextButtonWidth(tag);
// Prevent adding a new tag past the right edge of the popup
if (buttonWidth + ImGui.GetStyle().ItemSpacing.X >= ImGui.GetContentRegionAvail().X)
ImGui.NewLine();
var (usedColor, disabled, tt) = (missing, alreadyContained) switch
{
(> 0, _) => (_enabledColor, false,
$"Add this tag to {missing} mods.{(inModData > 0 ? $" {inModData} mods contain it in their mod tags and are untouched." : string.Empty)}"),
(_, > 0) => (_disabledColor, false,
$"Remove this tag from {alreadyContained} mods.{(inModData > 0 ? $" {inModData} mods contain it in their mod tags and are untouched." : string.Empty)}"),
_ => (_disabledColor, true, "This tag is already present in the mod tags of all selected mods."),
};
color.Push(ImGuiCol.Button, usedColor);
if (ImUtf8.ButtonEx(tag, tt, new Vector2(buttonWidth, 0), disabled))
{
if (missing > 0)
foreach (var (mod, (localIdx, _)) in _selectedMods.Zip(_countedMods))
{
if (localIdx >= 0)
continue;
_modManager.DataEditor.ChangeLocalTag(mod, mod.LocalTags.Count, tag);
}
else
foreach (var (mod, (localIdx, _)) in _selectedMods.Zip(_countedMods))
{
if (localIdx < 0)
continue;
_modManager.DataEditor.ChangeLocalTag(mod, localIdx, string.Empty);
}
}
ImGui.SameLine();
color.Pop();
}
ImGui.NewLine();
}
private bool DrawColoredButton(string buttonLabel, int index, int tagIdx, bool inOther)
{
using var id = ImRaii.PushId(index);

View file

@ -6,10 +6,11 @@ public static class DebugConfigurationDrawer
{
public static void Draw()
{
using var id = ImUtf8.CollapsingHeaderId("Debug Logging Options"u8);
using var id = ImUtf8.CollapsingHeaderId("Debugging Options"u8);
if (!id)
return;
ImUtf8.Checkbox("Log IMC File Replacements"u8, ref DebugConfiguration.WriteImcBytesToLog);
ImUtf8.Checkbox("Log IMC File Replacements"u8, ref DebugConfiguration.WriteImcBytesToLog);
ImUtf8.Checkbox("Scan for Skin Material Attributes"u8, ref DebugConfiguration.UseSkinMaterialProcessing);
}
}

View file

@ -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}");
}
}
/// <summary> Draw information about IPC options and availability. </summary>
private void DrawDebugTabIpc()
{
@ -1236,16 +1275,12 @@ public class DebugTab : Window, ITab, IUiService
}
public static unsafe void DrawCopyableAddress(ReadOnlySpan<byte> label, void* address)
{
using (var _ = ImRaii.PushFont(UiBuilder.MonoFont))
{
if (ImUtf8.Selectable($"0x{(nint)address:X16} {label}"))
ImUtf8.SetClipboardText($"0x{(nint)address:X16}");
}
ImUtf8.HoverTooltip("Click to copy address to clipboard."u8);
}
=> DrawCopyableAddress(label, (nint)address);
public static unsafe void DrawCopyableAddress(ReadOnlySpan<byte> label, nint address)
=> DrawCopyableAddress(label, (void*)address);
{
Penumbra.Dynamis.DrawPointer(address);
ImUtf8.SameLineInner();
ImUtf8.Text(label);
}
}

View file

@ -27,14 +27,31 @@ public unsafe class GlobalVariablesDrawer(
return;
var actionManager = (ActionTimelineManager**)ActionTimelineManager.Instance();
DebugTab.DrawCopyableAddress("CharacterUtility"u8, characterUtility.Address);
DebugTab.DrawCopyableAddress("ResidentResourceManager"u8, residentResources.Address);
DebugTab.DrawCopyableAddress("ScheduleManagement"u8, ScheduleManagement.Instance());
DebugTab.DrawCopyableAddress("ActionTimelineManager*"u8, actionManager);
DebugTab.DrawCopyableAddress("ActionTimelineManager"u8, actionManager != null ? *actionManager : null);
DebugTab.DrawCopyableAddress("SchedulerResourceManagement*"u8, scheduler.Address);
DebugTab.DrawCopyableAddress("SchedulerResourceManagement"u8, scheduler.Address != null ? *scheduler.Address : null);
DebugTab.DrawCopyableAddress("Device"u8, Device.Instance());
using (ImUtf8.Group())
{
Penumbra.Dynamis.DrawPointer(characterUtility.Address);
Penumbra.Dynamis.DrawPointer(residentResources.Address);
Penumbra.Dynamis.DrawPointer(ScheduleManagement.Instance());
Penumbra.Dynamis.DrawPointer(actionManager);
Penumbra.Dynamis.DrawPointer(actionManager != null ? *actionManager : null);
Penumbra.Dynamis.DrawPointer(scheduler.Address);
Penumbra.Dynamis.DrawPointer(scheduler.Address != null ? *scheduler.Address : null);
Penumbra.Dynamis.DrawPointer(Device.Instance());
}
ImGui.SameLine();
using (ImUtf8.Group())
{
ImUtf8.Text("CharacterUtility"u8);
ImUtf8.Text("ResidentResourceManager"u8);
ImUtf8.Text("ScheduleManagement"u8);
ImUtf8.Text("ActionTimelineManager*"u8);
ImUtf8.Text("ActionTimelineManager"u8);
ImUtf8.Text("SchedulerResourceManagement*"u8);
ImUtf8.Text("SchedulerResourceManagement"u8);
ImUtf8.Text("Device"u8);
}
DrawCharacterUtility();
DrawResidentResources();
DrawSchedulerResourcesMap();
@ -63,7 +80,7 @@ public unsafe class GlobalVariablesDrawer(
var resource = characterUtility.Address->Resource(idx);
ImUtf8.DrawTableColumn($"[{idx}]");
ImGui.TableNextColumn();
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resource:X}");
Penumbra.Dynamis.DrawPointer(resource);
if (resource == null)
{
ImGui.TableNextRow();
@ -74,25 +91,12 @@ public unsafe class GlobalVariablesDrawer(
ImGui.TableNextColumn();
var data = (nint)resource->CsHandle.GetData();
var length = resource->CsHandle.GetLength();
if (ImUtf8.Selectable($"0x{data:X}"))
if (data != nint.Zero && length > 0)
ImUtf8.SetClipboardText(string.Join("\n",
new ReadOnlySpan<byte>((byte*)data, (int)length).ToArray().Select(b => b.ToString("X2"))));
ImUtf8.HoverTooltip("Click to copy bytes to clipboard."u8);
Penumbra.Dynamis.DrawPointer(data);
ImUtf8.DrawTableColumn(length.ToString());
ImGui.TableNextColumn();
if (intern.Value != -1)
{
ImUtf8.Selectable($"0x{characterUtility.DefaultResource(intern).Address:X}");
if (ImGui.IsItemClicked())
ImUtf8.SetClipboardText(string.Join("\n",
new ReadOnlySpan<byte>((byte*)characterUtility.DefaultResource(intern).Address,
characterUtility.DefaultResource(intern).Size).ToArray().Select(b => b.ToString("X2"))));
ImUtf8.HoverTooltip("Click to copy bytes to clipboard."u8);
Penumbra.Dynamis.DrawPointer(characterUtility.DefaultResource(intern).Address);
ImUtf8.DrawTableColumn($"{characterUtility.DefaultResource(intern).Size}");
}
else
@ -122,7 +126,7 @@ public unsafe class GlobalVariablesDrawer(
var resource = residentResources.Address->ResourceList[idx];
ImUtf8.DrawTableColumn($"[{idx}]");
ImGui.TableNextColumn();
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resource:X}");
Penumbra.Dynamis.DrawPointer(resource);
if (resource == null)
{
ImGui.TableNextRow();
@ -133,12 +137,7 @@ public unsafe class GlobalVariablesDrawer(
ImGui.TableNextColumn();
var data = (nint)resource->CsHandle.GetData();
var length = resource->CsHandle.GetLength();
if (ImUtf8.Selectable($"0x{data:X}"))
if (data != nint.Zero && length > 0)
ImUtf8.SetClipboardText(string.Join("\n",
new ReadOnlySpan<byte>((byte*)data, (int)length).ToArray().Select(b => b.ToString("X2"))));
ImUtf8.HoverTooltip("Click to copy bytes to clipboard."u8);
Penumbra.Dynamis.DrawPointer(data);
ImUtf8.DrawTableColumn(length.ToString());
}
}
@ -184,15 +183,15 @@ public unsafe class GlobalVariablesDrawer(
ImUtf8.DrawTableColumn($"{resource->Consumers}");
ImUtf8.DrawTableColumn($"{resource->Unk1}"); // key
ImGui.TableNextColumn();
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resource:X}");
Penumbra.Dynamis.DrawPointer(resource);
ImGui.TableNextColumn();
var resourceHandle = *((ResourceHandle**)resource + 3);
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resourceHandle:X}");
Penumbra.Dynamis.DrawPointer(resourceHandle);
ImGui.TableNextColumn();
ImUtf8.CopyOnClickSelectable(resourceHandle->FileName().Span);
ImGui.TableNextColumn();
uint dataLength = 0;
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resource->GetResourceData(&dataLength):X}");
Penumbra.Dynamis.DrawPointer(resource->GetResourceData(&dataLength));
ImUtf8.DrawTableColumn($"{dataLength}");
++_shownResourcesMap;
}
@ -233,15 +232,15 @@ public unsafe class GlobalVariablesDrawer(
ImUtf8.DrawTableColumn($"{resource->Consumers}");
ImUtf8.DrawTableColumn($"{resource->Unk1}"); // key
ImGui.TableNextColumn();
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resource:X}");
Penumbra.Dynamis.DrawPointer(resource);
ImGui.TableNextColumn();
var resourceHandle = *((ResourceHandle**)resource + 3);
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resourceHandle:X}");
Penumbra.Dynamis.DrawPointer(resourceHandle);
ImGui.TableNextColumn();
ImUtf8.CopyOnClickSelectable(resourceHandle->FileName().Span);
ImGui.TableNextColumn();
uint dataLength = 0;
ImUtf8.CopyOnClickSelectable($"0x{(ulong)resource->GetResourceData(&dataLength):X}");
Penumbra.Dynamis.DrawPointer(resource->GetResourceData(&dataLength));
ImUtf8.DrawTableColumn($"{dataLength}");
++_shownResourcesList;
}

View file

@ -12,16 +12,15 @@ using OtterGui.Raii;
using OtterGui.Services;
using OtterGui.Text;
using OtterGui.Widgets;
using OtterGuiInternal.Enums;
using Penumbra.Api;
using Penumbra.Collections;
using Penumbra.Interop;
using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Interop.Services;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.UI.Classes;
using Penumbra.UI.ModsTab;
using ImGuiId = OtterGuiInternal.Enums.ImGuiId;
namespace Penumbra.UI.Tabs;
@ -38,6 +37,7 @@ public class SettingsTab : ITab, IUiService
private readonly Penumbra _penumbra;
private readonly FileDialogService _fileDialog;
private readonly ModManager _modManager;
private readonly FileWatcher _fileWatcher;
private readonly ModExportManager _modExportManager;
private readonly ModFileSystemSelector _selector;
private readonly CharacterUtility _characterUtility;
@ -54,19 +54,24 @@ 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;
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,
CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager,
FileWatcher fileWatcher, HttpApi httpApi,
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;
@ -79,6 +84,7 @@ public class SettingsTab : ITab, IUiService
_characterUtility = characterUtility;
_residentResources = residentResources;
_modExportManager = modExportManager;
_fileWatcher = fileWatcher;
_httpApi = httpApi;
_dalamudSubstitutionProvider = dalamudSubstitutionProvider;
_compactor = compactor;
@ -92,6 +98,7 @@ public class SettingsTab : ITab, IUiService
_autoSelector = autoSelector;
_cleanupService = cleanupService;
_attributeHook = attributeHook;
_pcpService = pcpService;
}
public void DrawHeader()
@ -208,6 +215,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);
@ -600,10 +616,47 @@ 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.PcpSettings.DisableHandling, v => _config.PcpSettings.DisableHandling = !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.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();
DrawPcpFolder();
DrawDefaultModExportPath();
Checkbox("Enable Directory Watcher",
"Enables a File Watcher that automatically listens for Mod files that enter a specified directory, causing Penumbra to open a popup to import these mods.",
_config.EnableDirectoryWatch, _fileWatcher.Toggle);
Checkbox("Enable Fully Automatic Import",
"Uses the File Watcher in order to skip the query popup and automatically import any new mods.",
_config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v);
DrawFileWatcherPath();
}
@ -683,6 +736,46 @@ public class SettingsTab : ITab, IUiService
+ "Keep this empty to use the root directory.");
}
private string? _tempWatchDirectory;
/// <summary> Draw input for the Automatic Mod import path. </summary>
private void DrawFileWatcherPath()
{
var tmp = _tempWatchDirectory ?? _config.WatchDirectory;
var spacing = new Vector2(UiHelpers.ScaleX3);
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing);
ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3);
if (ImGui.InputText("##fileWatchPath", ref tmp, 256))
_tempWatchDirectory = tmp;
if (ImGui.IsItemDeactivated() && _tempWatchDirectory is not null)
{
if (ImGui.IsItemDeactivatedAfterEdit())
_fileWatcher.UpdateDirectory(_tempWatchDirectory);
_tempWatchDirectory = null;
}
ImGui.SameLine();
if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##fileWatch", UiHelpers.IconButtonSize,
"Select a directory via dialog.", false, true))
{
var startDir = _config.WatchDirectory.Length > 0 && Directory.Exists(_config.WatchDirectory)
? _config.WatchDirectory
: Directory.Exists(_config.ModDirectory)
? _config.ModDirectory
: null;
_fileDialog.OpenFolderPicker("Choose Automatic Import Directory", (b, s) =>
{
if (b)
_fileWatcher.UpdateDirectory(s);
}, startDir, false);
}
style.Pop();
ImGuiUtil.LabeledHelpMarker("Automatic Import Director",
"Choose the Directory the File Watcher listens to.");
}
/// <summary> Draw input for the default name to input as author into newly generated mods. </summary>
private void DrawDefaultModAuthor()
{
@ -712,6 +805,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.PcpSettings.FolderName;
ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X);
if (ImUtf8.InputText("##pcpFolder"u8, ref tmp))
_config.PcpSettings.FolderName = 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 +1163,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

@ -58,6 +58,19 @@
"resolved": "3.1.11",
"contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw=="
},
"FlatSharp.Compiler": {
"type": "Transitive",
"resolved": "7.9.0",
"contentHash": "MU6808xvdbWJ3Ev+5PKalqQuzvVbn1DzzQH8txRDHGFUNDvHjd+ejqpvnYc9BSJ8Qp8VjkkpJD8OzRYilbPp3A=="
},
"FlatSharp.Runtime": {
"type": "Transitive",
"resolved": "7.9.0",
"contentHash": "Bm8+WqzEsWNpxqrD5x4x+zQ8dyINlToCreM5FI2oNSfUVc9U9ZB+qztX/jd8rlJb3r0vBSlPwVLpw0xBtPa3Vw==",
"dependencies": {
"System.Memory": "4.5.5"
}
},
"JetBrains.Annotations": {
"type": "Transitive",
"resolved": "2024.3.0",
@ -94,6 +107,11 @@
"resolved": "4.6.0",
"contentHash": "lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA=="
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.5",
"contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw=="
},
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
"resolved": "8.0.1",
@ -133,8 +151,10 @@
"penumbra.gamedata": {
"type": "Project",
"dependencies": {
"FlatSharp.Compiler": "[7.9.0, )",
"FlatSharp.Runtime": "[7.9.0, )",
"OtterGui": "[1.0.0, )",
"Penumbra.Api": "[5.6.1, )",
"Penumbra.Api": "[5.10.0, )",
"Penumbra.String": "[1.0.6, )"
}
},

View file

@ -1,16 +1,16 @@
[
{
"Author": "Ottermandias, Adam, Wintermute",
"Author": "Ottermandias, Nylfae, Adam, Wintermute",
"Name": "Penumbra",
"Punchline": "Runtime mod loader and manager.",
"Description": "Runtime mod loader and manager.",
"InternalName": "Penumbra",
"AssemblyVersion": "1.4.0.1",
"TestingAssemblyVersion": "1.4.0.6",
"AssemblyVersion": "1.5.1.8",
"TestingAssemblyVersion": "1.5.1.8",
"RepoUrl": "https://github.com/xivdev/Penumbra",
"ApplicableVersion": "any",
"DalamudApiLevel": 12,
"TestingDalamudApiLevel": 12,
"DalamudApiLevel": 13,
"TestingDalamudApiLevel": 13,
"IsHide": "False",
"IsTestingExclusive": "False",
"DownloadCount": 0,
@ -18,9 +18,9 @@
"LoadPriority": 69420,
"LoadRequiredState": 2,
"LoadSync": true,
"DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.4.0.1/Penumbra.zip",
"DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.4.0.6/Penumbra.zip",
"DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.4.0.1/Penumbra.zip",
"DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.8/Penumbra.zip",
"DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.8/Penumbra.zip",
"DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.8/Penumbra.zip",
"IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png"
}
]