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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## v2.0.0.0 - 2026-07-12
### Changed
- Conditionals now use braces. Open a block with `if (condition) {`, close it with `}`, and use `else {` or `else if (condition) {` for the other branches. This replaces the old `:if`, `:else`, and `:endif` lines. Your existing conditional aliases are converted to the new format automatically the first time you update
- The alias editor is now always the syntax highlighted multiline text box. The old row by row list view has been removed
- R.I.P List view (I don't think anyone used this, you won't be missed.. hopefully? c:)

### Added
- `else if` chains conditions together, running the first branch whose condition is true
- A `:return` line stops the rest of an alias right away, which makes a handy early exit, for example checking something at the top of an alias and bailing out
- The editor now marks any line with a problem: the line is highlighted and hovering it tells you what is wrong, on top of the list of problems shown above the editor
- The editor warns you when an alias has no trigger, or a trigger with a space, a slash, or a reserved name
- Tab indents lines in the editor and Shift + Tab removes indentation, so you can format blocks quickly

### Notes
- Your conditional aliases are rewritten from the old `:if` style to braces automatically on update, and a backup of your previous configuration is saved first (for example `Silkstring.v2.backup.json`)

## v1.6.3.0 - 2026-07-11
### Added
- Comments: any line that starts with `#` is now a note to yourself. It is left out when the alias runs and shown in its own color (which you can change in the settings), so you can label your aliases or jot down reminders.
Expand Down
76 changes: 58 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A Dalamud plugin for FFXIV that lets you define custom command aliases. Each ali
- Define custom aliases through an in-game GUI
- Run multiple lines per alias, in order, with a configurable delay between them
- Mix game commands and chat messages in a single alias
- Leave notes in an alias with `#` comment lines
- 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
Expand All @@ -16,6 +17,7 @@ A Dalamud plugin for FFXIV that lets you define custom command aliases. Each ali
- Enable or disable an alias without deleting it
- Built-in help window with a live command tester
- Syntax highlighting in the multiline editor, with customizable colors
- Run commands only when a condition is true with `if` / `else if` / `else` blocks
- Hold an alias until a condition is met with `:until`, and stop running aliases with `/silkstring cancel`
- Cycle detection warns you if aliases would trigger each other in a loop

Expand Down Expand Up @@ -79,6 +81,19 @@ good luck, everyone!
```
This runs `/say Hello!`, then `/emote waves`, then sends "good luck, everyone!" to your current channel.

### Comments

Any line that starts with `#` is a comment. It is left out when the alias runs and shown in its own color in the editor, so you can label an alias or leave yourself notes:

```
# heal the party if anyone is hurt
if ({hpp} < 50) {
/ac Medica II
}
```

Only whole lines are comments. A `#` partway through a line is treated as normal text, so `/say I'm #1!` still sends as written. You can change the comment color in the settings.

### Variables

