Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.3.0.0 - 2026-06-30
### Added
- Your own variables: create variables in the new Variables window (open it with `/silkstring variables`), give each one a value, and use it in any alias with `{name}`, just like the built-in ones
- Change a variable from inside an alias with `:set`, for example `:set mode raid`. The new value is saved automatically and kept between sessions
- The alias editor warns you when a `:set` points at a variable you have not created yet

## v1.2.0.0 - 2026-06-29
### Added
- Conditionals: run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks. Everything in a block runs only when its condition holds, with an optional `:else` for the false case, and blocks can be nested
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A Dalamud plugin for FFXIV that lets you define custom command aliases. Each ali
- Run multiple lines per alias, in order, with a configurable delay between them
- Mix game commands and chat messages in a single alias
- Insert live game state with variables such as `{character}` and `{job}`
- Define your own variables and change them on the fly from inside an alias
- Give an alias several triggers using a `|` separator
- Organize aliases into collapsible folders with drag and drop
- Set a friendly display name for an alias, separate from its trigger
Expand Down Expand Up @@ -134,6 +135,29 @@ A condition compares values with `==`, `!=`, `<`, `>`, `<=`, `>=`, and you can c

Text comparisons are case-insensitive, and numbers compare as numbers. The `:if`, `:else`, and `:endif` lines are never sent to chat, and the alias editor warns you if a block is left open or a condition cannot be understood.

### User Variables

Alongside the built-in variables, you can define your own. Open the Variables window with `/silkstring variables`, type a name, and give it a value. Names can use letters, numbers, and underscores, and cannot reuse a built-in variable name.

Your variables work just like the built-in ones: insert them anywhere with `{name}`, and they appear in the Variables tab of the help window.

You can change a variable from inside an alias with `:set`:

```
:set mode raid
:set greeting hi {0}
```

The value is resolved when the line runs, so it can include other variables and parameters, and the result is saved automatically and kept between sessions. A `:set` only affects a variable you have already created. If it names one that does not exist, nothing happens and the alias editor warns you.

Because conditions treat `true` and `false` the same way as the built-in switches, a variable makes a handy on/off flag. Toggle it in one alias with `:set burst true` (or `false`), and check it in another:

```
:if {burst}
/say bursting
:endif
```

### Folders

Create a folder with the **New Folder** button, then drag aliases into or out of it. Folders can be collapsed, renamed, and deleted from their right-click menu.
Expand Down
1 change: 1 addition & 0 deletions Silkstring/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public int CommandDelay
public int Version { get; set; } = CurrentVersion;
public List<AliasFolder> Folders = new();
public List<AliasEntry> Aliases = new();
public List<UserVariable> UserVariables = new();
public bool MultilineCommands { get; set; }

public void Save()
Expand Down
26 changes: 26 additions & 0 deletions Silkstring/Models/UserVariable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Text.Json.Serialization;
using System.Threading;

namespace Silkstring.Models;

public class UserVariable
{
private static int _nextId = 0;

public string Name = string.Empty;
public string Value = string.Empty;

[NonSerialized]
[JsonIgnore]
public bool Delete;

[NonSerialized]
[JsonIgnore]
public int UniqueId;

public UserVariable()
{
UniqueId = Interlocked.Increment(ref _nextId);
}
}
28 changes: 20 additions & 8 deletions Silkstring/Plugin.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using Dalamud.Game.Command;
using Dalamud.Interface.ImGuiNotification;
Expand Down Expand Up @@ -33,6 +34,7 @@ public sealed class Plugin : IDalamudPlugin
public readonly WindowSystem WindowSystem = new("Silkstring");
private ConfigWindow ConfigWindow { get; init; }
private MainWindow MainWindow { get; init; }
private VariablesWindow VariablesWindow { get; init; }
private HelpWindow HelpWindow { get; init; }
private ChangelogWindow ChangelogWindow { get; init; }

Expand Down Expand Up @@ -60,24 +62,30 @@ public Plugin()

ECommonsMain.Init(PluginInterface, this);

IVariableProvider[] providers =
[
new PlayerVariableProvider(PlayerState),
new VitalsVariablesProvider(ClientState),
new CombatVariablesProvider(Condition, TargetManager),
new CurrencyVariablesProvider()
];
IVariableProvider[] builtIn =
[
new PlayerVariableProvider(PlayerState),
new VitalsVariablesProvider(ClientState),
new CombatVariablesProvider(Condition, TargetManager),
new CurrencyVariablesProvider(),
];

