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
4 changes: 3 additions & 1 deletion .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
with:
submodules: recursive
- name: Install .NET
uses: actions/setup-dotnet@v5
with:
Expand All @@ -24,6 +26,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: Silkstring
path: .\Silkstring\bin\x64\Release\*
path: .\Silkstring\bin\Release\*
if-no-files-found: error
retention-days: 1
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## v1.6.0.0 - 2026-07-09
### Added
- Emote variables: `{emoting}` tells you whether you are holding an emote or pose, `{emote}` gives its name, and `{pose}` gives the pose number. They appear in the Variables tab alongside the other built-ins
- Your own variables can now have a description, which you set in the Variables window and see next to them in the Variables tab of the help window
- Until: add a `:until` line to hold an alias at that point until a condition becomes true, then carry on, for example `:until {emoting} == false`. Handy for waiting on your own state before the next line runs, such as holding a statement until an emote ends
- An `-unsafe` option on `:until` lets it wait with no time limit. It has to be turned on in the settings first, and normal untils use a timeout you can set there too
- A `/silkstring cancel` command stops any aliases that are currently running
- The editor now colors option flags like `-unsafe`, in a color you can change in the settings
- A new Editor tab in the help window shows what each highlight color means

## v1.5.0.0 - 2026-07-02
### Added
- Syntax highlighting in the multiline editor: commands, keywords (`:if`, `:set`, `:wait`), variables, parameters, and quoted text are each given their own color as you type, and anything malformed is shown in red (a bad `:wait` time, an unfinished `:if`, a `:set` for a variable you have not created, a mistyped keyword, or an unclosed `{`)
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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
- 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

