add basic console system for debugging

This commit is contained in:
goat 2024-06-07 01:21:13 +02:00
parent e6da98646a
commit 16f0fb76f4
8 changed files with 634 additions and 18 deletions

View file

@ -0,0 +1,27 @@
namespace Dalamud.Console;
/// <summary>
/// Possible console argument types.
/// </summary>
internal enum ConsoleArgumentType
{
/// <summary>
/// A regular string.
/// </summary>
String,
/// <summary>
/// A signed integer.
/// </summary>
Integer,
/// <summary>
/// A floating point value.
/// </summary>
Float,
/// <summary>
/// A boolean value.
/// </summary>
Bool,
}

View file

@ -0,0 +1,44 @@
using System.Collections.Generic;
namespace Dalamud.Console;
/// <summary>
/// Interface representing an entry in the console.
/// </summary>
public interface IConsoleEntry
{
/// <summary>
/// Gets the name of the entry.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the description of the entry.
/// </summary>
public string Description { get; }
}
/// <summary>
/// Interface representing a command in the console.
/// </summary>
public interface IConsoleCommand : IConsoleEntry
{
/// <summary>
/// Execute this command.
/// </summary>
/// <param name="arguments">Arguments to invoke the entry with.</param>
/// <returns>Whether or not execution succeeded.</returns>
public bool Invoke(IEnumerable<object> arguments);
}
/// <summary>
/// Interface representing a variable in the console.
/// </summary>
/// <typeparam name="T">The type of the variable</typeparam>
public interface IConsoleVariable<T> : IConsoleEntry
{
/// <summary>
/// Gets or sets the value of this variable.
/// </summary>
T Value { get; set; }
}

View file

