diff --git a/BloodstonePlugin.cs b/BloodstonePlugin.cs index f4c62f5..a5901e5 100644 --- a/BloodstonePlugin.cs +++ b/BloodstonePlugin.cs @@ -3,6 +3,7 @@ using BepInEx.Logging; using BepInEx.Unity.IL2CPP; using Bloodstone.API; +using Bloodstone.Network; namespace Bloodstone { @@ -41,11 +42,13 @@ public override void Load() { API.KeybindManager.Load(); // Hooks.Keybindings.Initialize(); + Hooks.ClientChat.Initialize(); } Hooks.OnInitialize.Initialize(); Hooks.GameFrame.Initialize(); Network.SerializationHooks.Initialize(); + MessageUtils.RegisterClientInitialisationType(); Logger.LogInfo($"Bloodstone v{MyPluginInfo.PLUGIN_VERSION} loaded."); @@ -68,11 +71,13 @@ public override bool Unload() { API.KeybindManager.Save(); Hooks.Keybindings.Uninitialize(); + Hooks.ClientChat.Uninitialize(); } Hooks.OnInitialize.Uninitialize(); Hooks.GameFrame.Uninitialize(); Network.SerializationHooks.Uninitialize(); + MessageUtils.UnregisterClientInitialisationType(); return true; } diff --git a/Hooks/ClientChat.cs b/Hooks/ClientChat.cs new file mode 100644 index 0000000..76d20b2 --- /dev/null +++ b/Hooks/ClientChat.cs @@ -0,0 +1,45 @@ +using System; +using Bloodstone.Network; +using HarmonyLib; +using ProjectM.Network; +using ProjectM.UI; +using Unity.Collections; + +namespace Bloodstone.Hooks; + +public static class ClientChat +{ + private static Harmony? _harmony; + + public static void Initialize() + { + if (_harmony != null) + throw new Exception("Detour already initialized. You don't need to call this. The Bloodstone plugin will do it for you."); + + _harmony = Harmony.CreateAndPatchAll(typeof(ClientChat), MyPluginInfo.PLUGIN_GUID); + } + + public static void Uninitialize() + { + if (_harmony == null) + throw new Exception("Detour wasn't initialized. Are you trying to unload Bloodstone twice?"); + + _harmony.UnpatchSelf(); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(ClientChatSystem), nameof(ClientChatSystem.OnUpdate))] + private static void OnUpdatePrefix(ClientChatSystem __instance) + { + var entities = __instance.__query_172511197_1.ToEntityArray(Allocator.Temp); + foreach (var entity in entities) + { + var ev = __instance.EntityManager.GetComponentData(entity); + if (ev.MessageType == ServerChatMessageType.System && MessageUtils.DeserialiseMessage(ev.MessageText.ToString())) + { + // Remove this as it is an internal message that the user is unlikely wanting to see in their chat + __instance.EntityManager.DestroyEntity(entity); + } + } + } +} \ No newline at end of file diff --git a/Network/MessageChatRegistry.cs b/Network/MessageChatRegistry.cs new file mode 100644 index 0000000..c639b43 --- /dev/null +++ b/Network/MessageChatRegistry.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Bloodstone.Network; + +// Tracks internal registered message types and their event handlers. +internal class MessageChatRegistry +{ + internal static Dictionary _eventHandlers = new(); + + internal static void Register(RegisteredChatEventHandler handler) + { + var key = MessageRegistry.DeriveKey(typeof(T)); + + if (_eventHandlers.ContainsKey(key)) + throw new Exception($"Network event {key} is already registered"); + + _eventHandlers.Add(key, handler); + } + + internal static void Unregister() + { + var key = MessageRegistry.DeriveKey(typeof(T)); + + // don't throw if it doesn't exist + _eventHandlers.Remove(key); + } +} + +internal class RegisteredChatEventHandler +{ +#nullable disable + internal Action OnReceiveFromServer { get; init; } +#nullable enable +} \ No newline at end of file diff --git a/Network/MessageUtils.cs b/Network/MessageUtils.cs new file mode 100644 index 0000000..3502bfe --- /dev/null +++ b/Network/MessageUtils.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Bloodstone.API; +using ProjectM; +using ProjectM.Network; + +namespace Bloodstone.Network; + +public delegate void ClientConnectionMessageHandler(User fromCharacter); + +public static class MessageUtils +{ + /// + /// This function initialises the client to enable the server to send VNetworkChatMessage messages via the in-game chat mechanism. + /// This should only be called once the client is ready to receive messages from the server. A suggestion for this is + /// once the `GameDataManager.GameDataInitialized` is true. + /// + /// This gives the client a chance to register any appropriate VNetworkChatMessage types to support reading different + /// data types. + /// + /// + public static void InitialiseClient() + { + if (VWorld.IsServer) throw new System.Exception("InitialiseClient can only be called on the client."); + VNetwork.SendToServerStruct(new ClientRegister() { clientNonce = ClientNonce }); + } + + /// + /// The server should add an action on this event to be able to respond with any start-up requests now that the user + /// is ready for messages. + /// + public static event ClientConnectionMessageHandler? OnClientConnectionEvent; + + /// + /// Send a VNetworkChatMessage message to the client via the in-game chat mechanism. + /// If the client has not yet been initialised (via `InitialiseClient`) then this will not send any message. + /// + /// Note: If the client has not registered the VNetworkChatMessage type that we are sending, then they will not + /// receive that message. + /// + /// This is the user that the message will be sent to + /// This is the data packet that will be sent to the user + /// + public static void SendToClient(User toCharacter, T msg) where T : VNetworkChatMessage + { + BloodstonePlugin.Logger.LogDebug("[SERVER] [SEND] VNetworkChatMessage"); + + // Note: Bloodstone currently doesn't support sending custom server messages to the client :( + // VNetwork.SendToClient(toCharacter, msg); + + // ... instead we are going to send the user a chat message, as long as we have them in our initialised list. + if (SupportedUsers.TryGetValue(toCharacter.PlatformId, out var clientNonce)) + { + ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, toCharacter, $"{SerialiseMessage(msg, clientNonce)}"); + } + else + { + BloodstonePlugin.Logger.LogDebug("user nonce not present in supportedUsers"); + } + } + + private static readonly int ClientNonce = Random.Shared.Next(); + private static readonly Dictionary SupportedUsers = new(); + + private struct ClientRegister + { + public int clientNonce; + } + + internal static void RegisterClientInitialisationType() + { + VNetworkRegistry.RegisterServerboundStruct((FromCharacter from, ClientRegister register) => + { + var user = VWorld.Server.EntityManager.GetComponentData(from.User); + SupportedUsers[user.PlatformId] = register.clientNonce; + + OnClientConnectionEvent?.Invoke(user); + }); + } + + internal static void UnregisterClientInitialisationType() + { + VNetworkRegistry.UnregisterStruct(); + } + + public static void RegisterType(Action onServerMessageEvent) where T : VNetworkChatMessage, new() + { + MessageChatRegistry.Register(new() + { + OnReceiveFromServer = br => + { + var msg = new T(); + msg.Deserialize(br); + onServerMessageEvent.Invoke(msg); + } + }); + } + + private static string SerialiseMessage(T msg, int clientNonce) where T : VNetworkChatMessage + { + using var stream = new MemoryStream(); + using var bw = new BinaryWriter(stream); + + VNetworkChatMessage.WriteHeader(bw, MessageRegistry.DeriveKey(msg.GetType()), clientNonce); + + msg.Serialize(bw); + return Convert.ToBase64String(stream.ToArray()); + } + + internal static bool DeserialiseMessage(string message) + { + var type = ""; + try + { + var bytes = Convert.FromBase64String(message); + + using var stream = new MemoryStream(bytes); + using var br = new BinaryReader(stream); + + // If we can't read the header, it is likely not a VNetworkChatMessage + if (!VNetworkChatMessage.ReadHeader(br, out var clientNonce, out type)) return false; + + // This is a valid message, but not intended for us. + if (clientNonce != ClientNonce) + { + BloodstonePlugin.Logger.LogWarning($"ClientNonce did not match: [actual: {clientNonce}, expected: {ClientNonce}]"); + return true; + } + + if (MessageChatRegistry._eventHandlers.TryGetValue(type, out var handler)) + { + handler.OnReceiveFromServer(br); + } + + return true; + } + catch (FormatException) + { + BloodstonePlugin.Logger.LogDebug("Invalid base64"); + return false; + } + catch (Exception ex) + { + BloodstonePlugin.Logger.LogError($"Error handling incoming network event {type}:"); + BloodstonePlugin.Logger.LogError(ex); + + return false; + } + } +} \ No newline at end of file diff --git a/Network/VNetworkChatMessage.cs b/Network/VNetworkChatMessage.cs new file mode 100644 index 0000000..ced2205 --- /dev/null +++ b/Network/VNetworkChatMessage.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; + +namespace Bloodstone.Network; + +public interface VNetworkChatMessage +{ + internal static void WriteHeader(BinaryWriter writer, string type, int clientNonce) + { + writer.Write(SerializationHooks.BLOODSTONE_NETWORK_EVENT_ID); + writer.Write(clientNonce); + writer.Write(type); + } + + internal static bool ReadHeader(BinaryReader reader, out int clientNonce, out string type) + { + type = ""; + clientNonce = 0; + + try + { + var eventId = reader.ReadInt32(); + clientNonce = reader.ReadInt32(); + type = reader.ReadString(); + + return eventId == SerializationHooks.BLOODSTONE_NETWORK_EVENT_ID; + } + catch (Exception e) + { + BloodstonePlugin.Logger.LogDebug($"Failed to read chat message header: {e.Message}"); + + return false; + } + } + + public void Serialize(BinaryWriter writer); + + public void Deserialize(BinaryReader reader); +} \ No newline at end of file