diff --git a/Glamourer/Automation/AutoDesignApplier.cs b/Glamourer/Automation/AutoDesignApplier.cs
index a61a004..d4d2f82 100644
--- a/Glamourer/Automation/AutoDesignApplier.cs
+++ b/Glamourer/Automation/AutoDesignApplier.cs
@@ -155,6 +155,49 @@ public sealed class AutoDesignApplier : IDisposable
foreach (var id in newSet.Identifiers)
{
+ // If the stored identifier uses a wildcard in the player name, it will not directly
+ // be present in the ActorObjectManager dictionaries. Scan the live objects and
+ // apply to any matching actors instead.
+ if (!id.PlayerName.IsEmpty && id.PlayerName.ToString().Contains('*'))
+ {
+ var pattern = id.PlayerName.ToString();
+ var regexPattern = System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*");
+ foreach (var (key, data2) in _objects)
+ {
+ if (key.Type != id.Type)
+ continue;
+
+ var worldMatches = key.Type switch
+ {
+ IdentifierType.Player => key.HomeWorld == id.HomeWorld || key.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld || id.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld,
+ IdentifierType.Owned => key.HomeWorld == id.HomeWorld || key.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld || id.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld,
+ _ => true,
+ };
+
+ if (!worldMatches)
+ continue;
+
+ if (!System.Text.RegularExpressions.Regex.IsMatch(key.PlayerName.ToString(), $"^{regexPattern}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
+ continue;
+
+ // Skip this actor if there's an exact-match automation set already enabled for it.
+ if (_manager.EnabledSets.ContainsKey(key))
+ continue;
+
+ // Apply to all actors represented by this key.
+ foreach (var actor in data2.Objects)
+ {
+ var specificId = actor.GetIdentifier(_actors);
+ if (_state.GetOrCreate(specificId, actor, out var state))
+ {
+ Reduce(actor, state, newSet, _config.RespectManualOnAutomationUpdate, false, true, out var forcedRedraw);
+ _state.ReapplyAutomationState(actor, forcedRedraw, false, StateSource.Fixed);
+ }
+ }
+ }
+
+ continue;
+ }
if (_objects.TryGetValue(id, out var data))
{
if (_state.GetOrCreate(id, data.Objects[0], out var state))
@@ -318,30 +361,81 @@ public sealed class AutoDesignApplier : IDisposable
switch (identifier.Type)
{
case IdentifierType.Player:
- if (_manager.EnabledSets.TryGetValue(identifier, out set))
+ if (TryGettingSetExactOrWildcard(identifier, out set))
return true;
identifier = _actors.CreatePlayer(identifier.PlayerName, WorldId.AnyWorld);
- return _manager.EnabledSets.TryGetValue(identifier, out set);
+ if (TryGettingSetExactOrWildcard(identifier, out set))
+ return true;
+
+ set = null;
+ return false;
case IdentifierType.Retainer:
case IdentifierType.Npc:
- return _manager.EnabledSets.TryGetValue(identifier, out set);
+ return TryGettingSetExactOrWildcard(identifier, out set);
case IdentifierType.Owned:
- if (_manager.EnabledSets.TryGetValue(identifier, out set))
+ if (TryGettingSetExactOrWildcard(identifier, out set))
return true;
identifier = _actors.CreateOwned(identifier.PlayerName, WorldId.AnyWorld, identifier.Kind, identifier.DataId);
- if (_manager.EnabledSets.TryGetValue(identifier, out set))
+ if (TryGettingSetExactOrWildcard(identifier, out set))
return true;
identifier = _actors.CreateNpc(identifier.Kind, identifier.DataId);
- return _manager.EnabledSets.TryGetValue(identifier, out set);
+ return TryGettingSetExactOrWildcard(identifier, out set);
default:
set = null;
return false;
}
}
+ /// Try to get a set matching exactly or via wildcard pattern.
+ private bool TryGettingSetExactOrWildcard(ActorIdentifier identifier, [NotNullWhen(true)] out AutoDesignSet? set)
+ {
+ // First try exact match
+ if (_manager.EnabledSets.TryGetValue(identifier, out set))
+ return true;
+
+ // Then try wildcard matches
+ foreach (var (key, value) in _manager.EnabledSets)
+ {
+ // Use wildcard-aware matching when the stored identifier contains a wildcard pattern in the name.
+ if (!key.PlayerName.IsEmpty && key.PlayerName.ToString().Contains('*'))
+ {
+ var sameType = identifier.Type == key.Type;
+ if (!sameType)
+ continue;
+
+ var worldMatches = identifier.Type switch
+ {
+ Penumbra.GameData.Enums.IdentifierType.Player => identifier.HomeWorld == key.HomeWorld || identifier.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld || key.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld,
+ Penumbra.GameData.Enums.IdentifierType.Owned => identifier.HomeWorld == key.HomeWorld || identifier.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld || key.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld,
+ _ => true,
+ };
+
+ if (!worldMatches)
+ continue;
+
+ var name = identifier.PlayerName.ToString();
+ var pattern = key.PlayerName.ToString();
+ var regexPattern = System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*");
+ if (System.Text.RegularExpressions.Regex.IsMatch(name, $"^{regexPattern}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
+ {
+ set = value;
+ return true;
+ }
+ }
+ else if (identifier.Matches(key))
+ {
+ set = value;
+ return true;
+ }
+ }
+
+ set = null;
+ return false;
+ }
+
internal static int NewGearsetId = -1;
private void OnEquippedGearset(string name, int id, int prior, byte _, byte job)
diff --git a/Glamourer/Automation/AutoDesignManager.cs b/Glamourer/Automation/AutoDesignManager.cs
index 7a4511b..ebf76bf 100644
--- a/Glamourer/Automation/AutoDesignManager.cs
+++ b/Glamourer/Automation/AutoDesignManager.cs
@@ -15,6 +15,7 @@ using OtterGui.Filesystem;
using Penumbra.GameData.Actors;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs;
+using Penumbra.String;
namespace Glamourer.Automation;
@@ -406,6 +407,60 @@ public class AutoDesignManager : ISavable, IReadOnlyList, IDispos
};
}
+ ///
+ /// Try to get an automation set that matches the given identifier, including wildcard patterns.
+ /// First tries exact match, then tries wildcard patterns from enabled sets.
+ ///
+ public bool TryGetSetWithWildcard(ActorIdentifier identifier, [NotNullWhen(true)] out AutoDesignSet? set)
+ {
+ // First try exact match
+ if (_enabled.TryGetValue(identifier, out set))
+ return true;
+
+ // Then try wildcard matching against all enabled sets
+ foreach (var (_, enabledSet) in _enabled)
+ {
+ foreach (var setId in enabledSet.Identifiers)
+ {
+ // Use wildcard-aware matching when the stored identifier contains a wildcard pattern in the name.
+ if (!setId.PlayerName.IsEmpty && setId.PlayerName.ToString().Contains('*'))
+ {
+ var sameType = identifier.Type == setId.Type;
+ if (!sameType)
+ continue;
+
+ var worldMatches = identifier.Type switch
+ {
+ Penumbra.GameData.Enums.IdentifierType.Player => identifier.HomeWorld == setId.HomeWorld || identifier.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld || setId.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld,
+ Penumbra.GameData.Enums.IdentifierType.Owned => identifier.HomeWorld == setId.HomeWorld || identifier.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld || setId.HomeWorld == Penumbra.GameData.Structs.WorldId.AnyWorld,
+ _ => true,
+ };
+
+ if (!worldMatches)
+ continue;
+
+ // Inline wildcard matching to avoid cross-namespace ambiguity.
+ var name = identifier.PlayerName.ToString();
+ var pattern = setId.PlayerName.ToString();
+ var regexPattern = System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*");
+ if (System.Text.RegularExpressions.Regex.IsMatch(name, $"^{regexPattern}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
+ {
+ set = enabledSet;
+ return true;
+ }
+ }
+ else if (identifier.Matches(setId))
+ {
+ set = enabledSet;
+ return true;
+ }
+ }
+ }
+
+ set = null;
+ return false;
+ }
+
private void Load()
{
var file = _saveService.FileNames.AutomationFile;
@@ -611,35 +666,46 @@ public class AutoDesignManager : ISavable, IReadOnlyList, IDispos
{
IdentifierType.Player =>
[
- identifier.CreatePermanent(),
+ (IsWildcardName(identifier.PlayerName)
+ ? _actors.CreatePlayerUnchecked(identifier.PlayerName, identifier.HomeWorld).CreatePermanent()
+ : identifier.CreatePermanent()),
],
IdentifierType.Retainer =>
[
- _actors.CreateRetainer(identifier.PlayerName,
- identifier.Retainer == ActorIdentifier.RetainerType.Mannequin
- ? ActorIdentifier.RetainerType.Mannequin
- : ActorIdentifier.RetainerType.Bell).CreatePermanent(),
+ (IsWildcardName(identifier.PlayerName)
+ ? _actors.CreateRetainerUnchecked(identifier.PlayerName,
+ identifier.Retainer == ActorIdentifier.RetainerType.Mannequin
+ ? ActorIdentifier.RetainerType.Mannequin
+ : ActorIdentifier.RetainerType.Bell)
+ : _actors.CreateRetainer(identifier.PlayerName,
+ identifier.Retainer == ActorIdentifier.RetainerType.Mannequin
+ ? ActorIdentifier.RetainerType.Mannequin
+ : ActorIdentifier.RetainerType.Bell)).CreatePermanent(),
],
IdentifierType.Npc => CreateNpcs(_actors, identifier),
IdentifierType.Owned => CreateNpcs(_actors, identifier),
_ => [],
};
- static ActorIdentifier[] CreateNpcs(ActorManager manager, ActorIdentifier identifier)
- {
- var name = manager.Data.ToName(identifier.Kind, identifier.DataId);
- var table = identifier.Kind switch
- {
- ObjectKind.BattleNpc => (IReadOnlyDictionary)manager.Data.BNpcs,
- ObjectKind.EventNpc => manager.Data.ENpcs,
- _ => new Dictionary(),
- };
- return table.Where(kvp => kvp.Value == name)
- .Select(kvp => manager.CreateIndividualUnchecked(identifier.Type, identifier.PlayerName, identifier.HomeWorld.Id,
- identifier.Kind, kvp.Key)).ToArray();
- }
+ static bool IsWildcardName(ByteString name)
+ => name.ToString().Contains('*');
}
+ static ActorIdentifier[] CreateNpcs(ActorManager manager, ActorIdentifier identifier)
+ {
+ var name = manager.Data.ToName(identifier.Kind, identifier.DataId);
+ var table = identifier.Kind switch
+ {
+ ObjectKind.BattleNpc => (IReadOnlyDictionary)manager.Data.BNpcs,
+ ObjectKind.EventNpc => manager.Data.ENpcs,
+ _ => new Dictionary(),
+ };
+ return table.Where(kvp => kvp.Value == name)
+ .Select(kvp => manager.CreateIndividualUnchecked(identifier.Type, identifier.PlayerName, identifier.HomeWorld.Id,
+ identifier.Kind, kvp.Key)).ToArray();
+ }
+
+
private void OnDesignChange(DesignChanged.Type type, Design design, ITransaction? _)
{
if (type is not DesignChanged.Type.Deleted)
diff --git a/Glamourer/Gui/Tabs/AutomationTab/IdentifierDrawer.cs b/Glamourer/Gui/Tabs/AutomationTab/IdentifierDrawer.cs
index ba2e424..2fbebe2 100644
--- a/Glamourer/Gui/Tabs/AutomationTab/IdentifierDrawer.cs
+++ b/Glamourer/Gui/Tabs/AutomationTab/IdentifierDrawer.cs
@@ -5,6 +5,7 @@ using Penumbra.GameData.DataContainers;
using Penumbra.GameData.Gui;
using Penumbra.GameData.Structs;
using Penumbra.String;
+using Penumbra.GameData.Enums;
namespace Glamourer.Gui.Tabs.AutomationTab;
@@ -64,20 +65,52 @@ public class IdentifierDrawer
public bool CanSetOwned
=> OwnedIdentifier.IsValid;
+ private static bool IsWildcardPattern(string? name)
+ => !string.IsNullOrEmpty(name) && name.Contains('*');
+
+ private static T Create(bool wildcard, Func uncheckedFactory, Func checkedFactory)
+ => wildcard ? uncheckedFactory() : checkedFactory();
+
private void UpdateIdentifiers()
{
- if (ByteString.FromString(_characterName, out var byteName))
+ var isWildcard = IsWildcardPattern(_characterName);
+ ByteString byteName = default;
+
+ // For wildcard patterns, use FromStringUnsafe to allow '*' characters
+ if (isWildcard)
+ byteName = ByteString.FromStringUnsafe(_characterName ?? string.Empty, false);
+ else if (!ByteString.FromString(_characterName, out byteName))
{
- PlayerIdentifier = _actors.CreatePlayer(byteName, _worldCombo.CurrentSelection.Key);
- RetainerIdentifier = _actors.CreateRetainer(byteName, ActorIdentifier.RetainerType.Bell);
- MannequinIdentifier = _actors.CreateRetainer(byteName, ActorIdentifier.RetainerType.Mannequin);
-
- if (_humanNpcCombo.CurrentSelection.Kind is ObjectKind.EventNpc or ObjectKind.BattleNpc)
- OwnedIdentifier = _actors.CreateOwned(byteName, _worldCombo.CurrentSelection.Key, _humanNpcCombo.CurrentSelection.Kind, _humanNpcCombo.CurrentSelection.Ids[0]);
- else
- OwnedIdentifier = ActorIdentifier.Invalid;
+ PlayerIdentifier = ActorIdentifier.Invalid;
+ RetainerIdentifier = ActorIdentifier.Invalid;
+ MannequinIdentifier = ActorIdentifier.Invalid;
+ OwnedIdentifier = ActorIdentifier.Invalid;
+ NpcIdentifier = ActorIdentifier.Invalid;
+ return;
}
+ // Create identifiers using a single helper to handle wildcard vs checked creation
+ PlayerIdentifier = Create(isWildcard,
+ () => _actors.CreatePlayerUnchecked(byteName, _worldCombo.CurrentSelection.Key),
+ () => _actors.CreatePlayer(byteName, _worldCombo.CurrentSelection.Key));
+
+ RetainerIdentifier = Create(isWildcard,
+ () => _actors.CreateRetainerUnchecked(byteName, ActorIdentifier.RetainerType.Bell),
+ () => _actors.CreateRetainer(byteName, ActorIdentifier.RetainerType.Bell));
+
+ MannequinIdentifier = Create(isWildcard,
+ () => _actors.CreateRetainerUnchecked(byteName, ActorIdentifier.RetainerType.Mannequin),
+ () => _actors.CreateRetainer(byteName, ActorIdentifier.RetainerType.Mannequin));
+
+ if (_humanNpcCombo.CurrentSelection.Kind is ObjectKind.EventNpc or ObjectKind.BattleNpc)
+ OwnedIdentifier = Create(isWildcard,
+ () => _actors.CreateIndividualUnchecked(IdentifierType.Owned, byteName, _worldCombo.CurrentSelection.Key.Id,
+ _humanNpcCombo.CurrentSelection.Kind, _humanNpcCombo.CurrentSelection.Ids[0]),
+ () => _actors.CreateOwned(byteName, _worldCombo.CurrentSelection.Key, _humanNpcCombo.CurrentSelection.Kind,
+ _humanNpcCombo.CurrentSelection.Ids[0]));
+ else
+ OwnedIdentifier = ActorIdentifier.Invalid;
+
NpcIdentifier = _humanNpcCombo.CurrentSelection.Kind is ObjectKind.EventNpc or ObjectKind.BattleNpc
? _actors.CreateNpc(_humanNpcCombo.CurrentSelection.Kind, _humanNpcCombo.CurrentSelection.Ids[0])
: ActorIdentifier.Invalid;