@ -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<bool, T1, T2, ...> for commands?
/// <summary>
/// Class managing console commands and variables.
/// </summary>
[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<string, IConsoleEntry> entries = new();
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleManager"/> class.
/// </summary>
[ServiceManager.ServiceConstructor]
public ConsoleManager()
{
}
/// <summary>
/// Event that is triggered when a command is processed. Return true to stop the command from being processed any further.
/// </summary>
public event Func<string, bool>? Invoke;
/// <summary>
/// Gets a read-only dictionary of console entries.
/// </summary>
public IReadOnlyDictionary<string, IConsoleEntry> Entries => this.entries;
/// <summary>
/// Add a command to the console.
/// </summary>
/// <param name="name">The name of the command.</param>
/// <param name="description">A description of the command.</param>
/// <param name="func">Function to invoke when the command has been called. Must return a <see cref="bool"/> indicating success.</param>
/// <returns>The added command.</returns>
/// <exception cref="InvalidOperationException">Thrown if the command already exists.</exception>
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;
}
/// <summary>
/// Add a variable to the console.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="description">A description of the variable.</param>
/// <param name="defaultValue">The default value of the variable.</param>
/// <typeparam name="T">The type of the variable.</typeparam>
/// <returns>The added variable.</returns>
/// <exception cref="InvalidOperationException">Thrown if the variable already exists.</exception>
public IConsoleVariable<T> AddVariable<T>(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<T>(name, description);
variable.Value = defaultValue;
this.entries.Add(name, variable);
return variable;
}
/// <summary>
/// Add an alias to a console entry.
/// </summary>
/// <param name="name">The name of the entry to add an alias for.</param>
/// <param name="alias">The alias to use.</param>
/// <returns>The added alias.</returns>
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;
}
/// <summary>
/// Remove an entry from the console.
/// </summary>
/// <param name="entry">The entry to remove.</param>
public void RemoveEntry(IConsoleEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
}
/// <summary>
/// Get the value of a variable.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <typeparam name="T">The type of the variable.</typeparam>
/// <returns>The value of the variable.</returns>
/// <exception cref="EntryNotFoundException">Thrown if the variable could not be found.</exception>
/// <exception cref="InvalidOperationException">Thrown if the found console entry is not of the expected type.</exception>
public T GetVariable<T>(string name)
{
ArgumentNullException.ThrowIfNull(name);
var entry = this.FindEntry(name);
if (entry is ConsoleVariable<T> 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.");
}
/// <summary>
/// Set the value of a variable.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="value">The value to set.</param>
/// <typeparam name="T">The type of the value to set.</typeparam>
/// <exception cref="InvalidOperationException">Thrown if the found console entry is not of the expected type.</exception>
/// <exception cref="EntryNotFoundException">Thrown if the variable could not be found.</exception>
public void SetVariable<T>(string name, T value)
{
ArgumentNullException.ThrowIfNull(name);
Traits.ThrowIfTIsNullableAndNull(value);
var entry = this.FindEntry(name);
if (entry is ConsoleVariable<T> 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.");
}
/// <summary>
/// Process a console command.
/// </summary>
/// <param name="command">The command to process.</param>
/// <returns>Whether or not the command was successfully processed.</returns>
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<object>();
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<string>()));
}
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>(T? argument, [CallerArgumentExpression("argument")] string? paramName = null)
{
if (argument == null && !typeof(T).IsValueType)
throw new ArgumentNullException(paramName);
}
}
/// <summary>
/// Class representing an entry in the console.
/// </summary>
private abstract class ConsoleEntry : IConsoleEntry
{
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleEntry"/> class.
/// </summary>
/// <param name="name">The name of the entry.</param>
/// <param name="description">A description of the entry.</param>
public ConsoleEntry(string name, string description)
{
this.Name = name;
this.Description = description;
}
/// <inheritdoc/>
public string Name { get; }
/// <inheritdoc/>
public string Description { get; }
/// <summary>
/// Gets or sets a list of valid argument types for this console entry.
/// </summary>
public IReadOnlyList<ArgumentInfo>? ValidArguments { get; protected set; }
/// <summary>
/// Execute this command.
/// </summary>
/// <param name="arguments">Arguments to invoke the entry with.</param>
/// <returns>Whether or not execution succeeded.</returns>
public abstract bool Invoke(IEnumerable<object> arguments);
/// <summary>
/// Get an instance of <see cref="ArgumentInfo"/> for a given type.
/// </summary>
/// <param name="type">The type of the argument.</param>
/// <param name="defaultValue">The default value to use if none is specified.</param>
/// <returns>An <see cref="ArgumentInfo"/> instance.</returns>
/// <exception cref="ArgumentException">Thrown if the given type cannot be handled by the console system.</exception>
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);
}
/// <summary>
/// Class representing an alias to another console entry.
/// </summary>
private class ConsoleAlias : ConsoleEntry
{
private readonly ConsoleEntry target;
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleAlias"/> class.
/// </summary>
/// <param name="name">The name of the alias.</param>
/// <param name="target">The target entry to alias to.</param>
public ConsoleAlias(string name, ConsoleEntry target)
: base(name, target.Description)
{
this.target = target;
this.ValidArguments = target.ValidArguments;
}
/// <inheritdoc/>
public override bool Invoke(IEnumerable<object> arguments)
{
return this.target.Invoke(arguments);
}
}
/// <summary>
/// Class representing a console command.
/// </summary>
private class ConsoleCommand : ConsoleEntry, IConsoleCommand
{
private Delegate func;
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleCommand"/> class.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="description">A description of the variable.</param>
/// <param name="func">The function to invoke.</param>
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<ArgumentInfo>();
foreach (var parameterInfo in func.Method.GetParameters())
{
var paraT = parameterInfo.ParameterType;
validArguments.Add(TypeToArgument(paraT, parameterInfo.DefaultValue));
}
this.ValidArguments = validArguments;
}
/// <inheritdoc cref="ConsoleEntry.Invoke" />
public override bool Invoke(IEnumerable<object> arguments)
{
this.func.DynamicInvoke(arguments.ToArray());
return true;
}
}
/// <summary>
/// Class representing a basic console variable.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="description">A description of the variable.</param>
private abstract class ConsoleVariable(string name, string description) : ConsoleEntry(name, description);
/// <summary>
/// Class representing a generic console variable.
/// </summary>
/// <typeparam name="T">The type of the variable.</typeparam>
private class ConsoleVariable<T> : ConsoleVariable, IConsoleVariable<T>
{
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleVariable{T}"/> class.
/// </summary>
/// <param name="name">The name of the variable.</param>
/// <param name="description">A description of the variable.</param>
public ConsoleVariable(string name, string description)
: base(name, description)
{
this.ValidArguments = new List<ArgumentInfo> { TypeToArgument(typeof(T)) };
}
/// <inheritdoc/>
public T Value { get; set; }
/// <inheritdoc/>
public override bool Invoke(IEnumerable<object> 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;
}
}
}
/// <summary>
/// Exception thrown when a console entry is not found.
/// </summary>
internal class EntryNotFoundException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="EntryNotFoundException"/> class.
/// </summary>
/// <param name="name">The name of the entry.</param>
public EntryNotFoundException(string name)
: base($"Console entry '{name}' does not exist.")
{
}
}

