Compare commits

..

No commits in common. "master" and "testing_1.5.0.10" have entirely different histories.

39 changed files with 160 additions and 702 deletions

@ -1 +1 @@
Subproject commit a63f6735cf4bed4f7502a022a10378607082b770 Subproject commit 4a9b71a93e76aa5eed818542288329e34ec0dd89

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

View file

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

@ -1 +1 @@
Subproject commit d889f9ef918514a46049725052d378b441915b00 Subproject commit 73010350338ecd7b98ad85d127bed08d7d8718d4

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

View file

@ -17,7 +17,7 @@ public class PenumbraApi(
UiApi ui) : IDisposable, IApiService, IPenumbraApi UiApi ui) : IDisposable, IApiService, IPenumbraApi
{ {
public const int BreakingVersion = 5; public const int BreakingVersion = 5;
public const int FeatureVersion = 13; public const int FeatureVersion = 11;
public void Dispose() public void Dispose()
{ {

View file

@ -2,14 +2,11 @@ using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Collections;
using Penumbra.Collections.Manager;
using Penumbra.GameData.Interop;
using Penumbra.Interop.Services; using Penumbra.Interop.Services;
namespace Penumbra.Api.Api; namespace Penumbra.Api.Api;
public class RedrawApi(RedrawService redrawService, IFramework framework, CollectionManager collections, ObjectManager objects, ApiHelpers helpers) : IPenumbraApiRedraw, IApiService public class RedrawApi(RedrawService redrawService, IFramework framework) : IPenumbraApiRedraw, IApiService
{ {
public void RedrawObject(int gameObjectIndex, RedrawType setting) public void RedrawObject(int gameObjectIndex, RedrawType setting)
{ {
@ -31,27 +28,9 @@ public class RedrawApi(RedrawService redrawService, IFramework framework, Collec
framework.RunOnFrameworkThread(() => redrawService.RedrawAll(setting)); 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 public event GameObjectRedrawnDelegate? GameObjectRedrawn
{ {
add => redrawService.GameObjectRedrawn += value; add => redrawService.GameObjectRedrawn += value;
remove => redrawService.GameObjectRedrawn -= value; remove => redrawService.GameObjectRedrawn -= value;
} }
} }

View file

@ -5,7 +5,6 @@ using EmbedIO.WebApi;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Api; using Penumbra.Api.Api;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Mods.Settings;
namespace Penumbra.Api; namespace Penumbra.Api;
@ -14,15 +13,13 @@ public class HttpApi : IDisposable, IApiService
private partial class Controller : WebApiController private partial class Controller : WebApiController
{ {
// @formatter:off // @formatter:off
[Route( HttpVerbs.Get, "/moddirectory" )] public partial string GetModDirectory(); [Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods();
[Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods(); [Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw();
[Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw(); [Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll();
[Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll(); [Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod();
[Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod(); [Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod();
[Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod(); [Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow();
[Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow(); [Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod();
[Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod();
[Route( HttpVerbs.Post, "/setmodsettings")] public partial Task SetModSettings();
// @formatter:on // @formatter:on
} }
@ -68,12 +65,6 @@ public class HttpApi : IDisposable, IApiService
private partial class Controller(IPenumbraApi api, IFramework framework) 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() public partial object? GetMods()
{ {
Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered."); Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered.");
@ -125,7 +116,6 @@ public class HttpApi : IDisposable, IApiService
Penumbra.Log.Debug($"[HTTP] {nameof(OpenWindow)} triggered."); Penumbra.Log.Debug($"[HTTP] {nameof(OpenWindow)} triggered.");
api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty); api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty);
} }
public async partial Task FocusMod() public async partial Task FocusMod()
{ {
var data = await HttpContext.GetRequestDataAsync<ModFocusData>().ConfigureAwait(false); var data = await HttpContext.GetRequestDataAsync<ModFocusData>().ConfigureAwait(false);
@ -134,30 +124,6 @@ public class HttpApi : IDisposable, IApiService
api.Ui.OpenMainWindow(TabType.Mods, data.Path, data.Name); 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) private record ModReloadData(string Path, string Name)
{ {
public ModReloadData() public ModReloadData()
@ -185,19 +151,5 @@ public class HttpApi : IDisposable, IApiService
: this(string.Empty, RedrawType.Redraw, -1) : 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

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

View file

@ -121,10 +121,6 @@ public class CollectionsIpcTester(IDalamudPluginInterface pi) : IUiService
}).ToArray(); }).ToArray();
ImGui.OpenPopup("Changed Item List"); 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() private void DrawChangedItemPopup()

View file

@ -53,7 +53,6 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public string ModDirectory { get; set; } = string.Empty; public string ModDirectory { get; set; } = string.Empty;
public string ExportDirectory { 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? UseCrashHandler { get; set; } = null;
public bool OpenWindowAtStart { get; set; } = false; public bool OpenWindowAtStart { get; set; } = false;
@ -77,8 +76,6 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public bool HideRedrawBar { get; set; } = false; public bool HideRedrawBar { get; set; } = false;
public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool HideMachinistOffhandFromChangedItems { get; set; } = true;
public bool DefaultTemporaryMode { get; set; } = false; 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 EnableCustomShapes { get; set; } = true;
public PcpSettings PcpSettings = new(); public PcpSettings PcpSettings = new();
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;

View file

@ -1,47 +0,0 @@
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

@ -63,7 +63,8 @@ public sealed unsafe class LoadTimelineResources : FastHook<LoadTimelineResource
{ {
if (timeline != null) if (timeline != null)
{ {
var idx = timeline->GetOwningGameObjectIndex(); // TODO: Clientstructify
var idx = ((delegate* unmanaged<SchedulerTimeline*, int>**)timeline)[0][29](timeline);
if (idx >= 0 && idx < objects.TotalCount) if (idx >= 0 && idx < objects.TotalCount)
{ {
var obj = objects[idx]; var obj = objects[idx];

View file

@ -137,7 +137,7 @@ public sealed unsafe class CollectionResolver(
{ {
var item = charaEntry.Value; var item = charaEntry.Value;
var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId); var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId);
Penumbra.Log.Excessive( Penumbra.Log.Verbose(
$"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")}."); $"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) if (identifier.IsValid && CollectionByIdentifier(identifier) is { } coll)
{ {

View file

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

View file

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

View file

@ -242,10 +242,10 @@ public class ResourceTree(
} }
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, CharacterBase* model, string prefix = "") 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); => AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, *(void**)((nint)model + 0x160), prefix);
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics, private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics,
BoneKineDriverModule* kineDriver, string prefix = "") void* kineDriver, string prefix = "")
{ {
var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid); var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid);
if (eidNode != null) if (eidNode != null)
@ -261,7 +261,8 @@ public class ResourceTree(
for (var i = 0; i < skeleton->PartialSkeletonCount; ++i) for (var i = 0; i < skeleton->PartialSkeletonCount; ++i)
{ {
var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null; var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null;
var kdbHandle = kineDriver != null ? kineDriver->PartialSkeletonEntries[i].KineDriverResourceHandle : null; // TODO ClientStructs-ify (aers/FFXIVClientStructs#1562)
var kdbHandle = kineDriver != null ? *(ResourceHandle**)((nint)kineDriver + 0x20 + 0x18 * i) : null;
if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode) if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode)
{ {
if (context.Global.WithUiData) if (context.Global.WithUiData)

View file

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

View file

@ -66,8 +66,11 @@ internal static class StructExtensions
public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
{ {
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1561)
var vf80 = (delegate* unmanaged<CharacterBase*, byte*, nuint, uint, byte*>)((nint*)character.VirtualTable)[80];
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveKdbPath(pathBuffer, CharacterBase.PathBufferSize, partialSkeletonIndex)); return ToOwnedByteString(vf80((CharacterBase*)Unsafe.AsPointer(ref character), pathBuffer, CharacterBase.PathBufferSize,
partialSkeletonIndex));
} }
private static unsafe CiByteString ToOwnedByteString(CStringPointer str) private static unsafe CiByteString ToOwnedByteString(CStringPointer str)

View file

@ -372,6 +372,7 @@ public class ModMerger : IDisposable, IService
} }
else else
{ {
// TODO DataContainer <> Option.
var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name); var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name);
var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName()); var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName());
var folder = Path.Combine(dir.FullName, group!.Name, option!.Name); var folder = Path.Combine(dir.FullName, group!.Name, option!.Name);

View file

@ -1,6 +1,5 @@
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Communication; using Penumbra.Communication;
using Penumbra.Interop;
using Penumbra.Mods.Editor; using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Mods.Manager.OptionEditor;
using Penumbra.Services; using Penumbra.Services;
@ -304,9 +303,6 @@ public sealed class ModManager : ModStorage, IDisposable, IService
if (!firstTime && _config.ModDirectory != BasePath.FullName) if (!firstTime && _config.ModDirectory != BasePath.FullName)
TriggerModDirectoryChange(BasePath.FullName, Valid); 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) private void TriggerModDirectoryChange(string newPath, bool valid)

View file

@ -21,8 +21,8 @@ using Penumbra.UI;
using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager; using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using Penumbra.GameData;
using Penumbra.GameData.Data; using Penumbra.GameData.Data;
using Penumbra.Interop;
using Penumbra.Interop.Hooks; using Penumbra.Interop.Hooks;
using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Interop.Hooks.ResourceLoading; using Penumbra.Interop.Hooks.ResourceLoading;
@ -211,11 +211,10 @@ public class Penumbra : IDalamudPlugin
public string GatherSupportInformation() public string GatherSupportInformation()
{ {
var sb = new StringBuilder(10240); var sb = new StringBuilder(10240);
var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory);
var cloudSynced = exists && CloudApi.IsCloudSynced(_config.ModDirectory); var hdrEnabler = _services.GetService<RenderTargetHdrEnabler>();
var hdrEnabler = _services.GetService<RenderTargetHdrEnabler>(); var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null;
var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null;
sb.AppendLine("**Settings**"); sb.AppendLine("**Settings**");
sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n"); sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n");
sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n"); sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n");
@ -224,8 +223,7 @@ public class Penumbra : IDalamudPlugin
sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n"); sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n");
if (Dalamud.Utility.Util.IsWine()) if (Dalamud.Utility.Util.IsWine())
sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n"); sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n");
sb.Append( sb.Append($"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}\n");
$"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}{(cloudSynced ? ", Cloud-Synced" : "")}\n");
sb.Append( sb.Append(
$"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n"); $"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n");
sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n"); sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n");

View file

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

View file

@ -1,5 +1,5 @@
{ {
"Author": "Ottermandias, Nylfae, Adam, Wintermute", "Author": "Ottermandias, Adam, Wintermute",
"Name": "Penumbra", "Name": "Penumbra",
"Punchline": "Runtime mod loader and manager.", "Punchline": "Runtime mod loader and manager.",
"Description": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.",

View file

@ -1,209 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -82,9 +82,9 @@ public class PcpService : IApiService, IDisposable
public void CleanPcpCollections() public void CleanPcpCollections()
{ {
var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList(); var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList();
Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} collections starting with PCP/."); Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} mods containing the tag PCP.");
foreach (var collection in collections) foreach (var collection in collections)
_collections.Storage.RemoveCollection(collection); _collections.Storage.Delete(collection);
} }
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory)

View file

@ -216,7 +216,7 @@ public sealed partial class MtrlTab : IWritable, IDisposable
} }
public bool Valid public bool Valid
=> Mtrl.Valid; // FIXME This should be _shadersKnown && Mtrl.Valid but the algorithm for _shadersKnown is flawed as of 7.2. => _shadersKnown && Mtrl.Valid;
public byte[] Write() public byte[] Write()
{ {

View file

@ -287,17 +287,6 @@ public partial class ModEditWindow
using var font = ImRaii.PushFont(UiBuilder.IconFont); using var font = ImRaii.PushFont(UiBuilder.IconFont);
ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); 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) private void PrintNewGamePath(int i, FileRegistry registry, IModDataContainer subMod)
@ -330,17 +319,6 @@ public partial class ModEditWindow
using var font = ImRaii.PushFont(UiBuilder.IconFont); using var font = ImRaii.PushFont(UiBuilder.IconFont);
ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); 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() private void DrawButtonHeader()

View file

@ -17,6 +17,7 @@ public partial class ModEditWindow
private readonly FileDialogService _fileDialog; private readonly FileDialogService _fileDialog;
private readonly ResourceTreeFactory _resourceTreeFactory; private readonly ResourceTreeFactory _resourceTreeFactory;
private readonly ResourceTreeViewer _quickImportViewer; private readonly ResourceTreeViewer _quickImportViewer;
private readonly Dictionary<FullPath, IWritable?> _quickImportWritables = new();
private readonly Dictionary<(Utf8GamePath, IWritable?), QuickImportAction> _quickImportActions = new(); private readonly Dictionary<(Utf8GamePath, IWritable?), QuickImportAction> _quickImportActions = new();
private HashSet<string> GetPlayerResourcesOfType(ResourceType type) private HashSet<string> GetPlayerResourcesOfType(ResourceType type)
@ -55,11 +56,52 @@ public partial class ModEditWindow
private void OnQuickImportRefresh() private void OnQuickImportRefresh()
{ {
_quickImportWritables.Clear();
_quickImportActions.Clear(); _quickImportActions.Clear();
} }
private void DrawQuickImportActions(ResourceNode resourceNode, IWritable? writable, Vector2 buttonSize) private void DrawQuickImportActions(ResourceNode resourceNode, 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(); ImGui.SameLine();
if (!_quickImportActions!.TryGetValue((resourceNode.GamePath, writable), out var quickImport)) if (!_quickImportActions!.TryGetValue((resourceNode.GamePath, writable), out var quickImport))
{ {
@ -79,6 +121,24 @@ 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 class QuickImportAction
{ {
public const string FallbackOptionName = "the current option"; 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); _center = new CombinedTexture(_left, _right);
_textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex)); _textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex));
_resourceTreeFactory = resourceTreeFactory; _resourceTreeFactory = resourceTreeFactory;
_quickImportViewer = resourceTreeViewerFactory.Create(1, OnQuickImportRefresh, DrawQuickImportActions); _quickImportViewer = resourceTreeViewerFactory.Create(2, OnQuickImportRefresh, DrawQuickImportActions);
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow);
IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true }; IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true };
if (IsOpen && selection.Mod != null) if (IsOpen && selection.Mod != null)

View file

@ -4,20 +4,16 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Lumina.Data;
using OtterGui; using OtterGui;
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Compression;
using OtterGui.Extensions; using OtterGui.Extensions;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Text; using OtterGui.Text;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.GameData.Files;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
using Penumbra.Interop.ResourceTree; using Penumbra.Interop.ResourceTree;
using Penumbra.Services; using Penumbra.Services;
using Penumbra.String; using Penumbra.String;
using Penumbra.String.Classes;
using Penumbra.UI.Classes; using Penumbra.UI.Classes;
namespace Penumbra.UI.AdvancedWindow; namespace Penumbra.UI.AdvancedWindow;
@ -29,20 +25,17 @@ public class ResourceTreeViewer(
IncognitoService incognito, IncognitoService incognito,
int actionCapacity, int actionCapacity,
Action onRefresh, Action onRefresh,
Action<ResourceNode, IWritable?, Vector2> drawActions, Action<ResourceNode, Vector2> drawActions,
CommunicatorService communicator, CommunicatorService communicator,
PcpService pcpService, PcpService pcpService,
IDataManager gameData, IDataManager gameData)
FileDialogService fileDialog,
FileCompactor compactor)
{ {
private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags = private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags =
ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership;
private readonly HashSet<nint> _unfolded = []; private readonly HashSet<nint> _unfolded = [];
private readonly Dictionary<nint, NodeVisibility> _filterCache = []; private readonly Dictionary<nint, NodeVisibility> _filterCache = [];
private readonly Dictionary<FullPath, IWritable?> _writableCache = [];
private TreeCategory _categoryFilter = AllCategories; private TreeCategory _categoryFilter = AllCategories;
private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags; private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags;
@ -122,7 +115,7 @@ public class ResourceTreeViewer(
ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8); ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8);
using var table = ImRaii.Table("##ResourceTree", 4, using var table = ImRaii.Table("##ResourceTree", actionCapacity > 0 ? 4 : 3,
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg);
if (!table) if (!table)
continue; continue;
@ -130,8 +123,9 @@ public class ResourceTreeViewer(
ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthStretch, 0.2f); ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthStretch, 0.2f);
ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthStretch, 0.3f); ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthStretch, 0.3f);
ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f); ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f);
ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, if (actionCapacity > 0)
actionCapacity * 3 * ImGuiHelpers.GlobalScale + (actionCapacity + 1) * ImGui.GetFrameHeight()); ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed,
(actionCapacity - 1) * 3 * ImGuiHelpers.GlobalScale + actionCapacity * ImGui.GetFrameHeight());
ImGui.TableHeadersRow(); ImGui.TableHeadersRow();
DrawNodes(tree.Nodes, 0, unchecked(tree.DrawObjectAddress * 31), 0); DrawNodes(tree.Nodes, 0, unchecked(tree.DrawObjectAddress * 31), 0);
@ -217,7 +211,6 @@ public class ResourceTreeViewer(
finally finally
{ {
_filterCache.Clear(); _filterCache.Clear();
_writableCache.Clear();
_unfolded.Clear(); _unfolded.Clear();
onRefresh(); onRefresh();
} }
@ -228,6 +221,7 @@ public class ResourceTreeViewer(
{ {
var debugMode = config.DebugMode; var debugMode = config.DebugMode;
var frameHeight = ImGui.GetFrameHeight(); var frameHeight = ImGui.GetFrameHeight();
var cellHeight = actionCapacity > 0 ? frameHeight : 0.0f;
foreach (var (resourceNode, index) in resourceNodes.WithIndex()) foreach (var (resourceNode, index) in resourceNodes.WithIndex())
{ {
@ -297,7 +291,7 @@ public class ResourceTreeViewer(
0 => "(none)", 0 => "(none)",
1 => resourceNode.GamePath.ToString(), 1 => resourceNode.GamePath.ToString(),
_ => "(multiple)", _ => "(multiple)",
}, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); }, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
if (hasGamePaths) if (hasGamePaths)
{ {
var allPaths = string.Join('\n', resourceNode.PossibleGamePaths); var allPaths = string.Join('\n', resourceNode.PossibleGamePaths);
@ -318,29 +312,17 @@ public class ResourceTreeViewer(
using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value())) using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value()))
{ {
ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap, ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap,
new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
} }
ImGui.SameLine(); ImGui.SameLine();
ImGui.SetCursorPosX(textPos); ImGui.SetCursorPosX(textPos);
ImUtf8.Text(resourceNode.ModRelativePath); 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 else
{ {
ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap,
new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
} }
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
@ -354,17 +336,20 @@ public class ResourceTreeViewer(
else else
{ {
ImUtf8.Selectable(GetPathStatusLabel(resourceNode.FullPathStatus), false, ImGuiSelectableFlags.Disabled, ImUtf8.Selectable(GetPathStatusLabel(resourceNode.FullPathStatus), false, ImGuiSelectableFlags.Disabled,
new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
ImGuiUtil.HoverTooltip( ImGuiUtil.HoverTooltip(
$"{GetPathStatusDescription(resourceNode.FullPathStatus)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); $"{GetPathStatusDescription(resourceNode.FullPathStatus)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}");
} }
mutedColor.Dispose(); mutedColor.Dispose();
ImGui.TableNextColumn(); if (actionCapacity > 0)
using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, {
ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale }); ImGui.TableNextColumn();
DrawActions(resourceNode, new Vector2(frameHeight)); using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing,
ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale });
drawActions(resourceNode, new Vector2(frameHeight));
}
if (unfolded) if (unfolded)
DrawNodes(resourceNode.Children, level + 1, unchecked(nodePathHash * 31), filterIcon); DrawNodes(resourceNode.Children, level + 1, unchecked(nodePathHash * 31), filterIcon);
@ -417,51 +402,6 @@ public class ResourceTreeViewer(
|| node.FullPath.InternalName.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase) || node.FullPath.InternalName.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase)
|| Array.Exists(node.PossibleGamePaths, path => path.Path.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) private static ReadOnlySpan<byte> GetPathStatusLabel(ResourceNode.PathStatus status)
@ -525,22 +465,4 @@ public class ResourceTreeViewer(
Visible = 1, Visible = 1,
DescendentsOnly = 2, 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,7 +1,5 @@
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using OtterGui.Compression;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.GameData.Files;
using Penumbra.Interop.ResourceTree; using Penumbra.Interop.ResourceTree;
using Penumbra.Services; using Penumbra.Services;
@ -14,11 +12,8 @@ public class ResourceTreeViewerFactory(
IncognitoService incognito, IncognitoService incognito,
CommunicatorService communicator, CommunicatorService communicator,
PcpService pcpService, PcpService pcpService,
IDataManager gameData, IDataManager gameData) : IService
FileDialogService fileDialog,
FileCompactor compactor) : IService
{ {
public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action<ResourceNode, IWritable?, Vector2> drawActions) public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action<ResourceNode, Vector2> drawActions)
=> new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData, => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData);
fileDialog, compactor);
} }

