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

View file

@ -1,39 +1,38 @@
using System.Collections.Frozen;
using Newtonsoft.Json;
using OtterGui.Services;
using Penumbra.Communication;
using Penumbra.Mods;
using Penumbra.Services;
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()
=> _config.ModDirectory;
=> config.ModDirectory;
public string GetConfiguration()
=> JsonConvert.SerializeObject(_config, Formatting.Indented);
=> JsonConvert.SerializeObject(config, Formatting.Indented);
public event Action<string, bool>? ModDirectoryChanged
{
add => _communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api);
remove => _communicator.ModDirectoryChanged.Unsubscribe(value!);
add => communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api);
remove => communicator.ModDirectoryChanged.Unsubscribe(value!);
}
public bool GetEnabledState()
=> _config.EnableMods;
=> config.EnableMods;
public event Action<bool>? EnabledChange
{
add => _communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api);
remove => _communicator.EnabledChanged.Unsubscribe(value!);
add => communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api);
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.GetEnabledState.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.RedrawAll.Provider(pi, api.Redraw),

View file

@ -5,6 +5,7 @@ using ImGuiNET;
using OtterGui;
using OtterGui.Raii;
using OtterGui.Services;
using OtterGui.Text;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
@ -26,6 +27,9 @@ public class PluginStateIpcTester : IUiService, IDisposable
private readonly List<DateTimeOffset> _initializedList = [];
private readonly List<DateTimeOffset> _disposedList = [];
private string _requiredFeatureString = string.Empty;
private string[] _requiredFeatures = [];
private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch;
private bool? _lastEnabledValue;
@ -48,12 +52,15 @@ public class PluginStateIpcTester : IUiService, IDisposable
EnabledChange.Dispose();
}
public void Draw()
{
using var _ = ImRaii.TreeNode("Plugin State");
if (!_)
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);
if (!table)
return;
@ -71,6 +78,12 @@ public class PluginStateIpcTester : IUiService, IDisposable
IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change");
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();
IpcTester.DrawIntro(GetConfiguration.Label, "Configuration");
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,
DefaultChangedItems = 0x2000,
PreferredChangedItems = 0x4000,
RequiredFeatures = 0x8000,
}
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);
}
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)
=> ChangeTag(mod, tagIdx, newTag, false);

View file

@ -143,6 +143,7 @@ public sealed class ModManager : ModStorage, IDisposable, IService
_communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath);
if (!Creator.ReloadMod(mod, true, false, out var metaChange))
{
if (mod.RequiredFeatures is not FeatureFlags.Invalid)
Penumbra.Log.Warning(mod.Name.Length == 0
? $"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.");
@ -251,12 +252,8 @@ public sealed class ModManager : ModStorage, IDisposable, IService
{
switch (type)
{
case ModPathChangeType.Added:
SetNew(mod);
break;
case ModPathChangeType.Deleted:
SetKnown(mod);
break;
case ModPathChangeType.Added: SetNew(mod); break;
case ModPathChangeType.Deleted: SetKnown(mod); break;
case ModPathChangeType.Moved:
if (oldDirectory != null && newDirectory != null)
DataEditor.MoveDataFile(oldDirectory, newDirectory);

View file

@ -1,4 +1,3 @@
using OtterGui;
using OtterGui.Classes;
using OtterGui.Extensions;
using Penumbra.GameData.Data;
@ -12,6 +11,16 @@ using Penumbra.String.Classes;
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 static readonly TemporaryMod ForcedFiles = new()
@ -57,6 +66,7 @@ public sealed class Mod : IMod
public string Image { get; internal set; } = string.Empty;
public IReadOnlyList<string> ModTags { get; internal set; } = [];
public HashSet<CustomItemId> DefaultPreferredItems { get; internal set; } = [];
public FeatureFlags RequiredFeatures { get; internal set; } = 0;
// Local Data
@ -70,6 +80,23 @@ public sealed class Mod : IMod
public readonly DefaultSubMod Default;
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)
{
if (settings is not { Enabled: true })

View file

@ -28,6 +28,7 @@ public partial class ModCreator(
MetaFileManager metaFileManager,
GamePathParser gamePathParser) : IService
{
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>
@ -74,7 +75,7 @@ public partial class ModCreator(
return false;
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;
modDataChange |= ModLocalData.Load(dataEditor, mod);

View file

@ -1,7 +1,6 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Penumbra.GameData.Structs;
using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager;
using Penumbra.Services;
@ -28,6 +27,19 @@ public readonly struct ModMeta(Mod mod) : ISavable
{ nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) },
{ 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);
jWriter.Formatting = Formatting.Indented;
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 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;
if (mod.Name != newName)
@ -111,6 +125,13 @@ public readonly struct ModMeta(Mod mod) : ISavable
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);
return changes;

View file

@ -64,6 +64,10 @@ public class ModPanelEditTab(
messager.NotificationMessage(e.Message, NotificationType.Warning, false);
}
UiHelpers.DefaultLineSpace();
FeatureChecker.DrawFeatureFlagInput(modManager.DataEditor, _mod, UiHelpers.InputTextWidth.X);
UiHelpers.DefaultLineSpace();
var sharedTagsEnabled = predefinedTagManager.Count > 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,
selector.Selected!);
UiHelpers.DefaultLineSpace();
addGroupDrawer.Draw(_mod, UiHelpers.InputTextWidth.X);
UiHelpers.DefaultLineSpace();

View file

@ -96,7 +96,7 @@ public class ShapeInspector(ObjectManager objects, CollectionResolver resolver)
if (set.All is { } 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);
}

View file

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