## Installation
Expand Down Expand Up @@ -86,7 +87,9 @@ Variables let you insert live game values into a line using curly brace syntax.
/say I am {character}, a level {level} {job} from {world}!
```

Silkstring includes variables for your character, job, HP and MP, combat state, target, currency, and more, and the list keeps growing. For the full, always-current list with live values, open the Variables tab of the help window (`/silkstring help`). The command tester at the top of that window shows resolved output as you type.
Silkstring includes variables for your character, job, HP and MP, combat state, target, currency, emote and pose state, and more, and the list keeps growing. For the full, always-current list with live values, open the Variables tab of the help window (`/silkstring help`). The command tester at the top of that window shows resolved output as you type.

You can also define your own variables with `/silkstring variables` and use them just like the built-in ones (see User Variables below).

### Parameters

Expand Down Expand Up @@ -146,9 +149,19 @@ You can also pause between lines with `:wait`, followed by a number of seconds:

The wait accepts decimals (`:wait 1.5`) and can take its duration from a variable or parameter (`:wait {0}`). Waits are capped at 60 seconds, and a `:wait` inside a condition only pauses when that branch actually runs. Like the other control lines, `:wait` is never sent to chat, and the editor warns you if a duration is invalid.

You can also hold an alias in place until a condition becomes true with `:until`, then let it carry on:

```
/sit
:until {emoting} == false
/say Up you get!
```

`:until` pauses at that line until the condition is true, for example waiting until an emote or pose ends. If the condition never comes true, it gives up after a timeout you can set in the settings and then continues. Add `-unsafe` to wait with no time limit, such as `:until {emoting} == false -unsafe`, but unsafe waits have to be turned on in the settings first. You can stop any running alias at any time with `/silkstring cancel`.

### 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.
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. You can also give each one an optional description, which appears next to it in the Variables tab of the help window.

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.

Expand All @@ -175,11 +188,11 @@ Create a folder with the **New Folder** button, then drag aliases into or out of

### Multiline entry

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, and quoted text 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.
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.

### 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), 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 multiline command entry, toggle line numbers in the editor, and open the Colors section to recolor the editor and interface to your taste.

## Notes

Expand Down
8 changes: 8 additions & 0 deletions Silkstring/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public int CommandDelay
set => _commandDelay = Math.Clamp(value, 0, 1000);
}

private int _untilTimeoutSeconds = 30;
public int UntilTimeoutSeconds
{
get => _untilTimeoutSeconds;
set => _untilTimeoutSeconds = Math.Clamp(value, 1, 600);
}
public bool AllowUnsafeWaits { get; set; }

public const int CurrentVersion = 2;
public string? LastSeenVersion { get; set; }
public int Version { get; set; } = CurrentVersion;
Expand Down
3 changes: 2 additions & 1 deletion Silkstring/Models/ThemeColors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public class ThemeColors
public Vector4 Variable = new(0.9f, 0.8f, 0.4f, 1.0f);
public Vector4 Parameter = new(1.0f, 0.6f, 0.3f, 1.0f);
public Vector4 Keyword = new(0.4f, 0.8f, 1.0f, 1.0f);
public Vector4 String = new(0.6f, 0.8f, 0.5f, 1.0f);
public Vector4 Command = new(0.7f, 0.5f, 1.0f, 1.0f);
public Vector4 Flag = new(0.85f, 0.55f, 0.85f, 1.0f);
public Vector4 String = new(0.6f, 0.8f, 0.5f, 1.0f);
public Vector4 Text = new(0.9f, 0.9f, 0.9f, 1.0f);
}
1 change: 1 addition & 0 deletions Silkstring/Models/UserVariable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class UserVariable
private static int _nextId = 0;

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

[NonSerialized]
Expand Down
12 changes: 11 additions & 1 deletion Silkstring/Plugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public sealed class Plugin : IDalamudPlugin
[PluginService] internal static IPlayerState PlayerState { get; private set; } = null!;
[PluginService] internal static IClientState ClientState { get; private set; } = null!;
[PluginService] internal static ICondition Condition { get; private set; } = null!;
[PluginService] internal static IDataManager DataManager { get; private set; } = null!;

private const string CommandName = "/silkstring";

Expand Down Expand Up @@ -70,6 +71,7 @@ public Plugin()
new VitalsVariablesProvider(ClientState),
new CombatVariablesProvider(Condition, TargetManager),
new CurrencyVariablesProvider(),
new EmoteVariablesProvider(ClientState, DataManager),
];

var reserved = builtIn.SelectMany(p => p.GetVariables()).Select(v => v.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -142,7 +144,15 @@ private void OnCommand(string command, string args)
case "help": ToggleHelpUi(); break;
case "changelog": ToggleChangelogUi(); break;
case "variables": ToggleVariablesUi(); break;
case "edit": ToggleConfigUi(); break;
case "cancel": {
var cancelled = _chatInterceptor.CancelRunning();
NotificationManager.AddNotification(new Notification
{
Content = cancelled ? "Cancelled running aliases." : "Nothing to cancel.",
Type = NotificationType.Info
});
break;
}
default: MainWindow.Toggle(); break;
}
}
Expand Down
15 changes: 15 additions & 0 deletions Silkstring/Services/AliasValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ public static List<string> FindCycle(AliasEntry target, IEnumerable<AliasEntry>
return null;
}

public static string? ValidateUntils(AliasEntry alias, bool allowUnsafe)
{
foreach (var command in alias.Output)
{
var (kind, expression) = BlockInterpreter.Classify(command.Command);
if (kind != BlockKind.Until) continue;
var (isUnsafe, condition) = BlockInterpreter.ParseUntil(expression);
if (string.IsNullOrWhiteSpace(condition)) return ":until needs a condition";
try { new Parser(Tokenizer.Tokenize(condition)).Parse(); }
catch (ConditionException ex) { return $"Invalid :until condition: {ex.Message}"; }
if (isUnsafe && !allowUnsafe) return "This :until uses -unsafe, but unsafe waits are off in settings";
}
return null;
}

private static Dictionary<string, AliasEntry> BuildTriggerLookup(IEnumerable<AliasEntry> allAliases)
{
var lookup = new Dictionary<string, AliasEntry>(StringComparer.OrdinalIgnoreCase);
Expand Down
15 changes: 13 additions & 2 deletions Silkstring/Services/ChatInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public sealed unsafe class ChatInterceptor : IDisposable
private readonly CommandHandler _commandHandler;
private readonly IFramework _framework;

private readonly CancellationTokenSource _cts = new();
private CancellationTokenSource _cts = new();
private readonly HashSet<string> _executingAliases = new(StringComparer.OrdinalIgnoreCase);

private readonly Hook<ShellCommandModule.Delegates.ExecuteCommandInner> _hook;
Expand All @@ -44,6 +44,15 @@ public void Dispose()
_hook?.Dispose();
}

public bool CancelRunning()
{
var wasRunning = _executingAliases.Count > 0;
_cts.Cancel();
_cts.Dispose();
_cts = new CancellationTokenSource();
return wasRunning;
}

private void ProcessChatInputDetour(ShellCommandModule* shellCommandModule, Utf8String* message, UIModule* uiModule)
{
try
Expand All @@ -69,7 +78,9 @@ private void ProcessChatInputDetour(ShellCommandModule* shellCommandModule, Utf8
bool ShouldSkip(string cmd) => _executingAliases.Contains(cmd);

_commandHandler.ExecuteAsync(commands, args, _configuration.CommandDelay, _cts.Token,
shouldSkip: ShouldSkip)
shouldSkip: ShouldSkip,
untilTimeoutMs: _configuration.UntilTimeoutSeconds * 1000,
allowUnsafe: _configuration.AllowUnsafeWaits)
.ContinueWith(t => Log.Error(t.Exception, "Command execution failed"),
TaskContinuationOptions.OnlyOnFaulted)
.ContinueWith(_ => _framework.RunOnFrameworkThread(() =>
Expand Down
25 changes: 24 additions & 1 deletion Silkstring/Services/CommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public CommandHandler(CommandResolver resolver, IFramework framework, Func<strin
_setUserVariable = setUserVariable;
}

public async Task ExecuteAsync(IReadOnlyList<string> commands, IReadOnlyList<string> args, int delayMs = 100, CancellationToken cancellationToken = default, Func<string, bool>? shouldSkip = null)
public async Task ExecuteAsync(IReadOnlyList<string> commands, IReadOnlyList<string> args, int delayMs = 100, CancellationToken cancellationToken = default, Func<string, bool>? shouldSkip = null, int untilTimeoutMs = 30000, bool allowUnsafe = false)
{
var blocks = new BlockInterpreter();
var sent = false;
Expand Down Expand Up @@ -69,6 +69,16 @@ await _framework.RunOnFrameworkThread(() =>
continue;
}

if (kind == BlockKind.Until)
{
if (blocks.Active)
{
var (isUnsafe, condition) = BlockInterpreter.ParseUntil(expression);
await WaitUntilAsync(condition, args, isUnsafe && allowUnsafe, untilTimeoutMs, cancellationToken);
}
continue;
}

if (!blocks.Active) continue;

var cmd = await _framework.RunOnFrameworkThread(() => _resolver.Resolve(line, args));
Expand Down Expand Up @@ -102,4 +112,17 @@ private bool EvaluateSafe(string expression, IReadOnlyList<string> args)
return false;
}
}

private async Task WaitUntilAsync(string condition, IReadOnlyList<string> args, bool isUnsafe, int capMs, CancellationToken token)
{
const int pollMs = 100;
var elapsed = 0;
while (!await _framework.RunOnFrameworkThread(() => EvaluateSafe(condition, args)))
{
await Task.Delay(pollMs, token);
if (isUnsafe) continue;
elapsed += pollMs;
if (elapsed >= capMs) break;
}
}
}
11 changes: 10 additions & 1 deletion Silkstring/Services/Conditions/BlockInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ internal enum BlockKind
Else,
EndIf,
Set,
Wait
Wait,
Until
}

internal sealed class BlockInterpreter
Expand All @@ -26,9 +27,17 @@ public static (BlockKind Kind, string Expression) Classify(string line)
if (line.Equals(":endif", StringComparison.OrdinalIgnoreCase)) return (BlockKind.EndIf, "");
if (line.StartsWith(":set ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Set, line[5..]);
if (line.StartsWith(":wait ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Wait, line[6..]);
if (line.StartsWith(":until ", StringComparison.OrdinalIgnoreCase)) return (BlockKind.Until, line[7..]);
return (BlockKind.Command, line);
}

public static (bool Unsafe, string Condition) ParseUntil(string expression)
{
var trimmed = expression.Trim();
if (trimmed.EndsWith(" -unsafe", StringComparison.OrdinalIgnoreCase)) return (true, trimmed[..^8].TrimEnd());
return (false, trimmed);
}

public static (string Name, string Value) ParseSet(string expression)
{
var trimmed = expression.Trim();
Expand Down
56 changes: 56 additions & 0 deletions Silkstring/Services/Variables/EmoteVariablesProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using Dalamud.Plugin.Services;
using ECommons.DalamudServices.Legacy;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using Lumina.Excel.Sheets;

namespace Silkstring.Services.Variables;

public sealed class EmoteVariablesProvider : IVariableProvider
{
private readonly IClientState _clientState;
private readonly IDataManager _dataManager;

public EmoteVariablesProvider(IClientState clientState, IDataManager dataManager)
{
_clientState = clientState;
_dataManager = dataManager;
}

public IEnumerable<VariableDescriptor> GetVariables()
{
yield return new("emoting", "Whether you are currently emoting or posing", "Emote", () => IsEmoting() ? "true" : "false");
yield return new ("emote", "Name of the emote you are currently doing", "Emote", EmoteName);
yield return new ("pose", "Pose of the emote you are currently doing", "Emote", Pose);
}

private unsafe Character* Local()
{
var player = _clientState.LocalPlayer;
return player is null ? null : (Character*)player.Address;
}

private unsafe bool IsEmoting()
{
var character = Local();
return character is not null && character->Mode is CharacterModes.InPositionLoop or CharacterModes.EmoteLoop;
}

private unsafe string? EmoteName()
{
var character = Local();
if (character is null || character->Mode is not CharacterModes.InPositionLoop or CharacterModes.EmoteLoop) return null;

Check warning on line 42 in Silkstring/Services/Variables/EmoteVariablesProvider.cs

View workflow job for this annotation

GitHub Actions / build

The pattern is redundant.

Check warning on line 42 in Silkstring/Services/Variables/EmoteVariablesProvider.cs

View workflow job for this annotation

GitHub Actions / build

The pattern is redundant.
var mode = _dataManager.GetExcelSheet<EmoteMode>().GetRowOrDefault(character->ModeParam);
if (mode is null) return null;
var emote = mode.Value.StartEmote;
if (!emote.IsValid || emote.RowId == 0) return null;
return emote.Value.Name.ToString();
}

private unsafe string? Pose()
{
var character = Local();
if (character is null || !IsEmoting()) return null;
return character->EmoteController.CPoseState.ToString();
}
}
2 changes: 1 addition & 1 deletion Silkstring/Services/Variables/UserVariableProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public IEnumerable<VariableDescriptor> GetVariables()
foreach (var variable in _variables())
{
var name = variable.Name;
yield return new(name, "User-defined variable", "User", () => Lookup(name));
yield return new(name, variable.Description, "User", () => Lookup(name));
}
}

Expand Down
2 changes: 2 additions & 0 deletions Silkstring/UI/Palette.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public static class Palette
public static Vector4 Folder;
public static Vector4 Success;
public static Vector4 LineNumber;
public static Vector4 Flag;

public static void Apply(ThemeColors t)
{
Expand All @@ -30,5 +31,6 @@ public static void Apply(ThemeColors t)
Folder = t.Folder;
Success = t.Success;
LineNumber = t.LineNumber;
Flag = t.Flag;
}
}
2 changes: 1 addition & 1 deletion Silkstring/UI/Panels/AliasEditPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ private void RefreshCycleCheck()
if (_selectedAlias == null) return;
_detectedCycle = AliasValidator.FindCycle(_selectedAlias, _configuration.GetAliases());
var defined = new HashSet<string>(_configuration.UserVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase);
_blockError = AliasValidator.ValidateBlocks(_selectedAlias) ?? AliasValidator.ValidateSets(_selectedAlias, defined) ?? AliasValidator.ValidateWaits(_selectedAlias);
_blockError = AliasValidator.ValidateBlocks(_selectedAlias) ?? AliasValidator.ValidateSets(_selectedAlias, defined) ?? AliasValidator.ValidateWaits(_selectedAlias) ?? AliasValidator.ValidateUntils(_selectedAlias, _configuration.AllowUnsafeWaits);
}

private static void ApplyMultiline(AliasEntry alias, string text)
Expand Down
Loading
Loading