View file

@ -63,27 +63,9 @@ public class PenumbraChangelog : IUiService
Add1_3_6_4(Changelog); Add1_3_6_4(Changelog);
Add1_4_0_0(Changelog); Add1_4_0_0(Changelog);
Add1_5_0_0(Changelog); Add1_5_0_0(Changelog);
Add1_5_1_0(Changelog); }
}
#region Changelogs
private static void Add1_5_1_0(Changelog log) #region Changelogs
=> 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) private static void Add1_5_0_0(Changelog log)
=> log.NextVersion("Version 1.5.0.0") => log.NextVersion("Version 1.5.0.0")

View file

@ -11,7 +11,6 @@ using OtterGui;
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Extensions; using OtterGui.Extensions;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Text;
using Penumbra.Collections; using Penumbra.Collections;
using Penumbra.Collections.Manager; using Penumbra.Collections.Manager;
using Penumbra.GameData.Actors; using Penumbra.GameData.Actors;
@ -223,31 +222,26 @@ public sealed class CollectionPanel(
ImGui.EndGroup(); ImGui.EndGroup();
ImGui.SameLine(); ImGui.SameLine();
ImGui.BeginGroup(); ImGui.BeginGroup();
var width = ImGui.GetContentRegionAvail().X; using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f));
using (ImRaii.Disabled(_collections.DefaultNamed == collection)) 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)
{ {
using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); collection.Identity.Name = _newName;
var name = _newName ?? collection.Identity.Name; saveService.QueueSave(new ModCollectionSave(mods, collection));
ImGui.SetNextItemWidth(width); selector.RestoreCollections();
if (ImGui.InputText("##name", ref name, 128)) _newName = null;
_newName = name; }
if (ImGui.IsItemDeactivatedAfterEdit() && _newName != null && _newName != collection.Identity.Name) else if (ImGui.IsItemDeactivated())
{ {
collection.Identity.Name = _newName; _newName = null;
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)) using (ImRaii.PushFont(UiBuilder.MonoFont))
{ {
if (ImGui.Button(collection.Identity.Identifier, new Vector2(width, 0))) if (ImGui.Button(collection.Identity.Identifier, new Vector2(width, 0)))
@ -381,7 +375,9 @@ public sealed class CollectionPanel(
ImGuiUtil.TextWrapped(type.ToDescription()); ImGuiUtil.TextWrapped(type.ToDescription());
switch (type) 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: case CollectionType.Yourself:
ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0));
break; break;

View file

@ -116,8 +116,7 @@ public sealed class CollectionSelector : ItemSelector<ModCollection>, IDisposabl
public void RestoreCollections() public void RestoreCollections()
{ {
Items.Clear(); Items.Clear();
Items.Add(_storage.DefaultNamed); foreach (var c in _storage.OrderBy(c => c.Identity.Name))
foreach (var c in _storage.OrderBy(c => c.Identity.Name).Where(c => c != _storage.DefaultNamed))
Items.Add(c); Items.Add(c);
SetFilterDirty(); SetFilterDirty();
SetCurrent(_active.Current); SetCurrent(_active.Current);

View file

@ -9,7 +9,6 @@ using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.System.Resource; using FFXIVClientStructs.FFXIV.Client.System.Resource;
using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Colors;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using OtterGui; using OtterGui;
using OtterGui.Classes; using OtterGui.Classes;
@ -42,7 +41,6 @@ using Penumbra.GameData.Data;
using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Interop.Hooks.ResourceLoading; using Penumbra.Interop.Hooks.ResourceLoading;
using Penumbra.GameData.Files.StainMapStructs; using Penumbra.GameData.Files.StainMapStructs;
using Penumbra.Interop;
using Penumbra.String.Classes; using Penumbra.String.Classes;
using Penumbra.UI.AdvancedWindow.Materials; using Penumbra.UI.AdvancedWindow.Materials;
@ -208,7 +206,6 @@ public class DebugTab : Window, ITab, IUiService
_hookOverrides.Draw(); _hookOverrides.Draw();
DrawPlayerModelInfo(); DrawPlayerModelInfo();
_globalVariablesDrawer.Draw(); _globalVariablesDrawer.Draw();
DrawCloudApi();
DrawDebugTabIpc(); DrawDebugTabIpc();
} }
@ -1202,42 +1199,6 @@ 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> /// <summary> Draw information about IPC options and availability. </summary>
private void DrawDebugTabIpc() private void DrawDebugTabIpc()
{ {

View file

@ -14,7 +14,6 @@ using OtterGui.Text;
using OtterGui.Widgets; using OtterGui.Widgets;
using Penumbra.Api; using Penumbra.Api;
using Penumbra.Collections; using Penumbra.Collections;
using Penumbra.Interop;
using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Interop.Services; using Penumbra.Interop.Services;
using Penumbra.Mods.Manager; using Penumbra.Mods.Manager;
@ -37,7 +36,6 @@ public class SettingsTab : ITab, IUiService
private readonly Penumbra _penumbra; private readonly Penumbra _penumbra;
private readonly FileDialogService _fileDialog; private readonly FileDialogService _fileDialog;
private readonly ModManager _modManager; private readonly ModManager _modManager;
private readonly FileWatcher _fileWatcher;
private readonly ModExportManager _modExportManager; private readonly ModExportManager _modExportManager;
private readonly ModFileSystemSelector _selector; private readonly ModFileSystemSelector _selector;
private readonly CharacterUtility _characterUtility; private readonly CharacterUtility _characterUtility;
@ -61,13 +59,9 @@ public class SettingsTab : ITab, IUiService
private readonly TagButtons _sharedTags = new(); private readonly TagButtons _sharedTags = new();
private string _lastCloudSyncTestedPath = string.Empty;
private bool _lastCloudSyncTestResult = false;
public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial,
Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector,
CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, HttpApi httpApi,
FileWatcher fileWatcher, HttpApi httpApi,
DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig,
IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService,
MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService,
@ -84,7 +78,6 @@ public class SettingsTab : ITab, IUiService
_characterUtility = characterUtility; _characterUtility = characterUtility;
_residentResources = residentResources; _residentResources = residentResources;
_modExportManager = modExportManager; _modExportManager = modExportManager;
_fileWatcher = fileWatcher;
_httpApi = httpApi; _httpApi = httpApi;
_dalamudSubstitutionProvider = dalamudSubstitutionProvider; _dalamudSubstitutionProvider = dalamudSubstitutionProvider;
_compactor = compactor; _compactor = compactor;
@ -215,15 +208,6 @@ public class SettingsTab : ITab, IUiService
if (IsSubPathOf(gameDir, newName)) if (IsSubPathOf(gameDir, newName))
return ("Path is not allowed to be inside your game folder.", false); 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 return selected
? ($"Press Enter or Click Here to Save (Current Directory: {old})", true) ? ($"Press Enter or Click Here to Save (Current Directory: {old})", true)
: ($"Click Here to Save (Current Directory: {old})", true); : ($"Click Here to Save (Current Directory: {old})", true);
@ -650,13 +634,6 @@ public class SettingsTab : ITab, IUiService
DrawDefaultModImportFolder(); DrawDefaultModImportFolder();
DrawPcpFolder(); DrawPcpFolder();
DrawDefaultModExportPath(); 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();
} }
@ -736,46 +713,6 @@ public class SettingsTab : ITab, IUiService
+ "Keep this empty to use the root directory."); + "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> /// <summary> Draw input for the default name to input as author into newly generated mods. </summary>
private void DrawDefaultModAuthor() private void DrawDefaultModAuthor()
{ {

View file

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