diff --git a/managed/src/SwiftlyS2.Core/Bootstrap.cs b/managed/src/SwiftlyS2.Core/Bootstrap.cs index b2b3b6d38..dcc9a9218 100644 --- a/managed/src/SwiftlyS2.Core/Bootstrap.cs +++ b/managed/src/SwiftlyS2.Core/Bootstrap.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Hosting; using SwiftlyS2.Core.Misc; using Microsoft.Extensions.Configuration; -using SwiftlyS2.Core.Modules.Engine; using SwiftlyS2.Shared.Memory; using SwiftlyS2.Shared.Services; namespace SwiftlyS2.Core; @@ -60,13 +59,10 @@ public static void Start(IntPtr nativeTable, int nativeTableSize, string basePat .AddPlayerManagerService() .AddPluginManager() .AddHookManager() - .AddEngineService() .AddTraceManagerService() - .AddCoreCommandService() .AddPermissionManager() .AddCoreHookService() .AddMenuService() - .AddSingleton() .AddSwiftlyCore(basePath); }) .Build(); diff --git a/managed/src/SwiftlyS2.Core/Hosting/EngineServiceInjection.cs b/managed/src/SwiftlyS2.Core/Hosting/EngineServiceInjection.cs deleted file mode 100644 index f408881e3..000000000 --- a/managed/src/SwiftlyS2.Core/Hosting/EngineServiceInjection.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using SwiftlyS2.Core.Services; - -namespace SwiftlyS2.Core.Hosting; - -internal static class EngineServiceInjection -{ - public static IServiceCollection AddEngineService(this IServiceCollection self) - { - self.AddSingleton(); - return self; - } -} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputCallback.cs b/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputCallback.cs deleted file mode 100644 index 1b46d074d..000000000 --- a/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputCallback.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Runtime.InteropServices; -using SwiftlyS2.Core.Natives; -using SwiftlyS2.Shared.ConsoleOutput; -using Microsoft.Extensions.Logging; -using SwiftlyS2.Shared.Profiler; - -namespace SwiftlyS2.Core.ConsoleOutput; - -internal delegate void ConsoleOutputListenerCallbackDelegate(nint message); - -internal abstract class ConsoleOutputCallbackBase : IDisposable -{ - public Guid Guid { get; protected init; } - public IContextedProfilerService Profiler { get; } - public ILoggerFactory LoggerFactory { get; } - - protected ConsoleOutputCallbackBase(ILoggerFactory loggerFactory, IContextedProfilerService profiler) - { - LoggerFactory = loggerFactory; - Profiler = profiler; - } - - public abstract void Dispose(); -} - -internal class ConsoleOutputListenerCallback : ConsoleOutputCallbackBase -{ - private IConsoleOutputService.ConsoleOutputHandler _handler; - private ConsoleOutputListenerCallbackDelegate _unmanagedCallback; - private nint _unmanagedCallbackPtr; - private ulong _nativeListenerId; - private ILogger _logger; - - public ConsoleOutputListenerCallback(IConsoleOutputService.ConsoleOutputHandler handler, ILoggerFactory loggerFactory, IContextedProfilerService profiler) - : base(loggerFactory, profiler) - { - _logger = LoggerFactory.CreateLogger(); - Guid = Guid.NewGuid(); - - _handler = handler; - - _unmanagedCallback = (messagePtr) => - { - try - { - var category = "ConsoleOutputListenerCallback"; - Profiler.StartRecording(category); - var messageString = Marshal.PtrToStringUTF8(messagePtr)!; - _handler(messageString); - Profiler.StopRecording(category); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to handle console output listener."); - } - }; - - _unmanagedCallbackPtr = Marshal.GetFunctionPointerForDelegate(_unmanagedCallback); - _nativeListenerId = NativeConsoleOutput.AddConsoleListener(_unmanagedCallbackPtr); - } - - public override void Dispose() - { - try - { - NativeConsoleOutput.RemoveConsoleListener(_nativeListenerId); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to unregister console output listener."); - } - - GC.SuppressFinalize(this); - } -} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputService.cs b/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputService.cs index 79f6ee266..4eeba9eb9 100644 --- a/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputService.cs +++ b/managed/src/SwiftlyS2.Core/Modules/ConsoleOutput/ConsoleOutputService.cs @@ -7,45 +7,8 @@ namespace SwiftlyS2.Core.ConsoleOutput; internal class ConsoleOutputService : IConsoleOutputService, IDisposable { - private List _callbacks = new(); - private ILogger _logger { get; init; } - private ILoggerFactory _loggerFactory { get; init; } - private IContextedProfilerService _profiler { get; init; } - - private object _lock = new(); - - public ConsoleOutputService(ILogger logger, ILoggerFactory loggerFactory, IContextedProfilerService profiler) - { - _logger = logger; - _loggerFactory = loggerFactory; - _profiler = profiler; - } - - public Guid RegisterConsoleOutputListener(IConsoleOutputService.ConsoleOutputHandler handler) - { - var callback = new ConsoleOutputListenerCallback(handler, _loggerFactory, _profiler); - lock (_lock) - { - _callbacks.Add(callback); - } - - return callback.Guid; - } - - public void UnregisterConsoleOutputListener(Guid guid) + public ConsoleOutputService() { - lock (_lock) - { - _callbacks.RemoveAll(callback => - { - if (callback.Guid == guid) - { - callback.Dispose(); - return true; - } - return false; - }); - } } public bool IsFilterEnabled() @@ -80,14 +43,6 @@ public void WriteToServerConsole(string message) public void Dispose() { - lock (_lock) - { - foreach (var callback in _callbacks) - { - callback.Dispose(); - } - _callbacks.Clear(); - } GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Engine/CommandTracked.cs b/managed/src/SwiftlyS2.Core/Modules/Engine/CommandTracked.cs deleted file mode 100644 index 6788fcdd3..000000000 --- a/managed/src/SwiftlyS2.Core/Modules/Engine/CommandTracked.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Concurrent; -using System.Runtime.InteropServices; -using SwiftlyS2.Shared; -using SwiftlyS2.Core.Natives; -using SwiftlyS2.Shared.Memory; -using SwiftlyS2.Shared.Services; -using SwiftlyS2.Shared.ConsoleOutput; - -namespace SwiftlyS2.Core.Modules.Engine; - -internal sealed class CommandTracked : IDisposable -{ - /* - Original function in engine2.dll: __int64 sub_1C0CD0(__int64 a1, int a2, unsigned int a3, ...) - This is a variadic function, but we only need the first two variable arguments (v55, v57) - - __int64 sub_1C0CD0(__int64 a1, int a2, unsigned int a3, ...) - { - ... - - va_list va; // [rsp+D28h] [rbp+D28h] - __int64 v55; // [rsp+E28h] [rbp+D28h] BYREF - va_list va1; // [rsp+E28h] [rbp+D28h] - - ... - - va_start(va1, a3); - va_start(va, a3); - v55 = va_arg(va1, _QWORD); - v57 = va_arg(va1, _QWORD); - - ... - } - - So we model it as a fixed 5-parameter function for interop purposes - */ - [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - private delegate nint ExecuteCommandDelegate(nint a1, int a2, uint a3, nint a4, nint a5); - - private sealed record CommandIdContainer(Guid Value) - { - public static readonly CommandIdContainer Empty = new(Guid.Empty); - } - - private readonly record struct ExecutingCommand(Action Callback) - { - public ConcurrentQueue Output { get; } = new(); - public DateTime Created { get; } = DateTime.UtcNow; - public bool IsExpired => DateTime.UtcNow - Created > TimeSpan.FromMilliseconds(5000); - } - - private volatile bool disposed; - private readonly CancellationTokenSource cancellationTokenSource; - private readonly ConcurrentQueue> pendingCallbacks; - private readonly ConcurrentDictionary activeCommands; - private readonly IMemoryService memoryService; - private readonly IConsoleOutputService consoleOutputService; - private readonly IGameDataService gameDataService; - private volatile CommandIdContainer currentCommandContainer; - private volatile bool hooksInitialized; - private readonly object initializationLock = new(); - private static readonly object commandNameOffsetLock = new(); - private Guid hookId; - private Guid outputListenerId; - private IUnmanagedFunction? executeCommandHook; - private static int commandNameOffset = 0; - - public CommandTracked(ISwiftlyCore swiftlyCore) - { - memoryService = swiftlyCore.Memory; - consoleOutputService = swiftlyCore.ConsoleOutput; - gameDataService = swiftlyCore.GameData; - pendingCallbacks = new ConcurrentQueue>(); - activeCommands = new ConcurrentDictionary(); - cancellationTokenSource = new CancellationTokenSource(); - currentCommandContainer = CommandIdContainer.Empty; - lock (commandNameOffsetLock) - { - if (commandNameOffset == 0) - { - commandNameOffset = NativeOffsets.Fetch("CommandNameOffset"); - } - } - - StartCleanupTimer(); - } - - private void EnsureHooksInitialized() - { - if (hooksInitialized || gameDataService == null) return; - - lock (initializationLock) - { - if (hooksInitialized) return; - - - var signature = gameDataService.GetSignature("Cmd_ExecuteCommand"); - if (signature == nint.Zero) return; - - executeCommandHook = memoryService.GetUnmanagedFunctionByAddress(signature); - hookId = executeCommandHook.AddHook((next) => - { - return (a1, a2, a3, a4, a5) => - { - ProcessCommandStart(a5); - var result = next()(a1, a2, a3, a4, a5); - ProcessCommandEnd(); - return result; - }; - }); - - outputListenerId = consoleOutputService.RegisterConsoleOutputListener((message) => - { - if (disposed) return; - - var commandId = currentCommandContainer?.Value ?? Guid.Empty; - if (commandId == Guid.Empty) return; - - if (activeCommands.TryGetValue(commandId, out var command) && command.Output.Count < 100) - { - command.Output.Enqueue(message); - } - }); - - Thread.MemoryBarrier(); - hooksInitialized = true; - } - } - - private void ProcessCommandStart(nint a5) - { - var commandNamePtr = GetCommandNamePointer(a5); - if (commandNamePtr == nint.Zero) return; - - var commandStr = Marshal.PtrToStringAnsi(commandNamePtr); - if (string.IsNullOrEmpty(commandStr) || !commandStr.Contains("^wb^")) - { - Interlocked.Exchange(ref currentCommandContainer, CommandIdContainer.Empty); - return; - } - - if (pendingCallbacks.TryDequeue(out var callback)) - { - var newCommandId = Guid.NewGuid(); - var newCommand = new ExecutingCommand(callback); - - if (activeCommands.TryAdd(newCommandId, newCommand)) - { - var newContainer = new CommandIdContainer(newCommandId); - Interlocked.Exchange(ref currentCommandContainer, newContainer); - CleanCommandName(commandNamePtr, commandStr); - } - } - else - { - Interlocked.Exchange(ref currentCommandContainer, CommandIdContainer.Empty); - } - } - - private void ProcessCommandEnd() - { - var previousContainer = Interlocked.Exchange(ref currentCommandContainer, CommandIdContainer.Empty); - var commandId = previousContainer?.Value ?? Guid.Empty; - - if (commandId != Guid.Empty && activeCommands.TryRemove(commandId, out var command)) - { - var output = new StringBuilder(); - while (command.Output.TryDequeue(out var line)) - { - if (output.Length > 0) output.AppendLine(); - output.Append(line); - } - - Task.Run(() => - { - command.Callback?.Invoke(output.ToString()); - }); - } - } - - private static nint GetCommandNamePointer(nint a5) - { - if (a5 == nint.Zero || a5 >= nint.MaxValue || commandNameOffset == 0) return nint.Zero; - var basePtr = Marshal.ReadIntPtr(new nint(a5 + commandNameOffset)); - - if (basePtr == nint.Zero || basePtr >= nint.MaxValue) return nint.Zero; - return Marshal.ReadIntPtr(basePtr); - } - - private static void CleanCommandName(nint commandPtr, string commandStr) - { - var cleanedCommand = commandStr.Replace("^wb^", string.Empty); - if (cleanedCommand.Length >= commandStr.Length) return; - - var cleanedBytes = Encoding.ASCII.GetBytes(cleanedCommand + "\0"); - var maxLength = Encoding.ASCII.GetByteCount(commandStr + "\0"); - - if (cleanedBytes.Length <= maxLength) - { - Marshal.Copy(cleanedBytes, 0, commandPtr, cleanedBytes.Length); - } - } - - private void StartCleanupTimer() - { - Task.Run(async () => - { - while (!cancellationTokenSource.Token.IsCancellationRequested) - { - try - { - await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationTokenSource.Token); - CleanupExpiredCommands(); - } - catch { } - } - }, cancellationTokenSource.Token); - } - - private void CleanupExpiredCommands() - { - foreach (var kvp in activeCommands.ToArray()) - { - if (kvp.Value.IsExpired) - { - activeCommands.TryRemove(kvp.Key, out _); - } - } - } - - public void EnqueueCommand(Action callback) - { - if (disposed || callback == null) return; - - EnsureHooksInitialized(); - - pendingCallbacks.Enqueue(callback); - } - - public void Dispose() - { - if (disposed) return; - disposed = true; - - cancellationTokenSource.Cancel(); - executeCommandHook?.RemoveHook(hookId); - consoleOutputService.UnregisterConsoleOutputListener(outputListenerId); - - while (pendingCallbacks.TryDequeue(out _)) { } - activeCommands.Clear(); - - cancellationTokenSource.Dispose(); - } -} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Engine/CommandTrackedService.cs b/managed/src/SwiftlyS2.Core/Modules/Engine/CommandTrackedService.cs new file mode 100644 index 000000000..13a55a2b2 --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Modules/Engine/CommandTrackedService.cs @@ -0,0 +1,177 @@ +using System.Text; +using System.Collections.Concurrent; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.Events; +using SwiftlyS2.Shared.Services; + +namespace SwiftlyS2.Core.Services; + +internal sealed class CommandTrackedService : ICommandTrackedService, IDisposable +{ + private sealed record CommandIdContainer(Guid Value) + { + public static readonly CommandIdContainer Empty = new(Guid.Empty); + } + + private readonly record struct ExecutingCommand(Action Callback) + { + public ConcurrentQueue Output { get; } = new(); + public DateTime Created { get; } = DateTime.UtcNow; + public bool IsExpired => DateTime.UtcNow - Created > TimeSpan.FromMilliseconds(5000); + } + + private volatile CommandIdContainer currentCommandContainer; + private readonly ConcurrentDictionary activeCommands; + private readonly CancellationTokenSource cancellationTokenSource; + private readonly ConcurrentQueue> pendingCallbacks; + private readonly IEventSubscriber eventSubscriber; + private volatile bool disposed; + private volatile bool eventsSubscribed; + + public CommandTrackedService(IEventSubscriber eventSubscriber) + { + this.eventSubscriber = eventSubscriber; + pendingCallbacks = new ConcurrentQueue>(); + activeCommands = new ConcurrentDictionary(); + cancellationTokenSource = new CancellationTokenSource(); + currentCommandContainer = CommandIdContainer.Empty; + eventsSubscribed = false; + + StartCleanupTimer(); + } + + private void ProcessCommand(IOnCommandExecuteHookEvent @event) + { + if (string.IsNullOrEmpty(@event.OriginalName) || !@event.OriginalName.Contains("^wb^")) + { + Interlocked.Exchange(ref currentCommandContainer, CommandIdContainer.Empty); + return; + } + + if (@event.HookMode == HookMode.Pre) + { + ProcessCommandStart(@event); + } + else if (@event.HookMode == HookMode.Post) + { + ProcessCommandEnd(@event); + } + } + + private void ProcessOutput(IOnConsoleOutputEvent @event) + { + if (disposed) return; + + var commandId = currentCommandContainer?.Value ?? Guid.Empty; + if (commandId == Guid.Empty) return; + + if (activeCommands.TryGetValue(commandId, out var command) && command.Output.Count < 100) + { + command.Output.Enqueue(@event.Message); + } + } + + private void ProcessCommandStart(IOnCommandExecuteHookEvent @event) + { + if (pendingCallbacks.TryDequeue(out var callback)) + { + var newCommandId = Guid.NewGuid(); + var newCommand = new ExecutingCommand(callback); + + if (activeCommands.TryAdd(newCommandId, newCommand)) + { + var newContainer = new CommandIdContainer(newCommandId); + Interlocked.Exchange(ref currentCommandContainer, newContainer); + @event.SetCommandName(@event.OriginalName.Replace("^wb^", string.Empty)); + } + } + else + { + Interlocked.Exchange(ref currentCommandContainer, CommandIdContainer.Empty); + } + } + + private void ProcessCommandEnd(IOnCommandExecuteHookEvent @event) + { + var previousContainer = Interlocked.Exchange(ref currentCommandContainer, CommandIdContainer.Empty); + var commandId = previousContainer?.Value ?? Guid.Empty; + + if (commandId != Guid.Empty && activeCommands.TryRemove(commandId, out var command)) + { + var output = new StringBuilder(); + while (command.Output.TryDequeue(out var line)) + { + if (output.Length > 0) output.AppendLine(); + output.Append(line); + } + + Task.Run(() => + { + command.Callback?.Invoke(output.ToString()); + }); + } + } + + private void StartCleanupTimer() + { + Task.Run(async () => + { + while (!cancellationTokenSource.Token.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationTokenSource.Token); + CleanupExpiredCommands(); + } + catch { } + } + }, cancellationTokenSource.Token); + } + + private void CleanupExpiredCommands() + { + foreach (var kvp in activeCommands.ToArray()) + { + if (kvp.Value.IsExpired) + { + activeCommands.TryRemove(kvp.Key, out _); + } + } + } + + private void EnsureEventsSubscribed() + { + lock (this) + { + if (eventsSubscribed || disposed) return; + + eventSubscriber.OnCommandExecuteHook += ProcessCommand; + eventSubscriber.OnConsoleOutput += ProcessOutput; + eventsSubscribed = true; + } + } + + public void EnqueueCommand(Action callback) + { + if (disposed || callback == null) return; + + EnsureEventsSubscribed(); + pendingCallbacks.Enqueue(callback); + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + + cancellationTokenSource.Cancel(); + eventSubscriber.OnCommandExecuteHook -= ProcessCommand; + eventSubscriber.OnConsoleOutput -= ProcessOutput; + + while (pendingCallbacks.TryDequeue(out _)) { } + activeCommands.Clear(); + + cancellationTokenSource.Dispose(); + } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs b/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs index 1e39da134..1afbd767d 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Engine/EngineService.cs @@ -1,17 +1,16 @@ +using SwiftlyS2.Shared; using SwiftlyS2.Core.Natives; using SwiftlyS2.Shared.Services; -using SwiftlyS2.Core.Modules.Engine; -using Microsoft.Extensions.DependencyInjection; namespace SwiftlyS2.Core.Services; internal class EngineService : IEngineService { - private readonly IServiceProvider serviceProvider; + private readonly ICommandTrackedService commandTrackedService; - public EngineService(IServiceProvider serviceProvider) + public EngineService(ICommandTrackedService commandTrackedService) { - this.serviceProvider = serviceProvider; + this.commandTrackedService = commandTrackedService; } public string ServerIP => NativeEngineHelpers.GetServerIP(); @@ -31,13 +30,8 @@ public void ExecuteCommand(string command) public void ExecuteCommandWithBuffer(string command, Action bufferCallback) { - try - { - var commandTracked = serviceProvider.GetService(); - commandTracked?.EnqueueCommand(bufferCallback); - NativeEngineHelpers.ExecuteCommand($"{command}^wb^"); - } - catch { } + commandTrackedService?.EnqueueCommand(bufferCallback); + NativeEngineHelpers.ExecuteCommand($"{command}^wb^"); } public bool IsMapValid(string map) diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnCommandExecuteHookEvent.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnCommandExecuteHookEvent.cs new file mode 100644 index 000000000..981a1cfd6 --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnCommandExecuteHookEvent.cs @@ -0,0 +1,20 @@ +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.Events; + +namespace SwiftlyS2.Core.Events; + +internal class OnCommandExecuteHookEvent : IOnCommandExecuteHookEvent +{ + public required string OriginalName { get; init; } + public string CommandName { get; set; } = string.Empty; + + public required HookMode HookMode { get; init; } + + public bool Intercepted { get; set; } = false; + + public void SetCommandName(string name) { + if (HookMode == HookMode.Post) return; + CommandName = name; + Intercepted = true; + } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnConsoleOutputEvent.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnConsoleOutputEvent.cs new file mode 100644 index 000000000..a9e8ece04 --- /dev/null +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventParams/OnConsoleOutputEvent.cs @@ -0,0 +1,9 @@ +using SwiftlyS2.Shared.Events; + +namespace SwiftlyS2.Core.Events; + +internal class OnConsoleOutputEvent : IOnConsoleOutputEvent +{ + + public required string Message { get; set; } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs index f6e538d84..210f80223 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventPublisher.cs @@ -43,10 +43,10 @@ public static void Register() { NativeEvents.RegisterOnClientProcessUsercmdsCallback((nint)(delegate* unmanaged)&OnClientProcessUsercmds); NativeEvents.RegisterOnEntityTakeDamageCallback((nint)(delegate* unmanaged)&OnEntityTakeDamage); NativeEvents.RegisterOnPrecacheResourceCallback((nint)(delegate* unmanaged)&OnPrecacheResource); + NativeConsoleOutput.AddConsoleListener((nint)(delegate* unmanaged)&OnConsoleOutput); } } - [UnmanagedCallersOnly] public static void OnTick(byte simulating, byte first, byte last) { @@ -376,4 +376,30 @@ public static void InvokeOnCanAcquireHook(OnItemServicesCanAcquireHookEvent @eve return; } } + + [UnmanagedCallersOnly] + public static void OnConsoleOutput(nint messagePtr) { + if (_subscribers.Count == 0) return; + try { + OnConsoleOutputEvent @event = new() { + Message = Marshal.PtrToStringUTF8(messagePtr) ?? string.Empty + }; + foreach (var subscriber in _subscribers) { + subscriber.InvokeOnConsoleOutput(@event); + } + } catch (Exception e) { + AnsiConsole.WriteException(e); + } + } + + public static void InvokeOnCommandExecuteHook(OnCommandExecuteHookEvent @event) { + if (_subscribers.Count == 0) return; + try { + foreach (var subscriber in _subscribers) { + subscriber.InvokeOnCommandExecuteHook(@event); + } + } catch (Exception e) { + AnsiConsole.WriteException(e); + } + } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs b/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs index 56fcb1c2a..bfa11f77d 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Events/EventSubscriber.cs @@ -42,6 +42,8 @@ public EventSubscriber(CoreContext id, IContextedProfilerService profiler, ILogg public event EventDelegates.OnEntityTakeDamage? OnEntityTakeDamage; public event EventDelegates.OnPrecacheResource? OnPrecacheResource; public event EventDelegates.OnItemServicesCanAcquireHook? OnItemServicesCanAcquireHook; + public event EventDelegates.OnConsoleOutput? OnConsoleOutput; + public event EventDelegates.OnCommandExecuteHook? OnCommandExecuteHook; public void Dispose() { EventPublisher.Unsubscribe(this); @@ -251,4 +253,28 @@ public void InvokeOnItemServicesCanAcquireHook(OnItemServicesCanAcquireHookEvent _Profiler.StopRecording("Event::OnItemServicesCanAcquireHook"); } } + + public void InvokeOnConsoleOutput(OnConsoleOutputEvent @event) { + try { + if (OnConsoleOutput == null) return; + _Profiler.StartRecording("Event::OnConsoleOutput"); + OnConsoleOutput?.Invoke(@event); + } catch (Exception e) { + _Logger.LogError(e, "Error invoking OnConsoleOutput."); + } finally { + _Profiler.StopRecording("Event::OnConsoleOutput"); + } + } + + public void InvokeOnCommandExecuteHook(OnCommandExecuteHookEvent @event) { + try { + if (OnCommandExecuteHook == null) return; + _Profiler.StartRecording("Event::OnCommandExecuteHook"); + OnCommandExecuteHook?.Invoke(@event); + } catch (Exception e) { + _Logger.LogError(e, "Error invoking OnCommandExecuteHook."); + } finally { + _Profiler.StopRecording("Event::OnCommandExecuteHook"); + } + } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs b/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs index 4279f6b8f..da443d35a 100644 --- a/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs +++ b/managed/src/SwiftlyS2.Core/Modules/Plugins/SwiftlyCore.cs @@ -52,6 +52,7 @@ internal class SwiftlyCore : ISwiftlyCore, IDisposable public PluginConfigurationService Configuration { get; init; } public ILoggerFactory LoggerFactory { get; init; } public CommandService CommandService { get; init; } + public CommandTrackedService CommandTrackerService { get; init; } public ConsoleOutputService ConsoleOutputService { get; init; } public EntitySystemService EntitySystemService { get; init; } public ConVarService ConVarService { get; init; } @@ -81,19 +82,21 @@ public SwiftlyCore(string contextId, string contextBaseDirectory, PluginMetadata services .AddSingleton(id) .AddSingleton(this) + .AddSingleton(this) .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) - .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton(coreProvider.GetRequiredService()) .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -113,6 +116,7 @@ public SwiftlyCore(string contextId, string contextBaseDirectory, PluginMetadata .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) + .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()) @@ -145,6 +149,7 @@ public SwiftlyCore(string contextId, string contextBaseDirectory, PluginMetadata LoggerFactory = _ServiceProvider.GetRequiredService(); NetMessageService = _ServiceProvider.GetRequiredService(); CommandService = _ServiceProvider.GetRequiredService(); + CommandTrackerService = _ServiceProvider.GetRequiredService(); ConsoleOutputService = _ServiceProvider.GetRequiredService(); EntitySystemService = _ServiceProvider.GetRequiredService(); GameDataService = _ServiceProvider.GetRequiredService(); @@ -185,6 +190,7 @@ public void Dispose() IGameEventService ISwiftlyCore.GameEvent => GameEventService; INetMessageService ISwiftlyCore.NetMessage => NetMessageService; ICommandService ISwiftlyCore.Command => CommandService; + ICommandTrackedService ISwiftlyCore.CommandTracked => CommandTrackerService; IConsoleOutputService ISwiftlyCore.ConsoleOutput => ConsoleOutputService; IEntitySystemService ISwiftlyCore.EntitySystem => EntitySystemService; IConVarService ISwiftlyCore.ConVar => ConVarService; diff --git a/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs b/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs index 261330e62..0873454c7 100644 --- a/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs +++ b/managed/src/SwiftlyS2.Core/Services/CoreHookService.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using SwiftlyS2.Core.Events; using SwiftlyS2.Core.Natives; @@ -17,9 +19,37 @@ public CoreHookService(ILogger logger, ISwiftlyCore core) { _Core = core; HookCanAcquire(); + HookCommandExecute(); } private delegate int CanAcquireDelegate(nint pItemServices, nint pEconItemView, nint acquireMethod, nint unk1); + /* + Original function in engine2.dll: __int64 sub_1C0CD0(__int64 a1, int a2, unsigned int a3, ...) + This is a variadic function, but we only need the first two variable arguments (v55, v57) + + __int64 sub_1C0CD0(__int64 a1, int a2, unsigned int a3, ...) + { + ... + + va_list va; // [rsp+D28h] [rbp+D28h] + __int64 v55; // [rsp+E28h] [rbp+D28h] BYREF + va_list va1; // [rsp+E28h] [rbp+D28h] + + ... + + va_start(va1, a3); + va_start(va, a3); + v55 = va_arg(va1, _QWORD); + v57 = va_arg(va1, _QWORD); + + ... + } + + So we model it as a fixed 5-parameter function for interop purposes + */ + private delegate nint ExecuteCommandDelegate(nint a1, int a2, uint a3, nint a4, nint a5); + private IUnmanagedFunction? _ExecuteCommand; + private Guid _ExecuteCommandGuid; private IUnmanagedFunction? _CanAcquire; private Guid _CanAcquireGuid; @@ -58,7 +88,54 @@ private void HookCanAcquire() { }); } + private void HookCommandExecute() { + + var address = _Core.GameData.GetSignature("Cmd_ExecuteCommand"); + + _Logger.LogInformation("Hooking Cmd_ExecuteCommand at {Address}", address); + var commandNameOffset = NativeOffsets.Fetch("CommandNameOffset"); + + _ExecuteCommand = _Core.Memory.GetUnmanagedFunctionByAddress(address); + _ExecuteCommandGuid = _ExecuteCommand.AddHook((next) => + { + return (a1, a2, a3, a4, a5) => + { + var (commandName, commandPtr) = (a5 != nint.Zero && a5 < nint.MaxValue && commandNameOffset != 0) switch { + true when Marshal.ReadIntPtr(new nint(a5 + commandNameOffset)) is var basePtr && basePtr != nint.Zero && basePtr < nint.MaxValue + => (Marshal.PtrToStringAnsi(Marshal.ReadIntPtr(basePtr)) ?? string.Empty, Marshal.ReadIntPtr(basePtr)), + _ => (string.Empty, nint.Zero) + }; + + var preEvent = new OnCommandExecuteHookEvent { + OriginalName = commandName, + HookMode = HookMode.Pre + }; + EventPublisher.InvokeOnCommandExecuteHook(preEvent); + + if (preEvent.Intercepted && preEvent.CommandName.Length < commandName.Length) { + var newCommandName = Encoding.ASCII.GetBytes(preEvent.CommandName + "\0"); + var maxLength = Encoding.ASCII.GetByteCount(commandName + "\0"); + + if (newCommandName.Length <= maxLength) { + Marshal.Copy(newCommandName, 0, commandPtr, newCommandName.Length); + } + } + + var result = next()(a1, a2, a3, a4, a5); + + var postEvent = new OnCommandExecuteHookEvent { + OriginalName = commandName, + HookMode = HookMode.Post + }; + EventPublisher.InvokeOnCommandExecuteHook(postEvent); + + return result; + }; + }); + } + public void Dispose() { _CanAcquire!.RemoveHook(_CanAcquireGuid); + _ExecuteCommand!.RemoveHook(_ExecuteCommandGuid); } } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Core/Services/StartupService.cs b/managed/src/SwiftlyS2.Core/Services/StartupService.cs index cc300e45e..da082aea7 100644 --- a/managed/src/SwiftlyS2.Core/Services/StartupService.cs +++ b/managed/src/SwiftlyS2.Core/Services/StartupService.cs @@ -1,21 +1,17 @@ using Microsoft.Extensions.Hosting; using SwiftlyS2.Core.Hosting; using SwiftlyS2.Core.Misc; -using SwiftlyS2.Core.Modules.Engine; namespace SwiftlyS2.Core.Services; internal class StartupService : IHostedService { - private IServiceProvider _provider; - private CommandTracked _commandTracked; + private readonly IServiceProvider _provider; - public StartupService(IServiceProvider provider, CommandTracked commandTracked) + public StartupService(IServiceProvider provider) { _provider = provider; - _commandTracked = commandTracked; - provider.UseCoreCommandService(); provider.UseCoreHookService(); provider.UsePermissionManager(); provider.UsePluginManager(); diff --git a/managed/src/SwiftlyS2.Shared/ISwiftlyCore.cs b/managed/src/SwiftlyS2.Shared/ISwiftlyCore.cs index aa4378f9e..308efa587 100644 --- a/managed/src/SwiftlyS2.Shared/ISwiftlyCore.cs +++ b/managed/src/SwiftlyS2.Shared/ISwiftlyCore.cs @@ -24,8 +24,6 @@ namespace SwiftlyS2.Shared; /// public interface ISwiftlyCore { - - /// /// Custom event subscriber. /// @@ -51,6 +49,11 @@ public interface ISwiftlyCore /// public ICommandService Command { get; } + /// + /// Command tracked service. + /// + public ICommandTrackedService CommandTracked { get; } + /// /// Console output service. /// diff --git a/managed/src/SwiftlyS2.Shared/Modules/ConsoleOutput/IConsoleOutputService.cs b/managed/src/SwiftlyS2.Shared/Modules/ConsoleOutput/IConsoleOutputService.cs index bb3646e83..5cbdd5da1 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/ConsoleOutput/IConsoleOutputService.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/ConsoleOutput/IConsoleOutputService.cs @@ -2,24 +2,24 @@ namespace SwiftlyS2.Shared.ConsoleOutput; public interface IConsoleOutputService { - /// - /// The handler for console output events. - /// - /// The console message. - delegate void ConsoleOutputHandler(string message); - - /// - /// Registers a console output listener. - /// - /// The handler to call when console output occurs. - /// A GUID that can be used to unregister the listener. - Guid RegisterConsoleOutputListener(ConsoleOutputHandler handler); - - /// - /// Unregisters a console output listener. - /// - /// The GUID returned from RegisterConsoleOutputListener. - void UnregisterConsoleOutputListener(Guid guid); + // /// + // /// The handler for console output events. + // /// + // /// The console message. + // delegate void ConsoleOutputHandler(string message); + + // /// + // /// Registers a console output listener. + // /// + // /// The handler to call when console output occurs. + // /// A GUID that can be used to unregister the listener. + // Guid RegisterConsoleOutputListener(ConsoleOutputHandler handler); + + // /// + // /// Unregisters a console output listener. + // /// + // /// The GUID returned from RegisterConsoleOutputListener. + // void UnregisterConsoleOutputListener(Guid guid); /// /// Gets whether console filtering is enabled. diff --git a/managed/src/SwiftlyS2.Shared/Modules/Engine/ICommandTrackerService.cs b/managed/src/SwiftlyS2.Shared/Modules/Engine/ICommandTrackerService.cs new file mode 100644 index 000000000..46027ccf6 --- /dev/null +++ b/managed/src/SwiftlyS2.Shared/Modules/Engine/ICommandTrackerService.cs @@ -0,0 +1,10 @@ +namespace SwiftlyS2.Shared.Services; + +public interface ICommandTrackedService +{ + /// + /// Enqueues a command to be executed. + /// + /// The callback to receive the output of the command. + void EnqueueCommand(Action callback); +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs index 3bd15779f..6321e6452 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/EventDelegates.cs @@ -91,4 +91,14 @@ public class EventDelegates { /// Called when an item services can acquire hook is triggered. /// public delegate void OnItemServicesCanAcquireHook(IOnItemServicesCanAcquireHookEvent @event); + + /// + /// Called when a console output is received. + /// + public delegate void OnConsoleOutput(IOnConsoleOutputEvent @event); + + /// + /// Called when a command is executed. + /// + public delegate void OnCommandExecuteHook(IOnCommandExecuteHookEvent @event); } \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnCommandExecuteHookEvent.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnCommandExecuteHookEvent.cs new file mode 100644 index 000000000..3ad311a2a --- /dev/null +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnCommandExecuteHookEvent.cs @@ -0,0 +1,26 @@ +using SwiftlyS2.Shared.Misc; + +namespace SwiftlyS2.Shared.Events; + +/// +/// Called when a command is executed. +/// +public interface IOnCommandExecuteHookEvent { + + /// + /// The original command name. + /// + public string OriginalName { get; } + + /// + /// The command arguments. + /// + public HookMode HookMode { get; } + + /// + /// Intercept and modify the command name. + /// This will modify the command name and stop the following hooks and original function. + /// + /// The name to modify. + public void SetCommandName(string name); +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnConsoleOutputEvent.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnConsoleOutputEvent.cs new file mode 100644 index 000000000..84ac21031 --- /dev/null +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/EventParams/IOnConsoleOutputEvent.cs @@ -0,0 +1,12 @@ +namespace SwiftlyS2.Shared.Events; + +/// +/// Called when a console output is received. +/// +public interface IOnConsoleOutputEvent { + + /// + /// The message of the console output. + /// + public string Message { get; } +} \ No newline at end of file diff --git a/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs b/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs index eda77af4c..ffcafc58d 100644 --- a/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs +++ b/managed/src/SwiftlyS2.Shared/Modules/Events/IEventSubscriber.cs @@ -91,4 +91,14 @@ public interface IEventSubscriber { /// Called when an item services can acquire hook is triggered. /// public event EventDelegates.OnItemServicesCanAcquireHook? OnItemServicesCanAcquireHook; + + /// + /// Called when the game outputs a console message. + /// + public event EventDelegates.OnConsoleOutput? OnConsoleOutput; + + /// + /// Called when a command is executed. + /// + public event EventDelegates.OnCommandExecuteHook? OnCommandExecuteHook; } \ No newline at end of file diff --git a/managed/src/TestPlugin/TestPlugin.cs b/managed/src/TestPlugin/TestPlugin.cs index 6e8e75687..d6de4ceec 100644 --- a/managed/src/TestPlugin/TestPlugin.cs +++ b/managed/src/TestPlugin/TestPlugin.cs @@ -70,10 +70,16 @@ public void SoundCommand(ICommandContext context) public override void Load(bool hotReload) { - // var consoleListenerId = Core.ConsoleOutput.RegisterConsoleOutputListener((message) => + // Core.Event.OnConsoleOutput += (@event) => // { - // Core.Logger.LogInformation($"message: {message}"); - // }); + // Console.WriteLine($"[TestPlugin] ConsoleOutput: {@event.Message}"); + // }; + + // Core.Event.OnCommandExecuteHook += (@event) => + // { + // Console.WriteLine($"[TestPlugin] CommandExecute({@event.HookMode}): {@event.OriginalName}"); + // @event.SetCommandName("test"); + // }; Core.Engine.ExecuteCommandWithBuffer("@ping", (buffer) => { Console.WriteLine($"pong: {buffer}");