var reserved = builtIn.SelectMany(p => p.GetVariables()).Select(v => v.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var store = new UserVariableStore(Configuration.UserVariables, reserved, Configuration.MarkDirty);
IVariableProvider[] providers = [..builtIn, new UserVariableProvider(() => store.Variables)];

_commandResolver = new CommandResolver(providers);
_commandHandler = new CommandHandler(_commandResolver, Framework);
_commandHandler = new CommandHandler(_commandResolver, Framework, store.TrySet);
_chatInterceptor = new ChatInterceptor(GameInteropProvider, Framework, Configuration, _commandHandler);

ConfigWindow = new ConfigWindow(this);
MainWindow = new MainWindow(this, ToggleConfigUi, ToggleHelpUi, ToggleChangelogUi);
VariablesWindow = new VariablesWindow(store, _commandResolver);
HelpWindow = new HelpWindow(_commandResolver);
ChangelogWindow = new ChangelogWindow();

WindowSystem.AddWindow(MainWindow);
WindowSystem.AddWindow(VariablesWindow);
WindowSystem.AddWindow(ConfigWindow);
WindowSystem.AddWindow(HelpWindow);
WindowSystem.AddWindow(ChangelogWindow);
Expand All @@ -86,6 +94,7 @@ public Plugin()
{
HelpMessage = "/silkstring → Open the Silkstring alias manager.\n" +
"/silkstring help → Open the Silkstring help window.\n" +
"/silkstring variables → Open the Silkstring variables window.\n" +
"/silkstring changelog → Open the Silkstring changelog window."
});

Expand Down Expand Up @@ -116,6 +125,7 @@ public void Dispose()
WindowSystem.RemoveAllWindows();

MainWindow.Dispose();
VariablesWindow.Dispose();
ConfigWindow.Dispose();
HelpWindow.Dispose();
ChangelogWindow.Dispose();
Expand All @@ -129,6 +139,7 @@ private void OnCommand(string command, string args)
{
case "help": ToggleHelpUi(); break;
case "changelog": ToggleChangelogUi(); break;
case "variables": ToggleVariablesUi(); break;
default: MainWindow.Toggle(); break;
}
}
Expand All @@ -140,6 +151,7 @@ private void OnFrameworkUpdate(IFramework framework)

public void ToggleConfigUi() => ConfigWindow.Toggle();
public void ToggleMainUi() => MainWindow.Toggle();
public void ToggleVariablesUi() => VariablesWindow.Toggle();
public void ToggleHelpUi() => HelpWindow.Toggle();
public void ToggleChangelogUi() => ChangelogWindow.Toggle();
}
13 changes: 13 additions & 0 deletions Silkstring/Services/AliasValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry>
return elseSeen.Count > 0 ? "Unclosed :if (missing :endif)" : null;
}

public static string? ValidateSets(AliasEntry alias, ISet<string> defined)
{
foreach (var command in alias.Output)
{
var (kind, expression) = BlockInterpreter.Classify(command.Command);
if (kind != BlockKind.Set) continue;
var (name, _) = BlockInterpreter.ParseSet(expression);
if (string.IsNullOrEmpty(name)) return ":set needs a variable name";
if (!defined.Contains(name)) return $"Unknown variable in :set: {name}";
}
return null;
}

private static Dictionary<string, AliasEntry> BuildTriggerLookup(IEnumerable<AliasEntry> allAliases)
{
var lookup = new Dictionary<string, AliasEntry>(StringComparer.OrdinalIgnoreCase);
Expand Down
19 changes: 17 additions & 2 deletions Silkstring/Services/CommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ public class CommandHandler
private readonly IFramework _framework;
private readonly CommandResolver _resolver;
private readonly ConditionEvaluator _conditions;

public CommandHandler(CommandResolver resolver, IFramework framework)
private readonly Func<string, string, bool> _setUserVariable;
public CommandHandler(CommandResolver resolver, IFramework framework, Func<string, string, bool> setUserVariable)
{
_resolver = resolver;
_framework = framework;
_conditions = new ConditionEvaluator(_resolver.Resolve);
_setUserVariable = setUserVariable;
}

public async Task ExecuteAsync(IReadOnlyList<string> commands, IReadOnlyList<string> args, int delayMs = 100, CancellationToken cancellationToken = default, Func<string, bool>? shouldSkip = null)
Expand All @@ -41,6 +42,20 @@ public async Task ExecuteAsync(IReadOnlyList<string> commands, IReadOnlyList<str
if (kind == BlockKind.Else) { blocks.Else(); continue; }
if (kind == BlockKind.EndIf) { blocks.EndIf(); continue; }

if (kind == BlockKind.Set)
{
if (blocks.Active)
{
var (name, rawValue) = BlockInterpreter.ParseSet(expression);
await _framework.RunOnFrameworkThread(() =>
{
if (!_setUserVariable(name, _resolver.Resolve(rawValue, args)))
Log.Warning("Unknown user variable in :set: {Name}", name);
});
}
continue;
}

if (!blocks.Active) continue;

var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args));
Expand Down
10 changes: 8 additions & 2 deletions Silkstring/Services/CommandResolver.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using Serilog;
Expand All @@ -9,13 +10,18 @@ namespace Silkstring.Services;

