mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-13 12:14:17 +01:00
Compare commits
55 commits
testing_1.
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb5b01290 | ||
|
|
5dd74297c6 | ||
|
|
ce54aa5d25 | ||
|
|
c4b6e4e00b | ||
|
|
912c183fc6 | ||
|
|
5bf901d0c4 | ||
|
|
cbedc878b9 | ||
|
|
c8cf560fc1 | ||
|
|
f05cb52da2 | ||
|
|
7ed81a9823 | ||
|
|
60aa23efcd | ||
|
|
ebbe957c95 | ||
|
|
300e0e6d84 | ||
|
|
049baa4fe4 | ||
|
|
0881dfde8a | ||
|
|
23c0506cb8 | ||
|
|
699745413e | ||
|
|
eb53f04c6b | ||
|
|
c6b596169c | ||
|
|
a0c3e820b0 | ||
|
|
a59689ebfe | ||
|
|
e9f67a009b | ||
|
|
97c8d82b33 | ||
|
|
c3b00ff426 | ||
|
|
6348c4a639 | ||
|
|
5a6e06df3b | ||
|
|
f5f6dd3246 | ||
|
|
4e788f7c2b | ||
|
|
ad1659caf6 | ||
|
|
18a6ce2a5f | ||
|
|
e68e821b2a | ||
|
|
96764b34ca | ||
|
|
2cf60b78cd | ||
|
|
d59be1e660 | ||
|
|
5503bb32e0 | ||
|
|
f3ec4b2e08 | ||
|
|
b3379a9710 | ||
|
|
8c25ef4b47 | ||
|
|
912020cc3f | ||
|
|
be8987a451 | ||
|
|
f7cf5503bb | ||
|
|
a04a5a071c | ||
|
|
71e24c13c7 | ||
|
|
c0120f81af | ||
|
|
da47c19aeb | ||
|
|
e16800f216 | ||
|
|
79a4fc5904 | ||
|
|
bf90725dd2 | ||
|
|
a14347f73a | ||
|
|
1e07e43498 | ||
|
|
f51f8a7bf8 | ||
|
|
1fca78fa71 | ||
|
|
c8b6325a87 | ||
|
|
6079103505 | ||
|
|
d302a17f1f |
48 changed files with 1062 additions and 202 deletions
2
OtterGui
2
OtterGui
|
|
@ -1 +1 @@
|
|||
Subproject commit 4a9b71a93e76aa5eed818542288329e34ec0dd89
|
||||
Subproject commit a63f6735cf4bed4f7502a022a10378607082b770
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 0a970295b2398683b1e49c46fd613541e2486210
|
||||
Subproject commit 3d6cee1a11922ccd426f36060fd026bc1a698adf
|
||||
|
|
@ -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 15e7c8eb41867e6bbd3fe6a8885404df087bc7e7
|
||||
Subproject commit d889f9ef918514a46049725052d378b441915b00
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 878acce46e286867d6ef1f8ecedb390f7bac34fd
|
||||
Subproject commit c8611a0c546b6b2ec29214ab319fc2c38fe74793
|
||||
7
Penumbra/Api/Api/IdentityChecker.cs
Normal file
7
Penumbra/Api/Api/IdentityChecker.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace Penumbra.Api.Api;
|
||||
|
||||
public static class IdentityChecker
|
||||
{
|
||||
public static bool Check(string identity)
|
||||
=> true;
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable
|
|||
public event Action<string>? ModAdded;
|
||||
public event Action<string, string>? ModMoved;
|
||||
|
||||
public event Action<JObject, ushort>? CreatingPcp
|
||||
public event Action<JObject, ushort, string>? CreatingPcp
|
||||
{
|
||||
add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi);
|
||||
remove => _communicator.PcpCreation.Unsubscribe(value!);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ public class PenumbraApi(
|
|||
UiApi ui) : IDisposable, IApiService, IPenumbraApi
|
||||
{
|
||||
public const int BreakingVersion = 5;
|
||||
public const int FeatureVersion = 11;
|
||||
public const int FeatureVersion = 13;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ namespace Penumbra.Communication;
|
|||
/// <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, PcpCreation.Priority>(nameof(PcpCreation))
|
||||
public sealed class PcpCreation() : EventWrapper<JObject, ushort, string, PcpCreation.Priority>(nameof(PcpCreation))
|
||||
{
|
||||
public enum Priority
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,12 +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 bool DisablePcpHandling { get; set; } = false;
|
||||
public bool AllowPcpIpc { get; set; } = true;
|
||||
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;
|
||||
public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed;
|
||||
public int OptionGroupCollapsibleMin { get; set; } = 5;
|
||||
public PcpSettings PcpSettings = new();
|
||||
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;
|
||||
public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed;
|
||||
public int OptionGroupCollapsibleMin { get; set; } = 5;
|
||||
|
||||
public Vector2 MinimumSize = new(Constants.MinimumSizeX, Constants.MinimumSizeY);
|
||||
|
||||
|
|
@ -90,7 +101,6 @@ public class Configuration : IPluginConfiguration, ISavable, IService
|
|||
public bool OpenFoldersByDefault { get; set; } = false;
|
||||
public int SingleGroupRadioMax { get; set; } = 2;
|
||||
public string DefaultImportFolder { get; set; } = string.Empty;
|
||||
public string PcpFolderName { get; set; } = "PCP";
|
||||
public string QuickMoveFolder1 { get; set; } = string.Empty;
|
||||
public string QuickMoveFolder2 { get; set; } = string.Empty;
|
||||
public string QuickMoveFolder3 { get; set; } = string.Empty;
|
||||
|
|
|
|||
47
Penumbra/Interop/CloudApi.cs
Normal file
47
Penumbra/Interop/CloudApi.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -63,8 +63,7 @@ public sealed unsafe class LoadTimelineResources : FastHook<LoadTimelineResource
|
|||
{
|
||||
if (timeline != null)
|
||||
{
|
||||
// TODO: Clientstructify
|
||||
var idx = ((delegate* unmanaged<SchedulerTimeline*, int>**)timeline)[0][29](timeline);
|
||||
var idx = timeline->GetOwningGameObjectIndex();
|
||||
if (idx >= 0 && idx < objects.TotalCount)
|
||||
{
|
||||
var obj = objects[idx];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable
|
|||
return false;
|
||||
|
||||
_copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx;
|
||||
_objects.InvokeRequiredUpdates();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,9 @@ public static unsafe class SkinMtrlPathEarlyProcessing
|
|||
if (character is null)
|
||||
return null;
|
||||
|
||||
if (character->TempSlotData is not null)
|
||||
if (character->PerSlotStagingArea is not null)
|
||||
{
|
||||
// TODO ClientStructs-ify
|
||||
var handle = *(ModelResourceHandle**)((nint)character->TempSlotData + 0xE0 * slotIndex + 0x8);
|
||||
var handle = character->PerSlotStagingArea[slotIndex].ModelResourceHandle;
|
||||
if (handle != null)
|
||||
return handle;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)'/');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -259,7 +261,8 @@ public class ResourceTree(
|
|||
for (var i = 0; i < skeleton->PartialSkeletonCount; ++i)
|
||||
{
|
||||
var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null;
|
||||
if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, (uint)i) is { } sklbNode)
|
||||
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}";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -64,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using System.Collections.Frozen;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Penumbra.Collections.Cache;
|
||||
using Penumbra.GameData.Enums;
|
||||
using Penumbra.GameData.Files.AtchStructs;
|
||||
using Penumbra.GameData.Interop;
|
||||
using Penumbra.GameData.Structs;
|
||||
using Penumbra.Util;
|
||||
using ImcEntry = Penumbra.GameData.Structs.ImcEntry;
|
||||
|
|
@ -40,6 +43,165 @@ public class MetaDictionary
|
|||
foreach (var geqp in cache.GlobalEqp.Keys)
|
||||
Add(geqp);
|
||||
}
|
||||
|
||||
public static unsafe Wrapper Filtered(MetaCache cache, Actor actor)
|
||||
{
|
||||
if (!actor.IsCharacter)
|
||||
return new Wrapper(cache);
|
||||
|
||||
var model = actor.Model;
|
||||
if (!model.IsHuman)
|
||||
return new Wrapper(cache);
|
||||
|
||||
var headId = model.GetModelId(HumanSlot.Head);
|
||||
var bodyId = model.GetModelId(HumanSlot.Body);
|
||||
var equipIdSet = ((IEnumerable<PrimaryId>)
|
||||
[
|
||||
headId,
|
||||
bodyId,
|
||||
model.GetModelId(HumanSlot.Hands),
|
||||
model.GetModelId(HumanSlot.Legs),
|
||||
model.GetModelId(HumanSlot.Feet),
|
||||
]).ToFrozenSet();
|
||||
var earsId = model.GetModelId(HumanSlot.Ears);
|
||||
var neckId = model.GetModelId(HumanSlot.Neck);
|
||||
var wristId = model.GetModelId(HumanSlot.Wrists);
|
||||
var rFingerId = model.GetModelId(HumanSlot.RFinger);
|
||||
var lFingerId = model.GetModelId(HumanSlot.LFinger);
|
||||
|
||||
var wrapper = new Wrapper();
|
||||
// Check for all relevant primary IDs due to slot overlap.
|
||||
foreach (var (eqp, value) in cache.Eqp)
|
||||
{
|
||||
if (eqp.Slot.IsEquipment())
|
||||
{
|
||||
if (equipIdSet.Contains(eqp.SetId))
|
||||
wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot));
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (eqp.Slot)
|
||||
{
|
||||
case EquipSlot.Ears when eqp.SetId == earsId:
|
||||
case EquipSlot.Neck when eqp.SetId == neckId:
|
||||
case EquipSlot.Wrists when eqp.SetId == wristId:
|
||||
case EquipSlot.RFinger when eqp.SetId == rFingerId:
|
||||
case EquipSlot.LFinger when eqp.SetId == lFingerId:
|
||||
wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check also for body IDs due to body occupying head.
|
||||
foreach (var (gmp, value) in cache.Gmp)
|
||||
{
|
||||
if (gmp.SetId == headId || gmp.SetId == bodyId)
|
||||
wrapper.Gmp.Add(gmp, value.Entry);
|
||||
}
|
||||
|
||||
// Check for all races due to inheritance and all slots due to overlap.
|
||||
foreach (var (eqdp, value) in cache.Eqdp)
|
||||
{
|
||||
if (eqdp.Slot.IsEquipment())
|
||||
{
|
||||
if (equipIdSet.Contains(eqdp.SetId))
|
||||
wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot));
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (eqdp.Slot)
|
||||
{
|
||||
case EquipSlot.Ears when eqdp.SetId == earsId:
|
||||
case EquipSlot.Neck when eqdp.SetId == neckId:
|
||||
case EquipSlot.Wrists when eqdp.SetId == wristId:
|
||||
case EquipSlot.RFinger when eqdp.SetId == rFingerId:
|
||||
case EquipSlot.LFinger when eqdp.SetId == lFingerId:
|
||||
wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var genderRace = (GenderRace)model.AsHuman->RaceSexId;
|
||||
var hairId = model.GetModelId(HumanSlot.Hair);
|
||||
var faceId = model.GetModelId(HumanSlot.Face);
|
||||
// We do not need to care for racial inheritance for ESTs.
|
||||
foreach (var (est, value) in cache.Est)
|
||||
{
|
||||
switch (est.Slot)
|
||||
{
|
||||
case EstType.Hair when est.SetId == hairId && est.GenderRace == genderRace:
|
||||
case EstType.Face when est.SetId == faceId && est.GenderRace == genderRace:
|
||||
case EstType.Body when est.SetId == bodyId && est.GenderRace == genderRace:
|
||||
case EstType.Head when (est.SetId == headId || est.SetId == bodyId) && est.GenderRace == genderRace:
|
||||
wrapper.Est.Add(est, value.Entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (geqp, _) in cache.GlobalEqp)
|
||||
{
|
||||
switch (geqp.Type)
|
||||
{
|
||||
case GlobalEqpType.DoNotHideEarrings when geqp.Condition != earsId:
|
||||
case GlobalEqpType.DoNotHideNecklace when geqp.Condition != neckId:
|
||||
case GlobalEqpType.DoNotHideBracelets when geqp.Condition != wristId:
|
||||
case GlobalEqpType.DoNotHideRingR when geqp.Condition != rFingerId:
|
||||
case GlobalEqpType.DoNotHideRingL when geqp.Condition != lFingerId:
|
||||
continue;
|
||||
default: wrapper.Add(geqp); break;
|
||||
}
|
||||
}
|
||||
|
||||
var (_, _, main, off) = model.GetWeapons(actor);
|
||||
foreach (var (imc, value) in cache.Imc)
|
||||
{
|
||||
switch (imc.ObjectType)
|
||||
{
|
||||
case ObjectType.Equipment when equipIdSet.Contains(imc.PrimaryId): wrapper.Imc.Add(imc, value.Entry); break;
|
||||
|
||||
case ObjectType.Weapon:
|
||||
if (imc.PrimaryId == main.Skeleton && imc.SecondaryId == main.Weapon)
|
||||
wrapper.Imc.Add(imc, value.Entry);
|
||||
else if (imc.PrimaryId == off.Skeleton && imc.SecondaryId == off.Weapon)
|
||||
wrapper.Imc.Add(imc, value.Entry);
|
||||
break;
|
||||
case ObjectType.Accessory:
|
||||
switch (imc.EquipSlot)
|
||||
{
|
||||
case EquipSlot.Ears when imc.PrimaryId == earsId:
|
||||
case EquipSlot.Neck when imc.PrimaryId == neckId:
|
||||
case EquipSlot.Wrists when imc.PrimaryId == wristId:
|
||||
case EquipSlot.RFinger when imc.PrimaryId == rFingerId:
|
||||
case EquipSlot.LFinger when imc.PrimaryId == lFingerId:
|
||||
wrapper.Imc.Add(imc, value.Entry);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var subRace = (SubRace)model.AsHuman->Customize[4];
|
||||
foreach (var (rsp, value) in cache.Rsp)
|
||||
{
|
||||
if (rsp.SubRace == subRace)
|
||||
wrapper.Rsp.Add(rsp, value.Entry);
|
||||
}
|
||||
|
||||
// Keep all atch, atr and shp.
|
||||
wrapper.Atch.EnsureCapacity(cache.Atch.Count);
|
||||
wrapper.Shp.EnsureCapacity(cache.Shp.Count);
|
||||
wrapper.Atr.EnsureCapacity(cache.Atr.Count);
|
||||
foreach (var (atch, value) in cache.Atch)
|
||||
wrapper.Atch.Add(atch, value.Entry);
|
||||
foreach (var (shp, value) in cache.Shp)
|
||||
wrapper.Shp.Add(shp, value.Entry);
|
||||
foreach (var (atr, value) in cache.Atr)
|
||||
wrapper.Atr.Add(atr, value.Entry);
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
|
||||
private Wrapper? _data;
|
||||
|
|
@ -934,4 +1096,24 @@ public class MetaDictionary
|
|||
_data = new Wrapper(cache);
|
||||
Count = cache.Count;
|
||||
}
|
||||
|
||||
public MetaDictionary(MetaCache? cache, Actor actor)
|
||||
{
|
||||
if (cache is null)
|
||||
return;
|
||||
|
||||
_data = Wrapper.Filtered(cache, actor);
|
||||
Count = _data.Count
|
||||
+ _data.Eqp.Count
|
||||
+ _data.Eqdp.Count
|
||||
+ _data.Est.Count
|
||||
+ _data.Gmp.Count
|
||||
+ _data.Imc.Count
|
||||
+ _data.Rsp.Count
|
||||
+ _data.Atch.Count
|
||||
+ _data.Atr.Count
|
||||
+ _data.Shp.Count;
|
||||
if (Count is 0)
|
||||
_data = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
209
Penumbra/Services/FileWatcher.cs
Normal file
209
Penumbra/Services/FileWatcher.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
39
Penumbra/Services/InstallNotification.cs
Normal file
39
Penumbra/Services/InstallNotification.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -82,14 +82,14 @@ public class PcpService : IApiService, IDisposable
|
|||
public void CleanPcpCollections()
|
||||
{
|
||||
var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList();
|
||||
Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} mods containing the tag PCP.");
|
||||
Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} collections starting with PCP/.");
|
||||
foreach (var collection in collections)
|
||||
_collections.Storage.Delete(collection);
|
||||
_collections.Storage.RemoveCollection(collection);
|
||||
}
|
||||
|
||||
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory)
|
||||
{
|
||||
if (type is not ModPathChangeType.Added || _config.DisablePcpHandling || newDirectory is null)
|
||||
if (type is not ModPathChangeType.Added || _config.PcpSettings.DisableHandling || newDirectory is null)
|
||||
return;
|
||||
|
||||
try
|
||||
|
|
@ -99,9 +99,11 @@ public class PcpService : IApiService, IDisposable
|
|||
{
|
||||
// First version had collection.json, changed.
|
||||
var oldFile = Path.Combine(newDirectory.FullName, "collection.json");
|
||||
Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json.");
|
||||
if (File.Exists(oldFile))
|
||||
{
|
||||
Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json.");
|
||||
File.Move(oldFile, file, true);
|
||||
}
|
||||
else
|
||||
return;
|
||||
}
|
||||
|
|
@ -109,27 +111,35 @@ public class PcpService : IApiService, IDisposable
|
|||
Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying.");
|
||||
var text = File.ReadAllText(file);
|
||||
var jObj = JObject.Parse(text);
|
||||
var identifier = _actors.FromJson(jObj["Actor"] as JObject);
|
||||
if (!identifier.IsValid)
|
||||
return;
|
||||
var 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);
|
||||
|
||||
if (jObj["Collection"]?.ToObject<string>() is not { } collectionName)
|
||||
return;
|
||||
// Assign collection.
|
||||
if (_config.PcpSettings.AssignCollection)
|
||||
{
|
||||
var identifierGroup = _collections.Active.Individuals.GetGroup(identifier);
|
||||
_collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var name = $"PCP/{collectionName}";
|
||||
if (!_collections.Storage.AddCollection(name, null))
|
||||
return;
|
||||
|
||||
var collection = _collections.Storage[^1];
|
||||
_collections.Editor.SetModState(collection, mod, true);
|
||||
|
||||
var identifierGroup = _collections.Active.Individuals.GetGroup(identifier);
|
||||
_collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup);
|
||||
// Move to folder.
|
||||
if (_fileSystem.TryGetValue(mod, out var leaf))
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpFolderName);
|
||||
var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpSettings.FolderName);
|
||||
_fileSystem.Move(leaf, folder);
|
||||
}
|
||||
catch
|
||||
|
|
@ -138,7 +148,8 @@ public class PcpService : IApiService, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
if (_config.AllowPcpIpc)
|
||||
// Invoke IPC.
|
||||
if (_config.PcpSettings.AllowIpc)
|
||||
_communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -155,7 +166,7 @@ public class PcpService : IApiService, IDisposable
|
|||
try
|
||||
{
|
||||
Penumbra.Log.Information($"[PCPService] Creating PCP file for game object {objectIndex.Index}.");
|
||||
var (identifier, tree, collection) = await _framework.Framework.RunOnFrameworkThread(() =>
|
||||
var (identifier, tree, meta) = await _framework.Framework.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var (actor, identifier) = CheckActor(objectIndex);
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
|
|
@ -169,13 +180,14 @@ public class PcpService : IApiService, IDisposable
|
|||
if (_treeFactory.FromCharacter(actor, 0) is not { } tree)
|
||||
throw new Exception($"Unable to fetch modded resources for {identifier}.");
|
||||
|
||||
return (identifier.CreatePermanent(), tree, collection);
|
||||
var meta = new MetaDictionary(collection.ModCollection.MetaCache, actor.Address);
|
||||
return (identifier.CreatePermanent(), tree, meta);
|
||||
}
|
||||
});
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
var time = DateTime.Now;
|
||||
var modDirectory = CreateMod(identifier, note, time);
|
||||
await CreateDefaultMod(modDirectory, collection.ModCollection, tree, cancel);
|
||||
await CreateDefaultMod(modDirectory, meta, tree, cancel);
|
||||
await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel);
|
||||
var file = ZipUp(modDirectory);
|
||||
return (true, file);
|
||||
|
|
@ -208,8 +220,8 @@ public class PcpService : IApiService, IDisposable
|
|||
};
|
||||
if (note.Length > 0)
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
if (_config.AllowPcpIpc)
|
||||
await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index));
|
||||
if (_config.PcpSettings.AllowIpc)
|
||||
await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index, directory.FullName));
|
||||
var filePath = Path.Combine(directory.FullName, "character.json");
|
||||
await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew);
|
||||
await using var stream = new StreamWriter(file);
|
||||
|
|
@ -233,11 +245,15 @@ public class PcpService : IApiService, IDisposable
|
|||
?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}.");
|
||||
}
|
||||
|
||||
private async Task CreateDefaultMod(DirectoryInfo modDirectory, ModCollection collection, ResourceTree tree,
|
||||
private async Task CreateDefaultMod(DirectoryInfo modDirectory, MetaDictionary meta, ResourceTree tree,
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var subDirectory = modDirectory.CreateSubdirectory("files");
|
||||
var subMod = new DefaultSubMod(null!);
|
||||
var subMod = new DefaultSubMod(null!)
|
||||
{
|
||||
Manipulations = meta,
|
||||
};
|
||||
|
||||
foreach (var node in tree.FlatNodes)
|
||||
{
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
|
|
@ -260,7 +276,6 @@ public class PcpService : IApiService, IDisposable
|
|||
}
|
||||
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
subMod.Manipulations = new MetaDictionary(collection.MetaCache);
|
||||
|
||||
var saveGroup = new ModSaveGroup(modDirectory, subMod, _config.ReplaceNonAsciiOnImport);
|
||||
var filePath = _files.FileNames.OptionGroupFile(modDirectory.FullName, -1, string.Empty, _config.ReplaceNonAsciiOnImport);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
using Dalamud.Bindings.ImGui;
|
||||
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.String;
|
||||
using Penumbra.String.Classes;
|
||||
using Penumbra.UI.Classes;
|
||||
using static System.Net.Mime.MediaTypeNames;
|
||||
|
||||
namespace Penumbra.UI.AdvancedWindow;
|
||||
|
||||
|
|
@ -24,16 +29,20 @@ public class ResourceTreeViewer(
|
|||
IncognitoService incognito,
|
||||
int actionCapacity,
|
||||
Action onRefresh,
|
||||
Action<ResourceNode, Vector2> drawActions,
|
||||
Action<ResourceNode, IWritable?, Vector2> drawActions,
|
||||
CommunicatorService communicator,
|
||||
PcpService pcpService)
|
||||
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 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;
|
||||
|
|
@ -45,6 +54,7 @@ public class ResourceTreeViewer(
|
|||
|
||||
public void Draw()
|
||||
{
|
||||
DrawModifiedGameFilesWarning();
|
||||
DrawControls();
|
||||
_task ??= RefreshCharacterList();
|
||||
|
||||
|
|
@ -112,7 +122,7 @@ public class ResourceTreeViewer(
|
|||
ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8);
|
||||
|
||||
|
||||
using var table = ImRaii.Table("##ResourceTree", actionCapacity > 0 ? 4 : 3,
|
||||
using var table = ImRaii.Table("##ResourceTree", 4,
|
||||
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg);
|
||||
if (!table)
|
||||
continue;
|
||||
|
|
@ -120,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);
|
||||
|
|
@ -130,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;
|
||||
|
|
@ -190,6 +217,7 @@ public class ResourceTreeViewer(
|
|||
finally
|
||||
{
|
||||
_filterCache.Clear();
|
||||
_writableCache.Clear();
|
||||
_unfolded.Clear();
|
||||
onRefresh();
|
||||
}
|
||||
|
|
@ -200,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())
|
||||
{
|
||||
|
|
@ -270,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);
|
||||
|
|
@ -291,17 +318,29 @@ public class ResourceTreeViewer(
|
|||
using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value()))
|
||||
{
|
||||
ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap,
|
||||
new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
|
||||
new Vector2(ImGui.GetContentRegionAvail().X, frameHeight));
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
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));
|
||||
new Vector2(ImGui.GetContentRegionAvail().X, frameHeight));
|
||||
}
|
||||
|
||||
if (ImGui.IsItemClicked())
|
||||
|
|
@ -315,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);
|
||||
|
|
@ -381,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)
|
||||
|
|
@ -444,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -10,8 +13,12 @@ public class ResourceTreeViewerFactory(
|
|||
ChangedItemDrawer changedItemDrawer,
|
||||
IncognitoService incognito,
|
||||
CommunicatorService communicator,
|
||||
PcpService pcpService) : IService
|
||||
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, pcpService);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ using OtterGui.Text;
|
|||
using OtterGui.Widgets;
|
||||
using Penumbra.Api;
|
||||
using Penumbra.Collections;
|
||||
using Penumbra.Interop;
|
||||
using Penumbra.Interop.Hooks.PostProcessing;
|
||||
using Penumbra.Interop.Services;
|
||||
using Penumbra.Mods.Manager;
|
||||
|
|
@ -36,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;
|
||||
|
|
@ -59,9 +61,13 @@ public class SettingsTab : ITab, IUiService
|
|||
|
||||
private readonly TagButtons _sharedTags = new();
|
||||
|
||||
private string _lastCloudSyncTestedPath = string.Empty;
|
||||
private bool _lastCloudSyncTestResult = false;
|
||||
|
||||
public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial,
|
||||
Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector,
|
||||
CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, HttpApi httpApi,
|
||||
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,
|
||||
|
|
@ -78,6 +84,7 @@ public class SettingsTab : ITab, IUiService
|
|||
_characterUtility = characterUtility;
|
||||
_residentResources = residentResources;
|
||||
_modExportManager = modExportManager;
|
||||
_fileWatcher = fileWatcher;
|
||||
_httpApi = httpApi;
|
||||
_dalamudSubstitutionProvider = dalamudSubstitutionProvider;
|
||||
_compactor = compactor;
|
||||
|
|
@ -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);
|
||||
|
|
@ -602,7 +618,7 @@ public class SettingsTab : ITab, IUiService
|
|||
_config.AlwaysOpenDefaultImport, v => _config.AlwaysOpenDefaultImport = v);
|
||||
Checkbox("Handle PCP Files",
|
||||
"When encountering specific mods, usually but not necessarily denoted by a .pcp file ending, Penumbra will automatically try to create an associated collection and assign it to a specific character for this mod package. This can turn this behaviour off if unwanted.",
|
||||
!_config.DisablePcpHandling, v => _config.DisablePcpHandling = !v);
|
||||
!_config.PcpSettings.DisableHandling, v => _config.PcpSettings.DisableHandling = !v);
|
||||
|
||||
var active = _config.DeleteModModifier.IsActive();
|
||||
ImGui.SameLine();
|
||||
|
|
@ -612,19 +628,35 @@ public class SettingsTab : ITab, IUiService
|
|||
ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking.");
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8, disabled: !active))
|
||||
if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8,
|
||||
disabled: !active))
|
||||
_pcpService.CleanPcpCollections();
|
||||
if (!active)
|
||||
ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking.");
|
||||
|
||||
Checkbox("Allow Other Plugins Access to PCP Handling",
|
||||
"When creating or importing PCP files, other plugins can add and interpret their own data to the character.json file.",
|
||||
_config.AllowPcpIpc, v => _config.AllowPcpIpc = v);
|
||||
_config.PcpSettings.AllowIpc, v => _config.PcpSettings.AllowIpc = v);
|
||||
|
||||
Checkbox("Create PCP Collections",
|
||||
"When importing PCP files, create the associated collection.",
|
||||
_config.PcpSettings.CreateCollection, v => _config.PcpSettings.CreateCollection = v);
|
||||
|
||||
Checkbox("Assign PCP Collections",
|
||||
"When importing PCP files and creating the associated collection, assign it to the associated character.",
|
||||
_config.PcpSettings.AssignCollection, v => _config.PcpSettings.AssignCollection = v);
|
||||
DrawDefaultModImportPath();
|
||||
DrawDefaultModAuthor();
|
||||
DrawDefaultModImportFolder();
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -704,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()
|
||||
{
|
||||
|
|
@ -736,10 +808,10 @@ public class SettingsTab : ITab, IUiService
|
|||
/// <summary> Draw input for the default folder to sort put newly imported mods into. </summary>
|
||||
private void DrawPcpFolder()
|
||||
{
|
||||
var tmp = _config.PcpFolderName;
|
||||
var tmp = _config.PcpSettings.FolderName;
|
||||
ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X);
|
||||
if (ImUtf8.InputText("##pcpFolder"u8, ref tmp))
|
||||
_config.PcpFolderName = tmp;
|
||||
_config.PcpSettings.FolderName = tmp;
|
||||
|
||||
if (ImGui.IsItemDeactivatedAfterEdit())
|
||||
_config.Save();
|
||||
|
|
|
|||
12
repo.json
12
repo.json
|
|
@ -1,12 +1,12 @@
|
|||
[
|
||||
{
|
||||
"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.5.0.6",
|
||||
"TestingAssemblyVersion": "1.5.0.7",
|
||||
"AssemblyVersion": "1.5.1.8",
|
||||
"TestingAssemblyVersion": "1.5.1.8",
|
||||
"RepoUrl": "https://github.com/xivdev/Penumbra",
|
||||
"ApplicableVersion": "any",
|
||||
"DalamudApiLevel": 13,
|
||||
|
|
@ -18,9 +18,9 @@
|
|||
"LoadPriority": 69420,
|
||||
"LoadRequiredState": 2,
|
||||
"LoadSync": true,
|
||||
"DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.0.6/Penumbra.zip",
|
||||
"DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/testing_1.5.0.7/Penumbra.zip",
|
||||
"DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.0.6/Penumbra.zip",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue