From 768c95f8101a2bf479b963cf98bba52359d2bb60 Mon Sep 17 00:00:00 2001 From: Anthony Mosca Date: Mon, 15 Jul 2024 14:49:22 +0930 Subject: [PATCH 1/2] Add chat message classes --- BloodstonePlugin.cs | 5 ++ Hooks/ClientChat.cs | 45 ++++++++++ Network/MessageChatRegistry.cs | 36 ++++++++ Network/MessageUtils.cs | 151 +++++++++++++++++++++++++++++++++ Network/VNetworkChatMessage.cs | 10 +++ 5 files changed, 247 insertions(+) create mode 100644 Hooks/ClientChat.cs create mode 100644 Network/MessageChatRegistry.cs create mode 100644 Network/MessageUtils.cs create mode 100644 Network/VNetworkChatMessage.cs diff --git a/BloodstonePlugin.cs b/BloodstonePlugin.cs index f4c62f5..690c210 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 { @@ -35,12 +36,14 @@ public override void Load() if (VWorld.IsServer) { Hooks.Chat.Initialize(); + MessageUtils.RegisterClientInitialisationType(); } if (VWorld.IsClient) { API.KeybindManager.Load(); // Hooks.Keybindings.Initialize(); + Hooks.ClientChat.Initialize(); } Hooks.OnInitialize.Initialize(); @@ -62,12 +65,14 @@ public override bool Unload() if (VWorld.IsServer) { Hooks.Chat.Uninitialize(); + MessageUtils.UnregisterClientInitialisationType(); } if (VWorld.IsClient) { API.KeybindManager.Save(); Hooks.Keybindings.Uninitialize(); + Hooks.ClientChat.Uninitialize(); } Hooks.OnInitialize.Uninitialize(); 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..6f5fcfc --- /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 +{ + public static event ClientConnectionMessageHandler? OnClientConnectionEvent; + + internal static readonly int ClientNonce = Random.Shared.Next(); + private static Dictionary supportedUsers = new(); + + internal struct ClientRegister + { + public int clientNonce; + } + + internal static void RegisterClientInitialisationType() + { + if (VWorld.IsClient) throw new System.Exception("RegisterClientInitialisationType can only be called on the server."); + + 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() + { + if (VWorld.IsClient) throw new System.Exception("UnregisterClientInitialisationType can only be called on the server."); + + 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); + } + }); + } + + 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 }); + } + + 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 userNonce)) + { + ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, toCharacter, $"{SerialiseMessage(msg, userNonce)}"); + } + else + { + BloodstonePlugin.Logger.LogDebug("user nonce not present in supportedUsers"); + } + } + + private static void WriteHeader(BinaryWriter writer, string type, int clientNonce) + { + writer.Write(type); + writer.Write(clientNonce); + } + + private static bool ReadHeader(BinaryReader reader, out int userNonce, out string type) + { + type = ""; + userNonce = 0; + + try + { + type = reader.ReadString(); + userNonce = reader.ReadInt32(); + + return true; + } + catch (Exception e) + { + BloodstonePlugin.Logger.LogDebug($"Failed to read chat message header: {e.Message}"); + + return false; + } + } + + private static string SerialiseMessage(T msg, int clientNonce) where T : VNetworkChatMessage + { + using var stream = new MemoryStream(); + using var bw = new BinaryWriter(stream); + + 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 (!ReadHeader(br, out var clientNonce, out type)) return false; + + 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..b472772 --- /dev/null +++ b/Network/VNetworkChatMessage.cs @@ -0,0 +1,10 @@ +using System.IO; + +namespace Bloodstone.Network; + +public interface VNetworkChatMessage +{ + public void Serialize(BinaryWriter writer); + + public void Deserialize(BinaryReader reader); +} \ No newline at end of file From c8734cb8e4607ae3d6ae09be57aa379b0fa9d3dc Mon Sep 17 00:00:00 2001 From: Anthony Mosca Date: Thu, 18 Jul 2024 00:24:03 +0930 Subject: [PATCH 2/2] Slightly updated code --- BloodstonePlugin.cs | 4 +- Network/MessageUtils.cs | 120 ++++++++++++++++----------------- Network/VNetworkChatMessage.cs | 31 ++++++++- 3 files changed, 92 insertions(+), 63 deletions(-) diff --git a/BloodstonePlugin.cs b/BloodstonePlugin.cs index 690c210..a5901e5 100644 --- a/BloodstonePlugin.cs +++ b/BloodstonePlugin.cs @@ -36,7 +36,6 @@ public override void Load() if (VWorld.IsServer) { Hooks.Chat.Initialize(); - MessageUtils.RegisterClientInitialisationType(); } if (VWorld.IsClient) @@ -49,6 +48,7 @@ public override void Load() Hooks.OnInitialize.Initialize(); Hooks.GameFrame.Initialize(); Network.SerializationHooks.Initialize(); + MessageUtils.RegisterClientInitialisationType(); Logger.LogInfo($"Bloodstone v{MyPluginInfo.PLUGIN_VERSION} loaded."); @@ -65,7 +65,6 @@ public override bool Unload() if (VWorld.IsServer) { Hooks.Chat.Uninitialize(); - MessageUtils.UnregisterClientInitialisationType(); } if (VWorld.IsClient) @@ -78,6 +77,7 @@ public override bool Unload() Hooks.OnInitialize.Uninitialize(); Hooks.GameFrame.Uninitialize(); Network.SerializationHooks.Uninitialize(); + MessageUtils.UnregisterClientInitialisationType(); return true; } diff --git a/Network/MessageUtils.cs b/Network/MessageUtils.cs index 6f5fcfc..3502bfe 100644 --- a/Network/MessageUtils.cs +++ b/Network/MessageUtils.cs @@ -11,24 +11,69 @@ namespace Bloodstone.Network; 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; - internal static readonly int ClientNonce = Random.Shared.Next(); - private static Dictionary supportedUsers = new(); + /// + /// 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"); - internal struct ClientRegister + // 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() { - if (VWorld.IsClient) throw new System.Exception("RegisterClientInitialisationType can only be called on the server."); - VNetworkRegistry.RegisterServerboundStruct((FromCharacter from, ClientRegister register) => { var user = VWorld.Server.EntityManager.GetComponentData(from.User); - supportedUsers[user.PlatformId] = register.clientNonce; + SupportedUsers[user.PlatformId] = register.clientNonce; OnClientConnectionEvent?.Invoke(user); }); @@ -36,8 +81,6 @@ internal static void RegisterClientInitialisationType() internal static void UnregisterClientInitialisationType() { - if (VWorld.IsClient) throw new System.Exception("UnregisterClientInitialisationType can only be called on the server."); - VNetworkRegistry.UnregisterStruct(); } @@ -53,63 +96,13 @@ internal static void UnregisterClientInitialisationType() } }); } - - 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 }); - } - - 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 userNonce)) - { - ServerChatUtils.SendSystemMessageToClient(VWorld.Server.EntityManager, toCharacter, $"{SerialiseMessage(msg, userNonce)}"); - } - else - { - BloodstonePlugin.Logger.LogDebug("user nonce not present in supportedUsers"); - } - } - - private static void WriteHeader(BinaryWriter writer, string type, int clientNonce) - { - writer.Write(type); - writer.Write(clientNonce); - } - - private static bool ReadHeader(BinaryReader reader, out int userNonce, out string type) - { - type = ""; - userNonce = 0; - - try - { - type = reader.ReadString(); - userNonce = reader.ReadInt32(); - - return true; - } - catch (Exception e) - { - BloodstonePlugin.Logger.LogDebug($"Failed to read chat message header: {e.Message}"); - - return false; - } - } private static string SerialiseMessage(T msg, int clientNonce) where T : VNetworkChatMessage { using var stream = new MemoryStream(); using var bw = new BinaryWriter(stream); - WriteHeader(bw, MessageRegistry.DeriveKey(msg.GetType()), clientNonce); + VNetworkChatMessage.WriteHeader(bw, MessageRegistry.DeriveKey(msg.GetType()), clientNonce); msg.Serialize(bw); return Convert.ToBase64String(stream.ToArray()); @@ -126,7 +119,14 @@ internal static bool DeserialiseMessage(string message) using var br = new BinaryReader(stream); // If we can't read the header, it is likely not a VNetworkChatMessage - if (!ReadHeader(br, out var clientNonce, out type)) return false; + 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)) { diff --git a/Network/VNetworkChatMessage.cs b/Network/VNetworkChatMessage.cs index b472772..ced2205 100644 --- a/Network/VNetworkChatMessage.cs +++ b/Network/VNetworkChatMessage.cs @@ -1,9 +1,38 @@ -using System.IO; +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);