Add checking for supported features with the currently new supported features 'Atch', 'Shp' and 'Atr'.

This commit is contained in:
Ottermandias 2025-06-03 18:39:46 +02:00
parent 98203e4e8a
commit 318a41fe52
14 changed files with 216 additions and 37 deletions

@ -1 +1 @@
Subproject commit 1465203967d08519c6716292bc5e5094c7fbcacc Subproject commit ff7b3b4014a97455f823380c78b8a7c5107f8e2f

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 = 9; public const int FeatureVersion = 10;
public void Dispose() public void Dispose()
{ {

View file

@ -1,39 +1,38 @@
using System.Collections.Frozen;
using Newtonsoft.Json; using Newtonsoft.Json;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Communication; using Penumbra.Communication;
using Penumbra.Mods;
using Penumbra.Services; using Penumbra.Services;
namespace Penumbra.Api.Api; namespace Penumbra.Api.Api;
public class PluginStateApi : IPenumbraApiPluginState, IApiService public class PluginStateApi(Configuration config, CommunicatorService communicator) : IPenumbraApiPluginState, IApiService
{ {
private readonly Configuration _config;
private readonly CommunicatorService _communicator;
public PluginStateApi(Configuration config, CommunicatorService communicator)
{
_config = config;
_communicator = communicator;
}
public string GetModDirectory() public string GetModDirectory()
=> _config.ModDirectory; => config.ModDirectory;
public string GetConfiguration() public string GetConfiguration()
=> JsonConvert.SerializeObject(_config, Formatting.Indented); => JsonConvert.SerializeObject(config, Formatting.Indented);
public event Action<string, bool>? ModDirectoryChanged public event Action<string, bool>? ModDirectoryChanged
{ {
add => _communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api); add => communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api);
remove => _communicator.ModDirectoryChanged.Unsubscribe(value!); remove => communicator.ModDirectoryChanged.Unsubscribe(value!);
} }
public bool GetEnabledState() public bool GetEnabledState()
=> _config.EnableMods; => config.EnableMods;
public event Action<bool>? EnabledChange public event Action<bool>? EnabledChange
{ {
add => _communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api); add => communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api);
remove => _communicator.EnabledChanged.Unsubscribe(value!); remove => communicator.EnabledChanged.Unsubscribe(value!);
} }
public FrozenSet<string> SupportedFeatures
=> FeatureChecker.SupportedFeatures.ToFrozenSet();
public string[] CheckSupportedFeatures(IEnumerable<string> requiredFeatures)
=> requiredFeatures.Where(f => !FeatureChecker.Supported(f)).ToArray();
} }

View file

@ -80,6 +80,8 @@ public sealed class IpcProviders : IDisposable, IApiService
IpcSubscribers.ModDirectoryChanged.Provider(pi, api.PluginState), IpcSubscribers.ModDirectoryChanged.Provider(pi, api.PluginState),
IpcSubscribers.GetEnabledState.Provider(pi, api.PluginState), IpcSubscribers.GetEnabledState.Provider(pi, api.PluginState),
IpcSubscribers.EnabledChange.Provider(pi, api.PluginState), IpcSubscribers.EnabledChange.Provider(pi, api.PluginState),
IpcSubscribers.SupportedFeatures.Provider(pi, api.PluginState),
IpcSubscribers.CheckSupportedFeatures.Provider(pi, api.PluginState),
IpcSubscribers.RedrawObject.Provider(pi, api.Redraw), IpcSubscribers.RedrawObject.Provider(pi, api.Redraw),
IpcSubscribers.RedrawAll.Provider(pi, api.Redraw), IpcSubscribers.RedrawAll.Provider(pi, api.Redraw),

View file

@ -5,6 +5,7 @@ using ImGuiNET;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using OtterGui.Text;
using Penumbra.Api.Helpers; using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers; using Penumbra.Api.IpcSubscribers;
@ -12,7 +13,7 @@ namespace Penumbra.Api.IpcTester;
public class PluginStateIpcTester : IUiService, IDisposable public class PluginStateIpcTester : IUiService, IDisposable
{ {
private readonly IDalamudPluginInterface _pi; private readonly IDalamudPluginInterface _pi;
public readonly EventSubscriber<string, bool> ModDirectoryChanged; public readonly EventSubscriber<string, bool> ModDirectoryChanged;
public readonly EventSubscriber Initialized; public readonly EventSubscriber Initialized;
public readonly EventSubscriber Disposed; public readonly EventSubscriber Disposed;
@ -26,6 +27,9 @@ public class PluginStateIpcTester : IUiService, IDisposable
private readonly List<DateTimeOffset> _initializedList = []; private readonly List<DateTimeOffset> _initializedList = [];
private readonly List<DateTimeOffset> _disposedList = []; private readonly List<DateTimeOffset> _disposedList = [];
private string _requiredFeatureString = string.Empty;
private string[] _requiredFeatures = [];
private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch; private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch;
private bool? _lastEnabledValue; private bool? _lastEnabledValue;
@ -48,12 +52,15 @@ public class PluginStateIpcTester : IUiService, IDisposable
EnabledChange.Dispose(); EnabledChange.Dispose();
} }
public void Draw() public void Draw()
{ {
using var _ = ImRaii.TreeNode("Plugin State"); using var _ = ImRaii.TreeNode("Plugin State");
if (!_) if (!_)
return; return;
if (ImUtf8.InputText("Required Features"u8, ref _requiredFeatureString))
_requiredFeatures = _requiredFeatureString.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit);
if (!table) if (!table)
return; return;
@ -71,6 +78,12 @@ public class PluginStateIpcTester : IUiService, IDisposable
IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change"); IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change");
ImGui.TextUnformatted(_lastEnabledValue is { } v ? $"{_lastEnabledChange} (to {v})" : "Never"); ImGui.TextUnformatted(_lastEnabledValue is { } v ? $"{_lastEnabledChange} (to {v})" : "Never");
IpcTester.DrawIntro(SupportedFeatures.Label, "Supported Features");
ImUtf8.Text(string.Join(", ", new SupportedFeatures(_pi).Invoke()));
IpcTester.DrawIntro(CheckSupportedFeatures.Label, "Missing Features");
ImUtf8.Text(string.Join(", ", new CheckSupportedFeatures(_pi).Invoke(_requiredFeatures)));
DrawConfigPopup(); DrawConfigPopup();
IpcTester.DrawIntro(GetConfiguration.Label, "Configuration"); IpcTester.DrawIntro(GetConfiguration.Label, "Configuration");
if (ImGui.Button("Get")) if (ImGui.Button("Get"))

View file

@ -0,0 +1,95 @@
using System.Collections.Frozen;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility.Raii;
using ImGuiNET;
using OtterGui.Text;
using Penumbra.Mods.Manager;
using Penumbra.UI.Classes;
using Notification = OtterGui.Classes.Notification;
namespace Penumbra.Mods;
public static class FeatureChecker
{
/// <summary> Manually setup supported features to exclude None and Invalid and not make something supported too early. </summary>
private static readonly FrozenDictionary<string, FeatureFlags> SupportedFlags = new[]
{
FeatureFlags.Atch,
FeatureFlags.Shp,
FeatureFlags.Atr,
}.ToFrozenDictionary(f => f.ToString(), f => f);
public static IReadOnlyCollection<string> SupportedFeatures
=> SupportedFlags.Keys;
public static FeatureFlags ParseFlags(string modDirectory, string modName, IEnumerable<string> features)
{
var featureFlags = FeatureFlags.None;
HashSet<string> missingFeatures = [];
foreach (var feature in features)
{
if (SupportedFlags.TryGetValue(feature, out var featureFlag))
featureFlags |= featureFlag;
else
missingFeatures.Add(feature);
}
if (missingFeatures.Count > 0)
{
Penumbra.Messager.AddMessage(new Notification(
$"Please update Penumbra to use the mod {modName}{(modDirectory != modName ? $" at {modDirectory}" : string.Empty)}!\n\nLoading failed because it requires the unsupported feature{(missingFeatures.Count > 1 ? $"s\n\n\t[{string.Join("], [", missingFeatures)}]." : $" [{missingFeatures.First()}].")}",
NotificationType.Warning));
return FeatureFlags.Invalid;
}
return featureFlags;
}
public static bool Supported(string features)
=> SupportedFlags.ContainsKey(features);
public static void DrawFeatureFlagInput(ModDataEditor editor, Mod mod, float width)
{
const int numButtons = 5;
var innerSpacing = ImGui.GetStyle().ItemInnerSpacing;
var size = new Vector2((width - (numButtons - 1) * innerSpacing.X) / numButtons, 0);
var buttonColor = ImGui.GetColorU32(ImGuiCol.FrameBg);
var textColor = ImGui.GetColorU32(ImGuiCol.TextDisabled);
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, innerSpacing)
.Push(ImGuiStyleVar.FrameBorderSize, 0);
using (var color = ImRaii.PushColor(ImGuiCol.Border, ColorId.FolderLine.Value())
.Push(ImGuiCol.Button, buttonColor)
.Push(ImGuiCol.Text, textColor))
{
foreach (var flag in SupportedFlags.Values)
{
if (mod.RequiredFeatures.HasFlag(flag))
{
style.Push(ImGuiStyleVar.FrameBorderSize, ImUtf8.GlobalScale);
color.Pop(2);
if (ImUtf8.Button($"{flag}", size))
editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures & ~flag);
color.Push(ImGuiCol.Button, buttonColor)
.Push(ImGuiCol.Text, textColor);
style.Pop();
}
else if (ImUtf8.Button($"{flag}", size))
{
editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures | flag);
}
ImGui.SameLine();
}
}
if (ImUtf8.ButtonEx("Compute"u8, "Compute the required features automatically from the used features."u8, size))
editor.ChangeRequiredFeatures(mod, mod.ComputeRequiredFeatures());
ImGui.SameLine();
if (ImUtf8.ButtonEx("Clear"u8, "Clear all required features."u8, size))
editor.ChangeRequiredFeatures(mod, FeatureFlags.None);
ImGui.SameLine();
ImUtf8.Text("Required Features"u8);
}
}

View file

@ -26,6 +26,7 @@ public enum ModDataChangeType : ushort
Image = 0x1000, Image = 0x1000,
DefaultChangedItems = 0x2000, DefaultChangedItems = 0x2000,
PreferredChangedItems = 0x4000, PreferredChangedItems = 0x4000,
RequiredFeatures = 0x8000,
} }
public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData) : IService public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData) : IService
@ -97,6 +98,16 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic
communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null); communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null);
} }
public void ChangeRequiredFeatures(Mod mod, FeatureFlags flags)
{
if (mod.RequiredFeatures == flags)
return;
mod.RequiredFeatures = flags;
saveService.QueueSave(new ModMeta(mod));
communicatorService.ModDataChanged.Invoke(ModDataChangeType.RequiredFeatures, mod, null);
}
public void ChangeModTag(Mod mod, int tagIdx, string newTag) public void ChangeModTag(Mod mod, int tagIdx, string newTag)
=> ChangeTag(mod, tagIdx, newTag, false); => ChangeTag(mod, tagIdx, newTag, false);

View file

@ -143,9 +143,10 @@ public sealed class ModManager : ModStorage, IDisposable, IService
_communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath); _communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath);
if (!Creator.ReloadMod(mod, true, false, out var metaChange)) if (!Creator.ReloadMod(mod, true, false, out var metaChange))
{ {
Penumbra.Log.Warning(mod.Name.Length == 0 if (mod.RequiredFeatures is not FeatureFlags.Invalid)
? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead." Penumbra.Log.Warning(mod.Name.Length == 0
: $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead."); ? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead."
: $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead.");
RemoveMod(mod); RemoveMod(mod);
return; return;
} }
@ -251,12 +252,8 @@ public sealed class ModManager : ModStorage, IDisposable, IService
{ {
switch (type) switch (type)
{ {
case ModPathChangeType.Added: case ModPathChangeType.Added: SetNew(mod); break;
SetNew(mod); case ModPathChangeType.Deleted: SetKnown(mod); break;
break;
case ModPathChangeType.Deleted:
SetKnown(mod);
break;
case ModPathChangeType.Moved: case ModPathChangeType.Moved:
if (oldDirectory != null && newDirectory != null) if (oldDirectory != null && newDirectory != null)
DataEditor.MoveDataFile(oldDirectory, newDirectory); DataEditor.MoveDataFile(oldDirectory, newDirectory);

View file

@ -1,4 +1,3 @@
using OtterGui;
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Extensions; using OtterGui.Extensions;
using Penumbra.GameData.Data; using Penumbra.GameData.Data;
@ -12,6 +11,16 @@ using Penumbra.String.Classes;
namespace Penumbra.Mods; namespace Penumbra.Mods;
[Flags]
public enum FeatureFlags : ulong
{
None = 0,
Atch = 1ul << 0,
Shp = 1ul << 1,
Atr = 1ul << 2,
Invalid = 1ul << 62,
}
public sealed class Mod : IMod public sealed class Mod : IMod
{ {
public static readonly TemporaryMod ForcedFiles = new() public static readonly TemporaryMod ForcedFiles = new()
@ -57,6 +66,7 @@ public sealed class Mod : IMod
public string Image { get; internal set; } = string.Empty; public string Image { get; internal set; } = string.Empty;
public IReadOnlyList<string> ModTags { get; internal set; } = []; public IReadOnlyList<string> ModTags { get; internal set; } = [];
public HashSet<CustomItemId> DefaultPreferredItems { get; internal set; } = []; public HashSet<CustomItemId> DefaultPreferredItems { get; internal set; } = [];
public FeatureFlags RequiredFeatures { get; internal set; } = 0;
// Local Data // Local Data
@ -70,6 +80,23 @@ public sealed class Mod : IMod
public readonly DefaultSubMod Default; public readonly DefaultSubMod Default;
public readonly List<IModGroup> Groups = []; public readonly List<IModGroup> Groups = [];
/// <summary> Compute the required feature flags for this mod. </summary>
public FeatureFlags ComputeRequiredFeatures()
{
var flags = FeatureFlags.None;
foreach (var option in AllDataContainers)
{
if (option.Manipulations.Atch.Count > 0)
flags |= FeatureFlags.Atch;
if (option.Manipulations.Atr.Count > 0)
flags |= FeatureFlags.Atr;
if (option.Manipulations.Shp.Count > 0)
flags |= FeatureFlags.Shp;
}
return flags;
}
public AppliedModData GetData(ModSettings? settings = null) public AppliedModData GetData(ModSettings? settings = null)
{ {
if (settings is not { Enabled: true }) if (settings is not { Enabled: true })

View file

@ -28,7 +28,8 @@ public partial class ModCreator(
MetaFileManager metaFileManager, MetaFileManager metaFileManager,
GamePathParser gamePathParser) : IService GamePathParser gamePathParser) : IService
{ {
public readonly Configuration Config = config; public const FeatureFlags SupportedFeatures = FeatureFlags.Atch | FeatureFlags.Shp | FeatureFlags.Atr;
public readonly Configuration Config = config;
/// <summary> Creates directory and files necessary for a new mod without adding it to the manager. </summary> /// <summary> Creates directory and files necessary for a new mod without adding it to the manager. </summary>
public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null) public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null)
@ -74,7 +75,7 @@ public partial class ModCreator(
return false; return false;
modDataChange = ModMeta.Load(dataEditor, this, mod); modDataChange = ModMeta.Load(dataEditor, this, mod);
if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0) if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0 || mod.RequiredFeatures is FeatureFlags.Invalid)
return false; return false;
modDataChange |= ModLocalData.Load(dataEditor, mod); modDataChange |= ModLocalData.Load(dataEditor, mod);

View file

@ -1,7 +1,6 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager; using Penumbra.Mods.Manager;
using Penumbra.Services; using Penumbra.Services;
@ -28,6 +27,19 @@ public readonly struct ModMeta(Mod mod) : ISavable
{ nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) }, { nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) },
{ nameof(Mod.DefaultPreferredItems), JToken.FromObject(mod.DefaultPreferredItems) }, { nameof(Mod.DefaultPreferredItems), JToken.FromObject(mod.DefaultPreferredItems) },
}; };
if (mod.RequiredFeatures is not FeatureFlags.None)
{
var features = mod.RequiredFeatures;
var array = new JArray();
foreach (var flag in Enum.GetValues<FeatureFlags>())
{
if ((features & flag) is not FeatureFlags.None)
array.Add(flag.ToString());
}
jObject[nameof(Mod.RequiredFeatures)] = array;
}
using var jWriter = new JsonTextWriter(writer); using var jWriter = new JsonTextWriter(writer);
jWriter.Formatting = Formatting.Indented; jWriter.Formatting = Formatting.Indented;
jObject.WriteTo(jWriter); jObject.WriteTo(jWriter);
@ -60,6 +72,8 @@ public readonly struct ModMeta(Mod mod) : ISavable
var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values<string>().OfType<string>(); var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values<string>().OfType<string>();
var defaultItems = (json[nameof(Mod.DefaultPreferredItems)] as JArray)?.Values<ulong>().Select(i => (CustomItemId)i).ToHashSet() var defaultItems = (json[nameof(Mod.DefaultPreferredItems)] as JArray)?.Values<ulong>().Select(i => (CustomItemId)i).ToHashSet()
?? []; ?? [];
var requiredFeatureArray = (json[nameof(Mod.RequiredFeatures)] as JArray)?.Values<string>() ?? [];
var requiredFeatures = FeatureChecker.ParseFlags(mod.ModPath.Name, newName.Length > 0 ? newName : mod.Name.Length > 0 ? mod.Name : "Unknown", requiredFeatureArray!);
ModDataChangeType changes = 0; ModDataChangeType changes = 0;
if (mod.Name != newName) if (mod.Name != newName)
@ -111,6 +125,13 @@ public readonly struct ModMeta(Mod mod) : ISavable
editor.SaveService.ImmediateSave(new ModMeta(mod)); editor.SaveService.ImmediateSave(new ModMeta(mod));
} }
// Required features get checked during parsing, in which case the new required features signal invalid.
if (requiredFeatures != mod.RequiredFeatures)
{
changes |= ModDataChangeType.RequiredFeatures;
mod.RequiredFeatures = requiredFeatures;
}
changes |= ModLocalData.UpdateTags(mod, modTags, null); changes |= ModLocalData.UpdateTags(mod, modTags, null);
return changes; return changes;

View file

@ -64,6 +64,10 @@ public class ModPanelEditTab(
messager.NotificationMessage(e.Message, NotificationType.Warning, false); messager.NotificationMessage(e.Message, NotificationType.Warning, false);
} }
UiHelpers.DefaultLineSpace();
FeatureChecker.DrawFeatureFlagInput(modManager.DataEditor, _mod, UiHelpers.InputTextWidth.X);
UiHelpers.DefaultLineSpace(); UiHelpers.DefaultLineSpace();
var sharedTagsEnabled = predefinedTagManager.Count > 0; var sharedTagsEnabled = predefinedTagManager.Count > 0;
var sharedTagButtonOffset = sharedTagsEnabled ? ImGui.GetFrameHeight() + ImGui.GetStyle().FramePadding.X : 0; var sharedTagButtonOffset = sharedTagsEnabled ? ImGui.GetFrameHeight() + ImGui.GetStyle().FramePadding.X : 0;
@ -76,6 +80,7 @@ public class ModPanelEditTab(
predefinedTagManager.DrawAddFromSharedTagsAndUpdateTags(selector.Selected!.LocalTags, selector.Selected!.ModTags, false, predefinedTagManager.DrawAddFromSharedTagsAndUpdateTags(selector.Selected!.LocalTags, selector.Selected!.ModTags, false,
selector.Selected!); selector.Selected!);
UiHelpers.DefaultLineSpace(); UiHelpers.DefaultLineSpace();
addGroupDrawer.Draw(_mod, UiHelpers.InputTextWidth.X); addGroupDrawer.Draw(_mod, UiHelpers.InputTextWidth.X);
UiHelpers.DefaultLineSpace(); UiHelpers.DefaultLineSpace();

View file

@ -96,7 +96,7 @@ public class ShapeInspector(ObjectManager objects, CollectionResolver resolver)
if (set.All is { } value) if (set.All is { } value)
{ {
using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value); using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value);
ImUtf8.Text("All"u8); ImUtf8.Text("All, "u8);
ImGui.SameLine(0, 0); ImGui.SameLine(0, 0);
} }

View file

@ -52,6 +52,14 @@
"type": "integer" "type": "integer"
}, },
"uniqueItems": true "uniqueItems": true
},
"RequiredFeatures": {
"description": "A list of required features by name.",
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
} }
}, },
"required": [ "required": [