public class CommandResolver
{
private readonly Dictionary<string, VariableDescriptor> _variables;
private readonly IVariableProvider[] _providers;
private Dictionary<string, VariableDescriptor> _variables;

public CommandResolver(IEnumerable<IVariableProvider> providers)
{
_variables = providers.SelectMany(p => p.GetVariables()).ToDictionary(v => v.Name, StringComparer.OrdinalIgnoreCase);
_providers = providers.ToArray();
Refresh();
}

[MemberNotNull(nameof(_variables))]
public void Refresh() => _variables = _providers.SelectMany(p => p.GetVariables()).ToDictionary(v => v.Name, StringComparer.OrdinalIgnoreCase);

public IReadOnlyCollection<VariableDescriptor> Variables => _variables.Values;

public string Resolve(string command) => Resolve(command, []);
Expand Down
11 changes: 10 additions & 1 deletion Silkstring/Services/Conditions/BlockInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ internal enum BlockKind
Command,
If,
Else,
EndIf
EndIf,
Set
}

internal sealed class BlockInterpreter
Expand All @@ -21,9 +22,17 @@ public static (BlockKind Kind, string Expression) Classify(string line)
if (line.StartsWith(":if ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.If, line[4..]);
if (line.Equals(":else", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Else, "");
if (line.Equals(":endif", StringComparison.OrdinalIgnoreCase)) return (BlockKind.EndIf, "");
if (line.StartsWith(":set ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Set, line[5..]);
return (BlockKind.Command, line);
}

public static (string Name, string Value) ParseSet(string expression)
{
var trimmed = expression.Trim();
var space = trimmed.IndexOf(' ');
return space < 0 ? (trimmed, "") : (trimmed[..space], trimmed[(space + 1)..].Trim());
}

public void EnterIf(bool conditionMet)
{
var parentActive = Active;
Expand Down
28 changes: 28 additions & 0 deletions Silkstring/Services/Variables/UserVariableProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Silkstring.Models;

namespace Silkstring.Services.Variables;

public sealed class UserVariableProvider : IVariableProvider
{
private readonly Func<IReadOnlyList<UserVariable>> _variables;

public UserVariableProvider(Func<IReadOnlyList<UserVariable>> variables)
{
_variables = variables;
}

public IEnumerable<VariableDescriptor> GetVariables()
{
foreach (var variable in _variables())
{
var name = variable.Name;
yield return new(name, "User-defined variable", "User", () => Lookup(name));
}
}

private string? Lookup(string name)
=> _variables().FirstOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase))?.Value;
}
59 changes: 59 additions & 0 deletions Silkstring/Services/Variables/UserVariableStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Silkstring.Models;

namespace Silkstring.Services.Variables;

public sealed class UserVariableStore
{
private readonly List<UserVariable> _vars;
private readonly ISet<string> _reserved;
private readonly Action _onChanged;

private static readonly Regex NamePattern = new(@"^\w+$", RegexOptions.Compiled);

public IReadOnlyList<UserVariable> Variables => _vars;

public UserVariableStore(List<UserVariable> vars, ISet<string> reserved, Action onChanged)
{
_vars = vars;
_reserved = reserved;
_onChanged = onChanged;
}

public string? ValidateName(string name, UserVariable? excluding = null)
{
if (string.IsNullOrWhiteSpace(name)) return "Enter a name";
if (!NamePattern.IsMatch(name)) return "Use only letters, numbers, and underscores";
if (_reserved.Contains(name)) return $"{name} is a built-in variable";
if (_vars.Any(v => v != excluding && string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase))) return $"{name} is already defined";
return null;
}

public bool TryAdd(string name)
{
if (ValidateName(name) != null) return false;
_vars.Add(new UserVariable { Name = name });
_onChanged();
return true;
}

public bool TrySet(string name, string value)
{
var variable = _vars.FirstOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase));
if (variable == null) return false;
variable.Value = value;
_onChanged();
return true;
}

public void MarkChanged() => _onChanged();

public void Remove(UserVariable variable)
{
_vars.Remove(variable);
_onChanged();
}
}
7 changes: 7 additions & 0 deletions Silkstring/UI/ImGuiUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,11 @@ public static void Tooltip(string text, bool allowWhenDisabled = false)
var flags = allowWhenDisabled ? ImGuiHoveredFlags.AllowWhenDisabled : ImGuiHoveredFlags.None;
if (ImGui.IsItemHovered(flags)) ImGui.SetTooltip(text);
}

public static void TextWrappedDisabled(string text)
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled));
ImGui.TextWrapped(text);
ImGui.PopStyleColor();
}
}
8 changes: 8 additions & 0 deletions Silkstring/UI/Palette.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Numerics;

namespace Silkstring.UI;

public static class Palette
{
public static readonly Vector4 VariableToken = new(0.4f, 0.8f, 1.0f, 1.0f);
}
Loading
Loading