View file

@ -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<ChatGui>.Get();
[ServiceManager.ServiceDependency]
private readonly ConsoleManager console = Service<ConsoleManager>.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;
}
/// <inheritdoc/>
@ -132,8 +137,14 @@ internal sealed class CommandManager : IInternalDisposableService, ICommandManag
/// <inheritdoc/>
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)
{

View file

@ -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,

View file

@ -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<Framework>.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate);
var cm = Service<ConsoleManager>.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<CommandManager>.Get().ProcessCommand("/" + this.commandText);
this.lastCmdSuccess = Service<ConsoleManager>.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<CommandManager>.Get().Commands
.Where(x => x.Key.Contains("/" + words[0]))
.ToList();
if (candidates.Count > 0)
var candidates = Service<ConsoleManager>.Get().Entries
.Where(x => x.Key.StartsWith(words[0]))
.Select(x => x.Key);
candidates = candidates.Union(
Service<CommandManager>.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;

View file

@ -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<Guid, InOutCubic> shadeEasings = new();
private readonly Dictionary<Guid, InOutQuint> moveEasings = new();
private readonly Dictionary<Guid, InOutCubic> logoEasings = new();
private readonly IConsoleVariable<bool> showTsm;
private InOutCubic? fadeOutEasing;
@ -55,7 +58,8 @@ internal class TitleScreenMenuWindow : Window, IDisposable
/// <param name="fontAtlasFactory">An instance of <see cref="FontAtlasFactory"/>.</param>
/// <param name="framework">An instance of <see cref="Framework"/>.</param>
/// <param name="titleScreenMenu">An instance of <see cref="TitleScreenMenu"/>.</param>
/// <param name="gameGui">An instance of <see cref="gameGui"/>.</param>
/// <param name="gameGui">An instance of <see cref="GameGui"/>.</param>
/// <param name="consoleManager">An instance of <see cref="ConsoleManager"/>.</param>
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
/// <inheritdoc/>
public override void Draw()
{
if (!this.AllowDrawing)
if (!this.AllowDrawing || !this.showTsm.Value)
return;
var scale = ImGui.GetIO().FontGlobalScale;

View file

@ -141,8 +141,15 @@ public class ModuleLog
public void Fatal(Exception? exception, string messageTemplate, params object?[] values)
=> this.WriteLog(LogEventLevel.Fatal, messageTemplate, exception, values);
/// <summary>
/// Log a templated message to the in-game debug log.
/// </summary>
/// <param name="level">The log level to log with.</param>
/// <param name="messageTemplate">The message template to log.</param>
/// <param name="exception">The exception to log.</param>
/// <param name="values">Values to log.</param>
[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