using Dalamud.Game.Command; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.Services; using Glamourer.Automation; using Glamourer.Designs; using Glamourer.Gui; using Glamourer.Interop; using Glamourer.Interop.Penumbra; using Glamourer.Interop.Structs; using Glamourer.State; using ImGuiNET; using OtterGui; using OtterGui.Classes; using Penumbra.GameData.Actors; using Penumbra.GameData.Enums; namespace Glamourer.Services; public class CommandService : IDisposable { private const string MainCommandString = "/glamourer"; private const string ApplyCommandString = "/glamour"; private readonly ICommandManager _commands; private readonly MainWindow _mainWindow; private readonly IChatGui _chat; private readonly ActorManager _actors; private readonly ObjectManager _objects; private readonly StateManager _stateManager; private readonly AutoDesignApplier _autoDesignApplier; private readonly AutoDesignManager _autoDesignManager; private readonly DesignManager _designManager; private readonly DesignConverter _converter; private readonly DesignFileSystem _designFileSystem; private readonly Configuration _config; private readonly PenumbraService _penumbra; public CommandService(ICommandManager commands, MainWindow mainWindow, IChatGui chat, ActorManager actors, ObjectManager objects, AutoDesignApplier autoDesignApplier, StateManager stateManager, DesignManager designManager, DesignConverter converter, DesignFileSystem designFileSystem, AutoDesignManager autoDesignManager, Configuration config, PenumbraService penumbra) { _commands = commands; _mainWindow = mainWindow; _chat = chat; _actors = actors; _objects = objects; _autoDesignApplier = autoDesignApplier; _stateManager = stateManager; _designManager = designManager; _converter = converter; _designFileSystem = designFileSystem; _autoDesignManager = autoDesignManager; _config = config; _penumbra = penumbra; _commands.AddHandler(MainCommandString, new CommandInfo(OnGlamourer) { HelpMessage = "Open or close the Glamourer window." }); _commands.AddHandler(ApplyCommandString, new CommandInfo(OnGlamour) { HelpMessage = "Use Glamourer Functions. Use with 'help' or '?' for extended help." }); } public void Dispose() { _commands.RemoveHandler(MainCommandString); _commands.RemoveHandler(ApplyCommandString); } private void OnGlamourer(string command, string arguments) { if (arguments.Length > 0) switch (arguments) { case "qdb": case "quick": case "bar": case "designs": case "design": case "design bar": _config.Ephemeral.ShowDesignQuickBar = !_config.Ephemeral.ShowDesignQuickBar; _config.Ephemeral.Save(); return; case "lock": case "unlock": _config.Ephemeral.LockMainWindow = !_config.Ephemeral.LockMainWindow; _config.Ephemeral.Save(); return; default: _chat.Print("Use without argument to toggle the main window."); _chat.Print(new SeStringBuilder().AddText("Use ").AddPurple("/glamour").AddText(" instead of ").AddRed("/glamourer") .AddText(" for application commands.").BuiltString); _chat.Print(new SeStringBuilder().AddCommand("qdb", "Toggles the quick design bar on or off.").BuiltString); _chat.Print(new SeStringBuilder().AddCommand("lock", "Toggles the lock of the main window on or off.").BuiltString); return; } _mainWindow.Toggle(); } private void OnGlamour(string command, string arguments) { var argumentList = arguments.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (argumentList.Length < 1) { PrintHelp("?"); return; } var argument = argumentList.Length == 2 ? argumentList[1] : string.Empty; var _ = argumentList[0].ToLowerInvariant() switch { "apply" => Apply(argument), "reapply" => ReapplyState(argument), "revert" => Revert(argument), "reapplyautomation" => ReapplyAutomation(argument), "automation" => SetAutomation(argument), "copy" => CopyState(argument), "save" => SaveState(argument), "delete" => Delete(argument), _ => PrintHelp(argumentList[0]), }; } private bool PrintHelp(string argument) { if (!string.Equals(argument, "help", StringComparison.OrdinalIgnoreCase) && argument != "?") _chat.Print(new SeStringBuilder().AddText("The given argument ").AddRed(argument, true) .AddText(" is not valid. Valid arguments are:").BuiltString); else _chat.Print("Valid arguments for /glamour are:"); _chat.Print(new SeStringBuilder().AddCommand("apply", "Applies a given design to a given character. Use without arguments for help.") .BuiltString); _chat.Print(new SeStringBuilder() .AddCommand("reapply", "Re-applies the current supposed state of a given character. Use without arguments for help.").BuiltString); _chat.Print(new SeStringBuilder().AddCommand("revert", "Reverts a given character to its game state. Use without arguments for help.") .BuiltString); _chat.Print(new SeStringBuilder().AddCommand("reapplyautomation", "Reverts a given character to its supposed state using automated designs. Use without arguments for help.").BuiltString); _chat.Print(new SeStringBuilder() .AddCommand("copy", "Copy the current state of a character to clipboard. Use without arguments for help.").BuiltString); _chat.Print(new SeStringBuilder() .AddCommand("save", "Save the current state of a character to a named design. Use without arguments for help.").BuiltString); _chat.Print(new SeStringBuilder() .AddCommand("automation", "Change the state of automated design sets. Use without arguments for help.").BuiltString); return true; } private bool SetAutomation(string arguments) { var argumentList = arguments.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (argumentList.Length != 2) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour automation ").AddBlue("enable, disable or application", true) .AddText(" ") .AddRed("Automated Design Set Index or Name", true).AddText(" | ").AddYellow("").AddText(" ") .AddPurple("") .BuiltString); _chat.Print( " 》 If the design set name is a valid natural number it will be used as a index. Design names that are such numbers can not be dealt with."); _chat.Print(" 》 If multiple design sets have the same name, the first one will be changed."); _chat.Print(" 》 The name is case-insensitive."); _chat.Print(new SeStringBuilder().AddText(" 》 If the command is ").AddBlue("application") .AddText(" the ").AddYellow("design index").AddText(" and ").AddPurple("flags").AddText(" are required.").BuiltString); _chat.Print(new SeStringBuilder().AddText(" 》 The ").AddYellow("design index") .AddText(" is the number in front of the relevant design in the automated design set.").BuiltString); _chat.Print(new SeStringBuilder().AddText(" 》 The ").AddPurple("Application Flags").AddText(" are a combination of the letters ") .AddInitialPurple("Customizations, ") .AddInitialPurple("Equipment, ") .AddInitialPurple("Accessories, ") .AddInitialPurple("Dyes & Crests and ") .AddInitialPurple("Weapons, where ").AddPurple("CEADW") .AddText(" means everything should be toggled on, and no value means nothing should be toggled on.") .BuiltString); return false; } bool? state = null; switch (argumentList[0].ToLowerInvariant()) { case "enabled": case "enable": case "on": case "true": state = true; break; case "disabled": case "disable": case "off": case "false": state = false; break; case "toggle": case "switch": break; case "application": return HandleApplication(argumentList[1]); default: _chat.Print(new SeStringBuilder().AddText("The command ") .AddBlue(argumentList[0], true).AddText(" is unknown. Currently only ").AddBlue("enable").AddText(", ").AddBlue("disable") .AddText(" or ").AddBlue("application") .AddText(" are supported.").BuiltString); return false; } if (!GetAutoDesignSetIndex(argumentList[1], out var designIdx)) return false; _autoDesignManager.SetState(designIdx, state ?? !_autoDesignManager[designIdx].Enabled); return true; } private bool GetAutoDesignSetIndex(string name, out int idx) { var lowerName = name.ToLowerInvariant(); idx = int.TryParse(lowerName, out var designIdx) && designIdx > 0 && designIdx <= _autoDesignManager.Count ? designIdx - 1 : _autoDesignManager.IndexOf(d => d.Name.ToLowerInvariant() == lowerName); if (idx >= 0) return true; _chat.Print(new SeStringBuilder().AddText("Could not change state of automated design set ") .AddRed(name, true).AddText(" No automated design set of that name or index exists.").BuiltString); return false; } private bool HandleApplication(string argument) { var split = argument.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (split.Length != 2) { _chat.Print(new SeStringBuilder().AddText("The command ").AddBlue("automation") .AddText(" requires a design index and application flags.").BuiltString); return false; } var setName = split[0]; if (!GetAutoDesignSetIndex(setName, out var setIdx)) return false; var set = _autoDesignManager[setIdx]; var split2 = split[1].Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (!int.TryParse(split2[0], out var designIdx) || designIdx <= 0) { _chat.Print(new SeStringBuilder().AddText("The value ").AddYellow(split2[0], true) .AddText(" is not a valid design index.").BuiltString); return false; } if (designIdx > set.Designs.Count) { _chat.Print(new SeStringBuilder().AddText($"The set {setIdx} does not have {designIdx} designs.").BuiltString); return false; } --designIdx; ApplicationType applicationFlags = 0; if (split2.Length == 2) foreach (var character in split2[1]) { switch (char.ToLowerInvariant(character)) { case 'c': applicationFlags |= ApplicationType.Customizations; break; case 'e': applicationFlags |= ApplicationType.Armor; break; case 'a': applicationFlags |= ApplicationType.Accessories; break; case 'd': applicationFlags |= ApplicationType.GearCustomization; break; case 'w': applicationFlags |= ApplicationType.Weapons; break; default: _chat.Print(new SeStringBuilder().AddText("The value ").AddPurple(split2[1], true) .AddText(" is not a valid set of application flags.").BuiltString); return false; } } _autoDesignManager.ChangeApplicationType(set, designIdx, applicationFlags); return true; } private bool ReapplyAutomation(string argument) { if (argument.Length == 0) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour reapplyautomation ").AddGreen("[Character Identifier]").BuiltString); PlayerIdentifierHelp(false, true); return true; } if (!IdentifierHandling(argument, out var identifiers, false, true)) return false; _objects.Update(); foreach (var identifier in identifiers) { if (!_objects.TryGetValue(identifier, out var data)) return true; foreach (var actor in data.Objects) { if (_stateManager.GetOrCreate(identifier, actor, out var state)) { _autoDesignApplier.ReapplyAutomation(actor, identifier, state); _stateManager.ReapplyState(actor); } } } return true; } private bool Revert(string argument) { if (argument.Length == 0) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour revert ").AddGreen("[Character Identifier]").BuiltString); PlayerIdentifierHelp(false, true); return true; } if (!IdentifierHandling(argument, out var identifiers, false, true)) return false; foreach (var identifier in identifiers) { if (_stateManager.TryGetValue(identifier, out var state)) _stateManager.ResetState(state, StateSource.Manual); } return true; } private bool ReapplyState(string argument) { if (argument.Length == 0) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour revert ").AddGreen("[Character Identifier]").BuiltString); PlayerIdentifierHelp(false, true); return true; } if (!IdentifierHandling(argument, out var identifiers, false, true)) return false; _objects.Update(); foreach (var identifier in identifiers) { if (!_objects.TryGetValue(identifier, out var data)) return true; foreach (var actor in data.Objects) _stateManager.ReapplyState(actor); } return true; } private bool Apply(string arguments) { var split = arguments.Split('|', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (split.Length is not 2) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour apply ").AddYellow("[Design Name, Path or Identifier, or Clipboard]") .AddText(" | ") .AddGreen("[Character Identifier]") .AddText("; ") .AddBlue("") .BuiltString); _chat.Print(new SeStringBuilder() .AddText( " 》 The design name is case-insensitive. If multiple designs of that name up to case exist, the first one is chosen.") .BuiltString); _chat.Print(new SeStringBuilder() .AddText( " 》 If using the design identifier, you need to specify at least 4 characters for it, and the first one starting with the provided characters is chosen.") .BuiltString); _chat.Print(new SeStringBuilder() .AddText(" 》 The design path is the folder path in the selector, with '/' as separators. It is also case-insensitive.") .BuiltString); _chat.Print(new SeStringBuilder() .AddText(" 》 Clipboard as a single word will try to apply a design string currently in your clipboard.").BuiltString); _chat.Print(new SeStringBuilder() .AddText(" 》 ").AddBlue("").AddText(" is optional and can be omitted (together with the ;), ").AddBlue("true") .AddText(" or ").AddBlue("false").AddText(".").BuiltString); _chat.Print(new SeStringBuilder().AddText("If ").AddBlue("true") .AddText(", it will try to apply mod associations to the collection assigned to the identified character.").BuiltString); PlayerIdentifierHelp(false, true); } var split2 = split[1].Split(';', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var applyMods = split2.Length == 2 && split2[1].ToLowerInvariant() switch { "true" => true, "1" => true, "t" => true, "yes" => true, "y" => true, _ => false, }; if (!GetDesign(split[0], out var design, true) || !IdentifierHandling(split2[0], out var identifiers, false, true)) return false; _objects.Update(); foreach (var identifier in identifiers) { if (!_objects.TryGetValue(identifier, out var actors)) { if (_stateManager.TryGetValue(identifier, out var state)) _stateManager.ApplyDesign(state, design, ApplySettings.Manual with { MergeLinks = true }); } else { foreach (var actor in actors.Objects) { if (_stateManager.GetOrCreate(actor.GetIdentifier(_actors), actor, out var state)) { ApplyModSettings(design, actor, applyMods); _stateManager.ApplyDesign(state, design, ApplySettings.Manual with { MergeLinks = true }); } } } } return true; } private void ApplyModSettings(DesignBase design, Actor actor, bool applyMods) { if (!applyMods || design is not Design d) return; var collection = _penumbra.GetActorCollection(actor); if (collection.Length <= 0) return; var appliedMods = 0; foreach (var (mod, setting) in d.AssociatedMods) { var message = _penumbra.SetMod(mod, setting, collection); if (message.Length > 0) Glamourer.Messager.Chat.Print($"Error applying mod settings: {message}"); else ++appliedMods; } if (appliedMods > 0) Glamourer.Messager.Chat.Print($"Applied {appliedMods} mod settings to {collection}."); } private bool Delete(string argument) { if (argument.Length == 0) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour delete ").AddYellow("[Design Name, Path or Identifier]").BuiltString); _chat.Print(new SeStringBuilder() .AddText( " 》 The design name is case-insensitive. If multiple designs of that name up to case exist, the first one is chosen.") .BuiltString); _chat.Print(new SeStringBuilder() .AddText( " 》 If using the design identifier, you need to specify at least 4 characters for it, and the first one starting with the provided characters is chosen.") .BuiltString); _chat.Print(new SeStringBuilder() .AddText(" 》 The design path is the folder path in the selector, with '/' as separators. It is also case-insensitive.") .BuiltString); return false; } if (!GetDesign(argument, out var designBase, false) || designBase is not Design d) return false; _designManager.Delete(d); return true; } private bool CopyState(string argument) { if (argument.Length == 0) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour copy ").AddGreen("[Character Identifier]").BuiltString); PlayerIdentifierHelp(false, true); } if (!IdentifierHandling(argument, out var identifiers, false, true)) return false; _objects.Update(); foreach (var identifier in identifiers) { if (!_stateManager.TryGetValue(identifier, out var state) && !(_objects.TryGetValue(identifier, out var data) && data.Valid && _stateManager.GetOrCreate(identifier, data.Objects[0], out state))) continue; try { var text = _converter.ShareBase64(state, ApplicationRules.AllButParameters(state)); ImGui.SetClipboardText(text); return true; } catch { _chat.Print("Could not copy state to clipboard: Failure to write to clipboard."); return false; } } _chat.Print(new SeStringBuilder().AddText("Could not copy state to clipboard: No identified object is available or has stored state.") .BuiltString); return false; } private bool SaveState(string arguments) { var split = arguments.Split('|', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (split.Length != 2) { _chat.Print(new SeStringBuilder().AddText("Use with /glamour save ").AddYellow("[New Design Name]").AddText(" | ") .AddGreen("[Character Identifier]").BuiltString); PlayerIdentifierHelp(false, true); } if (!IdentifierHandling(split[1], out var identifiers, false, true)) return false; _objects.Update(); foreach (var identifier in identifiers) { if (!_stateManager.TryGetValue(identifier, out var state) && !(_objects.TryGetValue(identifier, out var data) && data.Valid && _stateManager.GetOrCreate(identifier, data.Objects[0], out state))) continue; var design = _converter.Convert(state, ApplicationRules.FromModifiers(state)); _designManager.CreateClone(design, split[0], true); return true; } _chat.Print(new SeStringBuilder().AddText("Could not save state to design ").AddYellow(split[0], true) .AddText(": No identified object is available or has stored state.").BuiltString); return false; } private bool GetDesign(string argument, [NotNullWhen(true)] out DesignBase? design, bool allowClipboard) { design = null; if (argument.Length == 0) return false; if (allowClipboard && string.Equals("clipboard", argument, StringComparison.OrdinalIgnoreCase)) { try { var clipboardText = ImGui.GetClipboardText(); if (clipboardText.Length > 0) design = _converter.FromBase64(clipboardText, true, true, out _); } catch { // ignored } if (design != null) return true; _chat.Print(new SeStringBuilder().AddText("Your current clipboard did not contain a valid design string.").BuiltString); return false; } if (Guid.TryParse(argument, out var guid)) { design = _designManager.Designs.ByIdentifier(guid); } else { var lower = argument.ToLowerInvariant(); design = _designManager.Designs.FirstOrDefault(d => d.Name.Lower == lower || lower.Length > 3 && d.Identifier.ToString().StartsWith(lower)); if (design == null && _designFileSystem.Find(lower, out var child) && child is DesignFileSystem.Leaf leaf) design = leaf.Value; } if (design != null) return true; _chat.Print(new SeStringBuilder().AddText("The token ").AddYellow(argument, true).AddText(" did not resolve to an existing design.") .BuiltString); return false; } private unsafe bool IdentifierHandling(string argument, out ActorIdentifier[] identifiers, bool allowAnyWorld, bool allowIndex) { try { if (_objects.GetName(argument.ToLowerInvariant(), out var obj)) { var identifier = _actors.FromObject(obj.AsObject, out _, true, true, true); if (!identifier.IsValid) { _chat.Print(new SeStringBuilder().AddText("The placeholder ").AddGreen(argument) .AddText(" did not resolve to a game object with a valid identifier.").BuiltString); identifiers = Array.Empty(); return false; } if (allowIndex && identifier.Type is IdentifierType.Npc) identifier = _actors.CreateNpc(identifier.Kind, identifier.DataId, obj.Index); identifiers = [ identifier, ]; } else { identifiers = _actors.FromUserString(argument, allowIndex); if (!allowAnyWorld && identifiers[0].Type is IdentifierType.Player or IdentifierType.Owned && identifiers[0].HomeWorld == ushort.MaxValue) { _chat.Print(new SeStringBuilder().AddText("The argument ").AddRed(argument, true) .AddText(" did not specify a world.").BuiltString); return false; } } return true; } catch (ActorIdentifierFactory.IdentifierParseError e) { _chat.Print(new SeStringBuilder().AddText("The argument ").AddRed(argument, true) .AddText($" could not be converted to an identifier. {e.Message}") .BuiltString); identifiers = Array.Empty(); return false; } } private void PlayerIdentifierHelp(bool allowAnyWorld, bool allowIndex) { var npcGuide = new SeStringBuilder().AddText(" 》》》").AddGreen("n").AddText(" | ").AddPurple("[NPC Type]").AddText(" : ") .AddRed("[NPC Name]").AddBlue(allowIndex ? "@" : string.Empty).AddText(", where NPC Type can be ") .AddInitialPurple("Mount") .AddInitialPurple("Companion") .AddInitialPurple("Accessory").AddInitialPurple("Event NPC").AddText("or ").AddInitialPurple("Battle NPC", false); if (allowIndex) npcGuide = npcGuide.AddText(", and the ").AddBlue("index").AddText(" is an optional non-negative number in the object table."); else npcGuide = npcGuide.AddText("."); _chat.Print(new SeStringBuilder().AddText(" 》 Valid Character Identifiers have the form:").BuiltString); _chat.Print(new SeStringBuilder().AddText(" 》》》").AddGreen("").AddText(" or ").AddGreen("").AddText(" or ").AddGreen("") .AddText(" or ").AddGreen("") .AddText(" as placeholders for your character, your target, your mouseover or your focus, if they exist.").BuiltString); _chat.Print(new SeStringBuilder().AddText(" 》》》").AddGreen("p").AddText(" | ").AddWhite("[Player Name]@[World Name]") .AddText(allowAnyWorld ? ", if no @ is provided, Any World is used." : ".") .BuiltString); _chat.Print(new SeStringBuilder().AddText(" 》》》").AddGreen("r").AddText(" | ").AddWhite("[Retainer Name]").AddText(".").BuiltString); _chat.Print(npcGuide.BuiltString); _chat.Print(new SeStringBuilder().AddText(" 》》》 ").AddGreen("o").AddText(" | ").AddPurple("[NPC Type]") .AddText(" : ") .AddRed("[NPC Name]").AddText(" | ").AddWhite("[Player Name]@").AddText(".").BuiltString); } }