From 16f0fb76f4c7d074a3434551bb93e22f1966f741 Mon Sep 17 00:00:00 2001 From: goat Date: Fri, 7 Jun 2024 01:21:13 +0200 Subject: [PATCH] add basic console system for debugging --- Dalamud/Console/ConsoleArgumentType.cs | 27 + Dalamud/Console/ConsoleEntry.cs | 44 ++ Dalamud/Console/ConsoleManager.cs | 507 ++++++++++++++++++ Dalamud/Game/Command/CommandManager.cs | 11 + .../Interface/Internal/DalamudInterface.cs | 7 +- .../Internal/Windows/ConsoleWindow.cs | 34 +- .../Internal/Windows/TitleScreenMenuWindow.cs | 13 +- Dalamud/Logging/Internal/ModuleLog.cs | 9 +- 8 files changed, 634 insertions(+), 18 deletions(-) create mode 100644 Dalamud/Console/ConsoleArgumentType.cs create mode 100644 Dalamud/Console/ConsoleEntry.cs create mode 100644 Dalamud/Console/ConsoleManager.cs diff --git a/Dalamud/Console/ConsoleArgumentType.cs b/Dalamud/Console/ConsoleArgumentType.cs new file mode 100644 index 000000000..4b4a74ce4 --- /dev/null +++ b/Dalamud/Console/ConsoleArgumentType.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Console; + +/// +/// Possible console argument types. +/// +internal enum ConsoleArgumentType +{ + /// + /// A regular string. + /// + String, + + /// + /// A signed integer. + /// + Integer, + + /// + /// A floating point value. + /// + Float, + + /// + /// A boolean value. + /// + Bool, +} diff --git a/Dalamud/Console/ConsoleEntry.cs b/Dalamud/Console/ConsoleEntry.cs new file mode 100644 index 000000000..701a4e04b --- /dev/null +++ b/Dalamud/Console/ConsoleEntry.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Dalamud.Console; + +/// +/// Interface representing an entry in the console. +/// +public interface IConsoleEntry +{ + /// + /// Gets the name of the entry. + /// + public string Name { get; } + + /// + /// Gets the description of the entry. + /// + public string Description { get; } +} + +/// +/// Interface representing a command in the console. +/// +public interface IConsoleCommand : IConsoleEntry +{ + /// + /// Execute this command. + /// + /// Arguments to invoke the entry with. + /// Whether or not execution succeeded. + public bool Invoke(IEnumerable arguments); +} + +/// +/// Interface representing a variable in the console. +/// +/// The type of the variable +public interface IConsoleVariable : IConsoleEntry +{ + /// + /// Gets or sets the value of this variable. + /// + T Value { get; set; } +} diff --git a/Dalamud/Console/ConsoleManager.cs b/Dalamud/Console/ConsoleManager.cs new file mode 100644 index 000000000..17997cc59 --- /dev/null +++ b/Dalamud/Console/ConsoleManager.cs @@ -0,0 +1,507 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +using Dalamud.Logging.Internal; + +using Serilog.Events; + +namespace Dalamud.Console; + +// TODO: Mayhaps overloads with Func for commands? + +/// +/// Class managing console commands and variables. +/// +[ServiceManager.BlockingEarlyLoadedService("Console is needed by other blocking early loaded services.")] +internal partial class ConsoleManager : IServiceType +{ + private static readonly ModuleLog Log = new("CON"); + + private Dictionary entries = new(); + + /// + /// Initializes a new instance of the class. + /// + [ServiceManager.ServiceConstructor] + public ConsoleManager() + { + } + + /// + /// Event that is triggered when a command is processed. Return true to stop the command from being processed any further. + /// + public event Func? Invoke; + + /// + /// Gets a read-only dictionary of console entries. + /// + public IReadOnlyDictionary Entries => this.entries; + + /// + /// Add a command to the console. + /// + /// The name of the command. + /// A description of the command. + /// Function to invoke when the command has been called. Must return a indicating success. + /// The added command. + /// Thrown if the command already exists. + public IConsoleCommand AddCommand(string name, string description, Delegate func) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(description); + ArgumentNullException.ThrowIfNull(func); + + if (this.FindEntry(name) != null) + throw new InvalidOperationException($"Entry '{name}' already exists."); + + var command = new ConsoleCommand(name, description, func); + this.entries.Add(name, command); + + return command; + } + + /// + /// Add a variable to the console. + /// + /// The name of the variable. + /// A description of the variable. + /// The default value of the variable. + /// The type of the variable. + /// The added variable. + /// Thrown if the variable already exists. + public IConsoleVariable AddVariable(string name, string description, T defaultValue) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(description); + Traits.ThrowIfTIsNullableAndNull(defaultValue); + + if (this.FindEntry(name) != null) + throw new InvalidOperationException($"Entry '{name}' already exists."); + + var variable = new ConsoleVariable(name, description); + variable.Value = defaultValue; + this.entries.Add(name, variable); + + return variable; + } + + /// + /// Add an alias to a console entry. + /// + /// The name of the entry to add an alias for. + /// The alias to use. + /// The added alias. + public IConsoleEntry AddAlias(string name, string alias) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(alias); + + var target = this.FindEntry(name); + if (target == null) + throw new EntryNotFoundException(name); + + if (this.FindEntry(alias) != null) + throw new InvalidOperationException($"Entry '{alias}' already exists."); + + var aliasEntry = new ConsoleAlias(name, target); + this.entries.Add(alias, aliasEntry); + + return aliasEntry; + } + + /// + /// Remove an entry from the console. + /// + /// The entry to remove. + public void RemoveEntry(IConsoleEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + } + + /// + /// Get the value of a variable. + /// + /// The name of the variable. + /// The type of the variable. + /// The value of the variable. + /// Thrown if the variable could not be found. + /// Thrown if the found console entry is not of the expected type. + public T GetVariable(string name) + { + ArgumentNullException.ThrowIfNull(name); + + var entry = this.FindEntry(name); + + if (entry is ConsoleVariable variable) + return variable.Value; + + if (entry is ConsoleVariable) + throw new InvalidOperationException($"Variable '{name}' is not of type {typeof(T).Name}."); + + if (entry is null) + throw new EntryNotFoundException(name); + + throw new InvalidOperationException($"Command '{name}' is not a variable."); + } + + /// + /// Set the value of a variable. + /// + /// The name of the variable. + /// The value to set. + /// The type of the value to set. + /// Thrown if the found console entry is not of the expected type. + /// Thrown if the variable could not be found. + public void SetVariable(string name, T value) + { + ArgumentNullException.ThrowIfNull(name); + Traits.ThrowIfTIsNullableAndNull(value); + + var entry = this.FindEntry(name); + + if (entry is ConsoleVariable variable) + variable.Value = value; + + if (entry is ConsoleVariable) + throw new InvalidOperationException($"Variable '{name}' is not of type {typeof(T).Name}."); + + if (entry is null) + throw new EntryNotFoundException(name); + + throw new InvalidOperationException($"Command '{name}' is not a variable."); + } + + /// + /// Process a console command. + /// + /// The command to process. + /// Whether or not the command was successfully processed. + public bool ProcessCommand(string command) + { + if (this.Invoke?.Invoke(command) == true) + return true; + + var matches = GetCommandParsingRegex().Matches(command); + if (matches.Count == 0) + return false; + + var entryName = matches[0].Value; + if (string.IsNullOrEmpty(entryName) || entryName.Any(x => x == ' ')) + { + Log.Error("No valid command specified"); + return false; + } + + var entry = this.FindEntry(entryName); + if (entry == null) + { + Log.Error("Command '{CommandName}' not found", entryName); + return false; + } + + var parsedArguments = new List(); + + if (entry.ValidArguments != null) + { + for (var i = 1; i < matches.Count; i++) + { + if (i - 1 >= entry.ValidArguments.Count) + { + Log.Error("Too many arguments for command '{CommandName}'", entryName); + PrintUsage(entry); + return false; + } + + var argumentToMatch = entry.ValidArguments[i - 1]; + + var group = matches[i]; + if (!group.Success) + continue; + + var value = group.Value; + if (string.IsNullOrEmpty(value)) + continue; + + switch (argumentToMatch.Type) + { + case ConsoleArgumentType.String: + parsedArguments.Add(value); + break; + + case ConsoleArgumentType.Integer when int.TryParse(value, out var intValue): + parsedArguments.Add(intValue); + break; + case ConsoleArgumentType.Integer: + Log.Error("Argument '{Argument}' for command '{CommandName}' is not an integer", value, entryName); + PrintUsage(entry); + return false; + + case ConsoleArgumentType.Float when float.TryParse(value, out var floatValue): + parsedArguments.Add(floatValue); + break; + case ConsoleArgumentType.Float: + Log.Error("Argument '{Argument}' for command '{CommandName}' is not a float", value, entryName); + PrintUsage(entry); + return false; + + case ConsoleArgumentType.Bool when bool.TryParse(value, out var boolValue): + parsedArguments.Add(boolValue); + break; + case ConsoleArgumentType.Bool: + Log.Error("Argument '{Argument}' for command '{CommandName}' is not a boolean", value, entryName); + PrintUsage(entry); + return false; + + default: + throw new Exception("Unhandled argument type."); + } + } + + if (parsedArguments.Count != entry.ValidArguments.Count) + { + // Either fill in the default values or error out + + for (var i = parsedArguments.Count; i < entry.ValidArguments.Count; i++) + { + var argument = entry.ValidArguments[i]; + if (argument.DefaultValue == null) + { + Log.Error("Not enough arguments for command '{CommandName}'", entryName); + PrintUsage(entry); + return false; + } + + parsedArguments.Add(argument.DefaultValue); + } + + if (parsedArguments.Count != entry.ValidArguments.Count) + { + Log.Error("Too many arguments for command '{CommandName}'", entryName); + PrintUsage(entry); + return false; + } + } + } + else + { + if (matches.Count > 1) + { + Log.Error("Command '{CommandName}' does not take any arguments", entryName); + PrintUsage(entry); + return false; + } + } + + return entry.Invoke(parsedArguments); + } + + [GeneratedRegex("""("[^"]+"|[^\s"]+)""", RegexOptions.Compiled)] + private static partial Regex GetCommandParsingRegex(); + + private static void PrintUsage(ConsoleEntry entry, bool error = true) + { + Log.WriteLog( + error ? LogEventLevel.Error : LogEventLevel.Information, + "Usage: {CommandName} {Arguments}", + null, + entry.Name, + string.Join(" ", entry.ValidArguments?.Select(x => $"<{x.Type.ToString().ToLowerInvariant()}>") ?? Enumerable.Empty())); + } + + private ConsoleEntry? FindEntry(string name) + { + return this.entries.TryGetValue(name, out var entry) ? entry as ConsoleEntry : null; + } + + private static class Traits + { + public static void ThrowIfTIsNullableAndNull(T? argument, [CallerArgumentExpression("argument")] string? paramName = null) + { + if (argument == null && !typeof(T).IsValueType) + throw new ArgumentNullException(paramName); + } + } + + /// + /// Class representing an entry in the console. + /// + private abstract class ConsoleEntry : IConsoleEntry + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the entry. + /// A description of the entry. + public ConsoleEntry(string name, string description) + { + this.Name = name; + this.Description = description; + } + + /// + public string Name { get; } + + /// + public string Description { get; } + + /// + /// Gets or sets a list of valid argument types for this console entry. + /// + public IReadOnlyList? ValidArguments { get; protected set; } + + /// + /// Execute this command. + /// + /// Arguments to invoke the entry with. + /// Whether or not execution succeeded. + public abstract bool Invoke(IEnumerable arguments); + + /// + /// Get an instance of for a given type. + /// + /// The type of the argument. + /// The default value to use if none is specified. + /// An instance. + /// Thrown if the given type cannot be handled by the console system. + protected static ArgumentInfo TypeToArgument(Type type, object? defaultValue = null) + { + if (type == typeof(string)) + return new ArgumentInfo(ConsoleArgumentType.String, defaultValue); + + if (type == typeof(int)) + return new ArgumentInfo(ConsoleArgumentType.Integer, defaultValue); + + if (type == typeof(float)) + return new ArgumentInfo(ConsoleArgumentType.Float, defaultValue); + + if (type == typeof(bool)) + return new ArgumentInfo(ConsoleArgumentType.Bool, defaultValue); + + throw new ArgumentException($"Invalid argument type: {type.Name}"); + } + + public record ArgumentInfo(ConsoleArgumentType Type, object? DefaultValue); + } + + /// + /// Class representing an alias to another console entry. + /// + private class ConsoleAlias : ConsoleEntry + { + private readonly ConsoleEntry target; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the alias. + /// The target entry to alias to. + public ConsoleAlias(string name, ConsoleEntry target) + : base(name, target.Description) + { + this.target = target; + this.ValidArguments = target.ValidArguments; + } + + /// + public override bool Invoke(IEnumerable arguments) + { + return this.target.Invoke(arguments); + } + } + + /// + /// Class representing a console command. + /// + private class ConsoleCommand : ConsoleEntry, IConsoleCommand + { + private Delegate func; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the variable. + /// A description of the variable. + /// The function to invoke. + public ConsoleCommand(string name, string description, Delegate func) + : base(name, description) + { + this.func = func; + + if (func.Method.ReturnType != typeof(bool)) + throw new ArgumentException("Console command functions must return a boolean indicating success."); + + var validArguments = new List(); + foreach (var parameterInfo in func.Method.GetParameters()) + { + var paraT = parameterInfo.ParameterType; + validArguments.Add(TypeToArgument(paraT, parameterInfo.DefaultValue)); + } + + this.ValidArguments = validArguments; + } + + /// + public override bool Invoke(IEnumerable arguments) + { + this.func.DynamicInvoke(arguments.ToArray()); + return true; + } + } + + /// + /// Class representing a basic console variable. + /// + /// The name of the variable. + /// A description of the variable. + private abstract class ConsoleVariable(string name, string description) : ConsoleEntry(name, description); + + /// + /// Class representing a generic console variable. + /// + /// The type of the variable. + private class ConsoleVariable : ConsoleVariable, IConsoleVariable + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the variable. + /// A description of the variable. + public ConsoleVariable(string name, string description) + : base(name, description) + { + this.ValidArguments = new List { TypeToArgument(typeof(T)) }; + } + + /// + public T Value { get; set; } + + /// + public override bool Invoke(IEnumerable arguments) + { + var first = arguments.FirstOrDefault(); + if (first == null || first.GetType() != typeof(T)) + throw new ArgumentException($"Console variable must be set with an argument of type {typeof(T).Name}."); + + this.Value = (T)first; + + return true; + } + } +} + +/// +/// Exception thrown when a console entry is not found. +/// +internal class EntryNotFoundException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the entry. + public EntryNotFoundException(string name) + : base($"Console entry '{name}' does not exist.") + { + } +} diff --git a/Dalamud/Game/Command/CommandManager.cs b/Dalamud/Game/Command/CommandManager.cs index 6a419a34a..68d42591d 100644 --- a/Dalamud/Game/Command/CommandManager.cs +++ b/Dalamud/Game/Command/CommandManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Text.RegularExpressions; +using Dalamud.Console; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; @@ -33,6 +34,9 @@ internal sealed class CommandManager : IInternalDisposableService, ICommandManag [ServiceManager.ServiceDependency] private readonly ChatGui chatGui = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly ConsoleManager console = Service.Get(); [ServiceManager.ServiceConstructor] private CommandManager(Dalamud dalamud) @@ -47,6 +51,7 @@ internal sealed class CommandManager : IInternalDisposableService, ICommandManag }; this.chatGui.CheckMessageHandled += this.OnCheckMessageHandled; + this.console.Invoke += this.ConsoleOnInvoke; } /// @@ -132,8 +137,14 @@ internal sealed class CommandManager : IInternalDisposableService, ICommandManag /// void IInternalDisposableService.DisposeService() { + this.console.Invoke -= this.ConsoleOnInvoke; this.chatGui.CheckMessageHandled -= this.OnCheckMessageHandled; } + + private bool ConsoleOnInvoke(string arg) + { + return arg.StartsWith('/') && this.ProcessCommand(arg); + } private void OnCheckMessageHandled(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) { diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 54c5e1ba9..2eb8299b3 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using CheapLoc; using Dalamud.Configuration.Internal; +using Dalamud.Console; using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Keys; @@ -101,7 +102,8 @@ internal class DalamudInterface : IInternalDisposableService Game.Framework framework, ClientState clientState, TitleScreenMenu titleScreenMenu, - GameGui gameGui) + GameGui gameGui, + ConsoleManager consoleManager) { this.dalamud = dalamud; this.configuration = configuration; @@ -126,7 +128,8 @@ internal class DalamudInterface : IInternalDisposableService fontAtlasFactory, framework, gameGui, - titleScreenMenu) { IsOpen = false }; + titleScreenMenu, + consoleManager) { IsOpen = false }; this.changelogWindow = new ChangelogWindow( this.titleScreenMenuWindow, fontAtlasFactory, diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index 1405e078a..51ab7404a 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -8,6 +8,7 @@ using System.Text; using System.Text.RegularExpressions; using Dalamud.Configuration.Internal; +using Dalamud.Console; using Dalamud.Game; using Dalamud.Game.Command; using Dalamud.Interface.Colors; @@ -90,6 +91,14 @@ internal class ConsoleWindow : Window, IDisposable SerilogEventSink.Instance.LogLine += this.OnLogLine; Service.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate); + + var cm = Service.Get(); + cm.AddCommand("clear", "Clear the console log", () => + { + this.QueueClear(); + return true; + }); + cm.AddAlias("clear", "cls"); this.Size = new Vector2(500, 400); this.SizeCondition = ImGuiCond.FirstUseEver; @@ -787,11 +796,6 @@ internal class ConsoleWindow : Window, IDisposable { try { - if (this.commandText.StartsWith('/')) - { - this.commandText = this.commandText[1..]; - } - this.historyPos = -1; for (var i = this.history.Count - 1; i >= 0; i--) { @@ -810,7 +814,7 @@ internal class ConsoleWindow : Window, IDisposable return; } - this.lastCmdSuccess = Service.Get().ProcessCommand("/" + this.commandText); + this.lastCmdSuccess = Service.Get().ProcessCommand(this.commandText); this.commandText = string.Empty; // TODO: Force scroll to bottom @@ -839,15 +843,21 @@ internal class ConsoleWindow : Window, IDisposable if (words.Length > 1) return 0; - // TODO: Improve this, add partial completion + // TODO: Improve this, add partial completion, arguments, description, etc. // https://github.com/ocornut/imgui/blob/master/imgui_demo.cpp#L6443-L6484 - var candidates = Service.Get().Commands - .Where(x => x.Key.Contains("/" + words[0])) - .ToList(); - if (candidates.Count > 0) + var candidates = Service.Get().Entries + .Where(x => x.Key.StartsWith(words[0])) + .Select(x => x.Key); + + candidates = candidates.Union( + Service.Get().Commands + .Where(x => x.Key.StartsWith(words[0])).Select(x => x.Key)); + + var enumerable = candidates as string[] ?? candidates.ToArray(); + if (enumerable.Length != 0) { ptr.DeleteChars(0, ptr.BufTextLen); - ptr.InsertChars(0, candidates[0].Key.Replace("/", string.Empty)); + ptr.InsertChars(0, enumerable[0]); } break; diff --git a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs index 0600ec001..00b8d0c39 100644 --- a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs +++ b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Numerics; using Dalamud.Configuration.Internal; +using Dalamud.Console; using Dalamud.Game; using Dalamud.Game.ClientState; using Dalamud.Game.Gui; @@ -41,6 +42,8 @@ internal class TitleScreenMenuWindow : Window, IDisposable private readonly Dictionary shadeEasings = new(); private readonly Dictionary moveEasings = new(); private readonly Dictionary logoEasings = new(); + + private readonly IConsoleVariable showTsm; private InOutCubic? fadeOutEasing; @@ -55,7 +58,8 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// An instance of . /// An instance of . /// An instance of . - /// An instance of . + /// An instance of . + /// An instance of . public TitleScreenMenuWindow( ClientState clientState, DalamudConfiguration configuration, @@ -63,12 +67,15 @@ internal class TitleScreenMenuWindow : Window, IDisposable FontAtlasFactory fontAtlasFactory, Framework framework, GameGui gameGui, - TitleScreenMenu titleScreenMenu) + TitleScreenMenu titleScreenMenu, + ConsoleManager consoleManager) : base( "TitleScreenMenuOverlay", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNavFocus) { + this.showTsm = consoleManager.AddVariable("dalamud.show_tsm", "Show the Title Screen Menu", true); + this.clientState = clientState; this.configuration = configuration; this.gameGui = gameGui; @@ -136,7 +143,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// public override void Draw() { - if (!this.AllowDrawing) + if (!this.AllowDrawing || !this.showTsm.Value) return; var scale = ImGui.GetIO().FontGlobalScale; diff --git a/Dalamud/Logging/Internal/ModuleLog.cs b/Dalamud/Logging/Internal/ModuleLog.cs index 1fe955294..bcbb6e2b1 100644 --- a/Dalamud/Logging/Internal/ModuleLog.cs +++ b/Dalamud/Logging/Internal/ModuleLog.cs @@ -141,8 +141,15 @@ public class ModuleLog public void Fatal(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Fatal, messageTemplate, exception, values); + /// + /// Log a templated message to the in-game debug log. + /// + /// The log level to log with. + /// The message template to log. + /// The exception to log. + /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - private void WriteLog( + public void WriteLog( LogEventLevel level, string messageTemplate, Exception? exception = null, params object?[] values) { // FIXME: Eventually, the `pluginName` tag should be removed from here and moved over to the actual log