Variables let you insert live game values into a line using curly brace syntax. They are case-insensitive and resolved at the moment the alias fires, so they always reflect your current state. If a value cannot be read (for example when you are not logged in) the variable is left as written rather than blanked out.
Expand Down Expand Up @@ -119,30 +134,55 @@ If you reference an argument that was not supplied, it is left as written (e.g.

### Conditionals

Aliases can run commands only when a condition is true, using `:if` / `:else` / `:endif` blocks:
Aliases can run commands only when a condition is true, using `if` blocks with braces:

```
:if {hpp} < 50
/ac Cure
:else
/say all good
:endif
if ({hpp} < 50) {
/ac Cure
}
else {
/say all good
}
```

Everything between `:if` and `:endif` runs only when the condition holds; the optional `:else` block runs when it does not. A block can contain multiple commands, and blocks can be nested.
Everything inside the braces after `if (...)` runs only when the condition holds. Add an `else { }` block for the other case, or chain several checks with `else if (...)`, which runs the first branch whose condition is true:

```
if ({hpp} < 25) {
/ac Benediction
}
else if ({hpp} < 50) {
/ac Cure
}
else {
/say all good
}
```

Blocks can hold multiple lines and can be nested inside each other. Indent with Tab and remove indentation with Shift + Tab to keep nested blocks readable.

A condition compares values with `==`, `!=`, `<`, `>`, `<=`, `>=`, and you can combine comparisons with `&&` (and) and `||` (or). Either side can be a variable, a parameter, or plain text, so conditions can react to game state or to what you typed:

```
:if {incombat} && {hpp} < 50
:if {job} == WHM || {job} == SCH
:if {0} == on
:if {time24} >= 17:00 || {time24} <= 05:00
if ({incombat} && {hpp} < 50) {
if ({job} == WHM || {job} == SCH) {
if ({0} == on) {
if ({time24} >= 17:00 || {time24} <= 05:00) {
```

That last line reacts to the clock: because `{time24}` and `{date}` are written with leading zeros (like 09:00 and 2026-07-09), they sort in the right order and can be compared with `<`, `>`, `<=`, and `>=`. This is why `{time24}` is the one to compare against, while `{time}` (like 3:45 PM) is just for showing.

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.
Text comparisons are case-insensitive, and numbers compare as numbers. The editor colors a brace or condition red when it is not valid, marks the line, and lists any open block or condition it cannot understand above the editor.

You can stop the rest of an alias early with a `:return` line, which is handy as a guard near the top:

```
if ({incombat}) {
:return
}
/wave
good luck, everyone!
```

You can also pause between lines with `:wait`, followed by a number of seconds:

Expand Down Expand Up @@ -182,22 +222,22 @@ The value is resolved when the line runs, so it can include other variables and
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
if ({burst}) {
/say bursting
}
```

### 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.

### Multiline entry
### The editor

By default each line of an alias is its own row. You can switch to a single multiline editor in the settings, which is handy for pasting or editing longer aliases. The editor highlights your syntax as you type: commands, keywords, variables, parameters, quoted text, and option flags each get their own color, and anything malformed (a bad `:wait`, an unfinished `:if`, an unknown `:set` name, a mistyped keyword, or an unclosed `{`) is shown in red. It also shows line numbers, which you can turn off in the settings.
Aliases are edited in a single multiline text box that highlights your syntax as you type: commands, keywords, variables, parameters, quoted text, and option flags each get their own color, and anything malformed (a bad `:wait`, an unclosed block, an unknown `:set` name, or a broken condition) is shown in red and marked on the line. It shows line numbers, which you can turn off in the settings. Tab indents the current lines and Shift + Tab removes indentation, so you can format blocks quickly.

### Settings

Open settings with the cog icon in the Silkstring title bar, or the cog next to Silkstring in `/xlplugins`. You can set the delay between lines in milliseconds (0 to 1000, default 100), set how long an `:until` waits before giving up, allow unsafe waits that have no time limit, toggle multiline command entry, toggle line numbers in the editor, and open the Colors section to recolor the editor and interface to your taste.
Open settings with the cog icon in the Silkstring title bar, or the cog next to Silkstring in `/xlplugins`. You can set the delay between lines in milliseconds (0 to 1000, default 100), set how long an `:until` waits before giving up, allow unsafe waits that have no time limit, toggle line numbers in the editor, and open the Colors section to recolor the editor and interface to your taste.

## Notes

Expand Down
3 changes: 1 addition & 2 deletions Silkstring/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public int UntilTimeoutSeconds
}
public bool AllowUnsafeWaits { get; set; }

public const int CurrentVersion = 2;
public const int CurrentVersion = 3;
public string? LastSeenVersion { get; set; }
public int Version { get; set; } = CurrentVersion;

Expand All @@ -39,7 +39,6 @@ public int UntilTimeoutSeconds

public ThemeColors Theme = new();
public bool ShowLineNumbers = true;
public bool MultilineCommands { get; set; }

public void Save()
{
Expand Down
6 changes: 3 additions & 3 deletions Silkstring/Models/AliasEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class AliasEntry
public string DisplayName = string.Empty;
public string Name = string.Empty;

[JsonIgnore] public string[] triggers => Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
[JsonIgnore] public string[] Triggers => Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
[JsonIgnore] public string EffectiveName => string.IsNullOrWhiteSpace(DisplayName) ? Name : DisplayName;
public bool Enabled = true;
public List<CommandEntry> Output = new();
Expand All @@ -34,9 +34,9 @@ public AliasEntry()

public bool IsValid()
{
if (triggers.Length == 0) return false;
if (Triggers.Length == 0) return false;

foreach (var trigger in triggers)
foreach (var trigger in Triggers)
{
if (Blacklist.Contains(trigger)) return false;
if (trigger.Contains(' ')) return false;
Expand Down
4 changes: 0 additions & 4 deletions Silkstring/Models/CommandEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ public class CommandEntry

public string Command = string.Empty;

[NonSerialized]
[JsonIgnore]
public bool Delete;

[NonSerialized]
[JsonIgnore]
public int UniqueId;
Expand Down
2 changes: 1 addition & 1 deletion Silkstring/Services/AliasMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static class AliasMatcher
{
return aliases.FirstOrDefault(a =>
a.Enabled && a.IsValid() &&
a.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
a.Triggers
.Any(n => n.Equals(commandName, StringComparison.OrdinalIgnoreCase)));
}
}
51 changes: 22 additions & 29 deletions Silkstring/Services/AliasValidator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Silkstring.Models;
using Silkstring.Services.Conditions;

Expand All @@ -10,6 +11,7 @@ public static class AliasValidator
public static List<Diagnostic> Validate(AliasEntry alias, ISet<string> definedVariables, bool allowUnsafe, IEnumerable<AliasEntry> allAliases)
{
var diagnostics = new List<Diagnostic>();
diagnostics.AddRange(ValidateTrigger(alias));
diagnostics.AddRange(ValidateBlocks(alias));
diagnostics.AddRange(ValidateSets(alias, definedVariables));
diagnostics.AddRange(ValidateWaits(alias));
Expand All @@ -19,6 +21,22 @@ public static List<Diagnostic> Validate(AliasEntry alias, ISet<string> definedVa
return diagnostics;
}

public static IEnumerable<Diagnostic> ValidateTrigger(AliasEntry alias)
{
var triggers = alias.Triggers;
if (alias.Triggers.Length == 0)
{
yield return new("This alias needs a trigger");
yield break;
}
foreach (var t in triggers)
{
if (AliasEntry.Blacklist.Contains(t)) yield return new($"\"{t}\" is a reserved name and cannot be used as a trigger");
else if (t.Contains(' ')) yield return new($"Trigger \"{t}\" cannot contain spaces");
else if (t.Contains('/')) yield return new($"Trigger \"{t}\" cannot contain a slash");
}
}

public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry> allAliases)
{
var lookup = BuildTriggerLookup(allAliases);
Expand All @@ -27,32 +45,7 @@ public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry>
return Dfs(target, lookup, visited, path);
}

public static IEnumerable<Diagnostic> ValidateBlocks(AliasEntry alias)
{
var elseSeen = new Stack<bool>();
for (var i = 0; i < alias.Output.Count; i++)
{
var (kind, expression) = BlockInterpreter.Classify(alias.Output[i].Command.Trim());
switch (kind)
{
case BlockKind.If:
if (!Condition.TryParse(expression, out _, out var error)) { yield return new($"Invalid condition: {error}", i); yield break; }
elseSeen.Push(false);
break;
case BlockKind.Else:
if (elseSeen.Count == 0) { yield return new(":else without a matching :if", i); yield break; }
if (elseSeen.Peek()) { yield return new("Duplicate :else in a block", i); yield break; }
elseSeen.Pop();
elseSeen.Push(true);
break;
case BlockKind.EndIf:
if (elseSeen.Count == 0) { yield return new(":endif without a matching :if", i); yield break; }
elseSeen.Pop();
break;
}
}
if (elseSeen.Count > 0) yield return new("Unclosed :if (missing :endif)");
}
public static IEnumerable<Diagnostic> ValidateBlocks(AliasEntry alias) => BlockParser.Parse(alias.Output.Select(c => c.Command).ToList()).Diagnostics;

public static IEnumerable<Diagnostic> ValidateSets(AliasEntry alias, ISet<string> defined)
{
Expand Down Expand Up @@ -97,7 +90,7 @@ private static Dictionary<string, AliasEntry> BuildTriggerLookup(IEnumerable<Ali
var lookup = new Dictionary<string, AliasEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var alias in allAliases)
{
var triggers = alias.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var triggers = alias.Triggers;
foreach (var trigger in triggers) lookup.TryAdd(trigger, alias);
}
return lookup;
Expand All @@ -121,15 +114,15 @@ private static IEnumerable<AliasEntry> GetDependencies(AliasEntry alias, Diction
private static List<string> Dfs(
AliasEntry current, Dictionary<string, AliasEntry> lookup, HashSet<string> visited, List<string> path)
{
var triggers = current.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var triggers = current.Triggers;
if (triggers.Length == 0) return new List<string>();

var trigger = triggers[0];
path.Add(trigger);

foreach (var dependency in GetDependencies(current, lookup))
{
var depTrigger = dependency.Name.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)[0];
var depTrigger = dependency.Triggers[0];

if (path.Contains(depTrigger))
{
Expand Down
3 changes: 1 addition & 2 deletions Silkstring/Services/ChatInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ private void ProcessChatInputDetour(ShellCommandModule* shellCommandModule, Utf8
.Where(c => !string.IsNullOrWhiteSpace(c.Command) && !c.Command.TrimStart().StartsWith('#'))
.Select(c => c.Command.Trim())
.ToList();
var names = alias.Name.Split('|', StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
var names = alias.Triggers;

foreach (var name in names) _executingAliases.Add(name);

Expand Down
Loading
Loading