Added linux comaptibility! Added null safety and cross-platform behaviour, improved BetterUser handshake.

This commit is contained in:
DeveloperNyx committed 2026-03-17 09:57:38 +00:00
1 parent c4baa42a23
commit a162297997
6 files changed
+131 -151

No files matched your search

+57 -10
View File
@@ -11,23 +11,40 @@ internal sealed class SharedSecretExchange
private byte[] sharedSecret = [];
private bool cryptoDisabled;
internal bool CryptoAvailable => !cryptoDisabled;
internal bool UseFallback { get; set; }
private int? remoteTempKey;
internal SharedSecretExchange()
{
try
{
dh = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
dh = ECDiffieHellman.Create();
dh.GenerateKey(ECCurve.NamedCurves.nistP256);
publicKey = dh.ExportSubjectPublicKeyInfo();
}
catch (Exception ex)
{
Logger_.Error("ECDH unavailable, disabling secure handshake: " + ex.Message);
Logger_.Error("ECDH unavailable, using fallback handshake: " + ex.Message);
cryptoDisabled = true;
dh = null;
publicKey = [];
}
var random = new Random();
tempKey = random.Next();
tempKey = GenerateSecureTempKey();
}
private static int GenerateSecureTempKey()
{
byte[] buffer = new byte[4];
RandomNumberGenerator.Fill(buffer);
return Math.Abs(BitConverter.ToInt32(buffer, 0));
}
internal void SetRemoteTempKey(int key)
{
remoteTempKey = key;
}
internal byte[] GetPublicKey()
@@ -43,21 +60,32 @@ internal sealed class SharedSecretExchange
internal byte[] GetSharedSecret()
{
if (cryptoDisabled) return [];
return sharedSecret;
}
internal byte[] GenerateSharedSecret(byte[] otherPartyPublicKey)
{
if (cryptoDisabled) return [];
if (sharedSecret.Length > 0) return sharedSecret;
if (cryptoDisabled || UseFallback)
{
if (!remoteTempKey.HasValue)
return [];
sharedSecret = DeriveFallbackSecret(tempKey, remoteTempKey.Value);
return sharedSecret;
}
try
{
using ECDiffieHellman otherPartyDH = ECDiffieHellman.Create();
otherPartyDH.ImportSubjectPublicKeyInfo(otherPartyPublicKey, out _);
using var otherPartyDH = ECDiffieHellman.Create();
if (otherPartyPublicKey == null || otherPartyPublicKey.Length == 0)
return [];
otherPartyDH.ImportSubjectPublicKeyInfo(otherPartyPublicKey, out _);
sharedSecret = dh.DeriveKeyMaterial(otherPartyDH.PublicKey);
dh.Dispose();
return sharedSecret;
}
@@ -70,15 +98,34 @@ internal sealed class SharedSecretExchange
internal int GetSharedSecretHash()
{
if (cryptoDisabled) return 0;
if (sharedSecret.Length == 0) return 0;
using SHA256 sha256 = SHA256.Create();
byte[] hashBytes = sha256.ComputeHash(sharedSecret);
int numericHash = BitConverter.ToInt32(hashBytes, 0);
int numericHash = (hashBytes[0] << 24) |
(hashBytes[1] << 16) |
(hashBytes[2] << 8) |
hashBytes[3];
return Math.Abs(numericHash);
}
private static byte[] DeriveFallbackSecret(int local, int remote)
{
using SHA256 sha256 = SHA256.Create();
int a = local < remote ? local : remote;
int b = local < remote ? remote : local;
byte[] buffer = new byte[8];
Buffer.BlockCopy(BitConverter.GetBytes(a), 0, buffer, 0, 4);
Buffer.BlockCopy(BitConverter.GetBytes(b), 0, buffer, 4, 4);
return sha256.ComputeHash(buffer);
}
internal bool HasBeenCleared { get; private set; }
internal void ClearData()
+14 -60
View File
@@ -11,9 +11,6 @@ using UnityEngine;
namespace BetterAmongUs.Modules;
/// <summary>
/// Handles the secure handshake process between BetterAmongUs clients using Diffie-Hellman key exchange.
/// </summary>
internal sealed class HandshakeHandler
{
[HideFromIl2Cpp]
@@ -25,17 +22,11 @@ internal sealed class HandshakeHandler
[HideFromIl2Cpp]
private ExtendedPlayerInfo extendedData { get; }
/// <summary>
/// Initiates the wait period before sending the secret to another player.
/// </summary>
internal void WaitSendSecretToPlayer()
{
extendedData.StartCoroutine(CoWaitSendSecretToPlayer());
}
/// <summary>
/// Coroutine that waits for player initialization before sending the secret.
/// </summary>
private IEnumerator CoWaitSendSecretToPlayer()
{
if (!BAUPlugin.SendBetterRpc.Value) yield break;
@@ -45,14 +36,11 @@ internal sealed class HandshakeHandler
if (GameState.IsFreePlay) yield break;
yield return null;
}
yield return new WaitForSeconds(1f);
yield return new WaitForSeconds(1f);
SendSecretToPlayer();
}
/// <summary>
/// Resends the secret to the player if not already verified.
/// </summary>
internal void ResendSecretToPlayer()
{
if (!BAUPlugin.SendBetterRpc.Value) return;
@@ -62,61 +50,47 @@ internal sealed class HandshakeHandler
SendSecretToPlayer();
}
/// <summary>
/// Sends the local client's public key and temporary key to another player.
/// </summary>
// Local client sends to client
private void SendSecretToPlayer()
{
if (extendedData._Data.Object.IsLocalPlayer()) return;
if (HasSendSharedSecret) return;
HasSendSharedSecret = true;
RPC.SendCustomRpcPacked(CustomRPC.SendSecretToPlayer, writer =>
{
writer.Write(SharedSecret.CryptoAvailable);
writer.WriteBytes(SharedSecret.GetPublicKey());
writer.Write(SharedSecret.GetTempKey());
}, extendedData._Data.ClientId);
}
/// <summary>
/// Handles receiving a secret from another player and generates a shared secret.
/// </summary>
/// <param name="reader">MessageReader containing the sender's public key and temporary key.</param>
// Client receives from local client
internal void HandleSecretFromSender(MessageReader reader)
{
if (extendedData._Data?.Object?.IsLocalPlayer() == true) return;
bool senderSupportsCrypto = reader.ReadBoolean();
byte[] sendersPublicKey = reader.ReadBytes();
int tempKey = reader.ReadInt32();
// Logger.Log($"Received public key ({sendersPublicKey.Length} bytes) from {_Data.PlayerName}");
SharedSecret.UseFallback = !senderSupportsCrypto;
SharedSecret.SetRemoteTempKey(tempKey);
byte[] secret = SharedSecret.GenerateSharedSecret(sendersPublicKey);
if (secret.Length == 0)
{
// Logger.Error("Failed to generate shared secret!");
return;
}
if (secret.Length == 0) return;
extendedData.IsBetterUser = true;
TryHandlePendingVerificationData();
SendSecretHashToSender(tempKey, extendedData._Data.ClientId);
ResendSecretToPlayer();
}
/// <summary>
/// Sends the hash of the generated shared secret back to the original sender for verification.
/// </summary>
/// <param name="tempKey">The temporary key received from the sender.</param>
/// <param name="senderClientId">The client ID of the sender.</param>
// Client sends back to local client
private void SendSecretHashToSender(int tempKey, int senderClientId)
{
if (!BAUPlugin.SendBetterRpc.Value) return;
int hash = SharedSecret.GetSharedSecretHash();
// Logger.Log($"Sending secret hash: {hash} (tempKey: {tempKey})");
RPC.SendCustomRpcPacked(CustomRPC.CheckSecretHashFromPlayer, writer =>
{
@@ -125,47 +99,30 @@ internal sealed class HandshakeHandler
}, senderClientId);
}
/// <summary>
/// Handles receiving a secret hash from another player for verification.
/// </summary>
/// <param name="reader">MessageReader containing the temporary key and hash.</param>
internal void HandleSecretHashFromPlayer(MessageReader reader)
{
int tempKey = reader.ReadInt32();
int receivedHash = reader.ReadInt32();
_pendingVerificationData = (tempKey, receivedHash);
TryHandlePendingVerificationData();
}
/// <summary>
/// Attempts to verify pending handshake data if all required information is available.
/// </summary>
internal void TryHandlePendingVerificationData()
{
if (_pendingVerificationData?.tempKey == null || _pendingVerificationData?.receivedHash == null) return;
if (!_pendingVerificationData.HasValue) return;
if (SharedSecret.GetSharedSecret().Length == 0) return;
var tempKey = _pendingVerificationData?.tempKey;
var receivedHash = _pendingVerificationData?.receivedHash;
var data = _pendingVerificationData.Value;
// Logger.Log($"Received hash check: TempKey={tempKey} (ours={SharedSecret.GetTempKey()}), Hash={receivedHash} (ours={SharedSecret.GetSharedSecretHash()})");
if (tempKey != SharedSecret.GetTempKey())
{
// Logger.Warning($"Invalid tempKey from {_Data.PlayerName}");
if (data.tempKey != SharedSecret.GetTempKey())
return;
}
extendedData.IsBetterUser = true;
if (receivedHash == SharedSecret.GetSharedSecretHash())
if (data.receivedHash == SharedSecret.GetSharedSecretHash())
{
extendedData.IsVerifiedBetterUser = true;
// Logger.Log($"Verified player: {_Data.PlayerName}");
}
else
{
// Logger.Warning($"Hash mismatch from {_Data.PlayerName}");
}
_pendingVerificationData = null;
@@ -174,8 +131,5 @@ internal sealed class HandshakeHandler
private (int tempKey, int receivedHash)? _pendingVerificationData = null;
private bool HasSendSharedSecret { get; set; }
/// <summary>
/// Gets the SharedSecretExchange instance for secure key exchange.
/// </summary>
internal SharedSecretExchange SharedSecret { get; set; } = new();
}
+5 -2
View File
@@ -17,7 +17,7 @@ internal sealed class ExtendedPlayerInfo : MonoBehaviour, IMonoExtension<Network
{
try
{
HandshakeHandler = new(this);
HandshakeHandler = new HandshakeHandler(this);
}
catch (Exception ex)
{
@@ -76,14 +76,17 @@ internal sealed class ExtendedPlayerInfo : MonoBehaviour, IMonoExtension<Network
if (AntiCheatInfo.RPCSentPS >= ExtendedAntiCheatInfo.MAX_RPC_SENT && !flag)
{
BetterNotificationManager.NotifyCheat(_Data.Object,
BetterNotificationManager.NotifyCheat(
_Data.Object,
Translator.GetString("AntiCheat.Reason.RPCSentPS"),
Translator.GetString("AntiCheat.UnauthorizedAction")
);
Logger_.LogCheat($"{_Data.Object.BetterData().RealName} {AntiCheatInfo.RPCSentPS} Sent.");
}
timeAccumulator += time;
if (timeAccumulator >= 0.25f - 0.005 * AntiCheatInfo.RPCSentPS)
{
AntiCheatInfo.RPCSentPS -= 1;
+15 -5
View File
@@ -374,11 +374,8 @@ internal class PlayerInfoDisplay : MonoBehaviour
}
/// <summary>
/// Sets lobby-specific information.
/// Sets in-game specific information.
/// </summary>
/// <param name="newName">Reference to the player's name.</param>
/// <param name="betterData">Extended player data.</param>
/// <param name="sbTag">StringBuilder for tag text.</param>
[HideFromIl2Cpp]
private void SetLobbyInfo(ref string newName, ExtendedPlayerInfo betterData, StringBuilder sbTag)
{
@@ -389,10 +386,23 @@ internal class PlayerInfoDisplay : MonoBehaviour
if ((_player.IsLocalPlayer() || betterData.IsBetterUser) && !GameState.IsInGamePlay)
{
string verificationSymbol = betterData.IsVerifiedBetterUser || _player.IsLocalPlayer() ? "✓ " : "";
string verificationSymbol;
if (betterData.HandshakeHandler.SharedSecret.UseFallback)
{
// Fallback path (Linux or crypto unavailable)
verificationSymbol = betterData.IsVerifiedBetterUser ? "(Linux) ✓ " : "(Linux) ";
}
else
{
// Normal ECDH path
verificationSymbol = betterData.IsVerifiedBetterUser || _player.IsLocalPlayer() ? "✓ " : "";
}
sbTag.AppendFormat("<color=#0dff00>{1}{0}</color>+++",
_cachedTranslations.BetterUser, verificationSymbol);
}
sbTag.Append($"<color=#b554ff>ID: {_player.PlayerId}</color>+++");
}
+1 -1
View File
@@ -129,7 +129,7 @@ internal static class PrivateLobbyPatch
var sprite = __instance.HostPrivateButton.transform.Find("Inactive")?.GetComponent<SpriteRenderer>();
if (sprite != null)
{
sprite.color = new Color(0.35f, 1f, 1f, 1f); // Corrected color assignment
sprite.color = new Color(0.35f, 1f, 1f, 1f);
}
}
}
+39 -73
View File
@@ -21,8 +21,6 @@ internal static class ChatPatch
internal static void ClearChat()
{
if (!HudManager.InstanceExists) return;
// Clear all chat bubbles
HudManager.Instance.Chat.chatBubblePool.ReclaimAll();
}
@@ -30,7 +28,6 @@ internal static class ChatPatch
{
if (!HudManager.InstanceExists) return;
// Clear only player chat bubbles (keep command bubbles)
foreach (var obj in HudManager.Instance.Chat.chatBubblePool.activeChildren.ToArray())
{
var chatBubble = obj.GetComponent<ChatBubble>();
@@ -40,6 +37,7 @@ internal static class ChatPatch
HudManager.Instance.Chat.chatBubblePool.Reclaim(chatBubble);
}
}
HudManager.Instance.Chat.AlignAllBubbles();
}
@@ -47,7 +45,6 @@ internal static class ChatPatch
{
if (!HudManager.InstanceExists) return;
// Clear only command chat bubbles (keep player chat)
foreach (var obj in HudManager.Instance.Chat.chatBubblePool.activeChildren.ToArray())
{
var chatBubble = obj.GetComponent<ChatBubble>();
@@ -57,14 +54,14 @@ internal static class ChatPatch
HudManager.Instance.Chat.chatBubblePool.Reclaim(chatBubble);
}
}
HudManager.Instance.Chat.AlignAllBubbles();
}
[HarmonyPatch(typeof(ChatController), nameof(ChatController.Toggle))]
[HarmonyPostfix]
private static void ChatController_Toggle_Postfix(/*ChatController __instance*/)
private static void ChatController_Toggle_Postfix()
{
// Apply chat theme when chat is opened/closed
SetChatTheme();
}
@@ -73,52 +70,45 @@ internal static class ChatPatch
[HarmonyPriority(Priority.First)]
private static void ChatController_Update_Prefix(ChatController __instance)
{
// Apply dark/light theme to chat input field
if (BAUPlugin.ChatDarkMode.Value)
{
// Free chat color
__instance.freeChatField.background.color = new Color32(40, 40, 40, byte.MaxValue);
__instance.freeChatField.textArea.compoText.Color(Color.white);
__instance.freeChatField.textArea.outputText.color = Color.white;
}
else
{
// Free chat color
__instance.freeChatField.background.color = new Color32(255, 255, 255, byte.MaxValue);
__instance.freeChatField.textArea.compoText.Color(Color.black);
__instance.freeChatField.textArea.outputText.color = Color.black;
}
// Ctrl+X to cut text to clipboard
if ((Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) && Input.GetKeyDown(KeyCode.X))
{
ClipboardHelper.PutClipboardString(__instance.freeChatField.textArea.text);
__instance.freeChatField.textArea.SetText("");
}
// Up arrow for chat history navigation
if (Input.GetKeyDown(KeyCode.UpArrow) && ChatHistory.Any())
{
CurrentHistorySelection = Mathf.Clamp(--CurrentHistorySelection, 0, ChatHistory.Count - 1);
__instance.freeChatField.textArea.SetText(ChatHistory[CurrentHistorySelection]);
}
// Down arrow for chat history navigation
if (Input.GetKeyDown(KeyCode.DownArrow) && ChatHistory.Any())
{
CurrentHistorySelection++;
if (CurrentHistorySelection < ChatHistory.Count)
__instance.freeChatField.textArea.SetText(ChatHistory[CurrentHistorySelection]);
else __instance.freeChatField.textArea.SetText("");
else
__instance.freeChatField.textArea.SetText("");
}
}
// Log chat messages to console
[HarmonyPatch(typeof(ChatController), nameof(ChatController.AddChat))]
[HarmonyPostfix]
private static void ChatController_AddChat_Postfix(ChatController __instance, PlayerControl sourcePlayer, string chatText)
{
// Log chat publicly if player is alive, privately if dead
if (sourcePlayer.IsAlive() || !PlayerControl.LocalPlayer.IsAlive())
{
Logger_.Log($"{sourcePlayer.Data.PlayerName} -> {chatText}", "ChatLog");
@@ -134,13 +124,11 @@ internal static class ChatPatch
private static void ChatController_SetChatBubbleName_Postfix(ChatController __instance, ChatBubble bubble, NetworkedPlayerInfo playerInfo, bool isDead, bool didVote)
{
if (didVote) return;
if (bubble == null) return;
if (bubble.NameText == null) return;
if (playerInfo == null) return;
if (bubble == null || bubble.NameText == null || playerInfo == null) return;
var sourcePlayer = playerInfo.Object;
if (sourcePlayer == null) return;
if (PlayerControl.LocalPlayer == null) return;
var localPlayer = PlayerControl.LocalPlayer;
if (sourcePlayer == null || localPlayer == null) return;
StringBuilder sbTag = new();
StringBuilder sbInfo = new();
@@ -159,7 +147,13 @@ internal static class ChatPatch
if (betterData == null) return;
if (sourcePlayer.IsLocalPlayer() || betterData.IsBetterUser)
sbTag.AppendFormat("<color=#0dff00>{1}{0}</color>+++", Translator.GetString("Player.BetterUser"), betterData.IsVerifiedBetterUser || sourcePlayer.IsLocalPlayer() ? "✓ " : "");
{
sbTag.AppendFormat(
"<color=#0dff00>{1}{0}</color>+++",
Translator.GetString("Player.BetterUser"),
betterData.IsVerifiedBetterUser || sourcePlayer.IsLocalPlayer() ? "✓ " : ""
);
}
if (BetterDataManager.BetterDataFile.SickoData.Any(info => info.CheckPlayerData(sourcePlayer.Data)))
sbTag.Append($"<color=#00f583>{Translator.GetString("Player.SickoUser")}</color>+++");
@@ -173,41 +167,34 @@ internal static class ChatPatch
if (!sourcePlayer.IsImpostorTeammate())
{
if (PlayerControl.LocalPlayer.IsAlive() && !sourcePlayer.IsLocalPlayer())
{
if (localPlayer.IsAlive() && !sourcePlayer.IsLocalPlayer())
Role = "";
}
}
if (PlayerControl.LocalPlayer.Is(RoleTypes.GuardianAngel) && !sourcePlayer.IsAlive() || !PlayerControl.LocalPlayer.Is(RoleTypes.GuardianAngel))
if ((localPlayer.Is(RoleTypes.GuardianAngel) && !sourcePlayer.IsAlive()) || !localPlayer.Is(RoleTypes.GuardianAngel))
{
sbTag.Append(Role);
}
sbInfo.Append("<size=75%>");
var parts = sbTag.ToString().Split("+++");
for (int i = 0; i < parts.Length; i++)
{
if (!string.IsNullOrEmpty(parts[i]))
{
sbInfo.Append(parts[i]);
if (i != parts.Length - 2)
{
sbInfo.Append(" - ");
}
}
if (string.IsNullOrEmpty(parts[i])) continue;
sbInfo.Append(parts[i]);
if (i != parts.Length - 2)
sbInfo.Append(" - ");
}
sbInfo.Append("</size>");
bool flag = sourcePlayer.IsLocalPlayer();
if (flag)
{
playerName = $"{sbInfo} " + playerName;
}
if (sourcePlayer.IsLocalPlayer())
playerName = sbInfo + " " + playerName;
else
{
playerName += $" {sbInfo}";
}
playerName += " " + sbInfo;
bubble.NameText.SetText(playerName);
}
@@ -225,63 +212,51 @@ internal static class ChatPatch
if (BAUPlugin.ChatDarkMode.Value)
{
// Quick chat color
chat.quickChatField.background.color = new Color32(40, 40, 40, byte.MaxValue);
chat.quickChatField.text.color = Color.white;
// Icons
chat.quickChatButton.transform.Find("QuickChatIcon").GetComponent<SpriteRenderer>().color = new Color(0.5f, 0.5f, 0.5f, 1f);
chat.openKeyboardButton.transform.Find("OpenKeyboardIcon").GetComponent<SpriteRenderer>().color = new Color(0.5f, 0.5f, 0.5f, 1f);
}
else
{
// Quick chat color
chat.quickChatField.background.color = new Color32(255, 255, 255, byte.MaxValue);
chat.quickChatField.text.color = Color.black;
// Icons
chat.quickChatButton.transform.Find("QuickChatIcon").GetComponent<SpriteRenderer>().color = new Color(1f, 1f, 1f, 1f);
chat.openKeyboardButton.transform.Find("OpenKeyboardIcon").GetComponent<SpriteRenderer>().color = new Color(1f, 1f, 1f, 1f);
}
// Apply theme to all existing chat bubbles
foreach (var item in HudManager.Instance.Chat.chatBubblePool.activeChildren.SelectIl2Cpp(c => c.GetComponent<ChatBubble>()))
{
SetChatPoolTheme(item);
}
}
// Apply theme to individual chat bubble
internal static ChatBubble SetChatPoolTheme(ChatBubble asChatBubble)
{
ChatBubble chatBubble = asChatBubble;
var chatBubble = asChatBubble;
if (BAUPlugin.ChatDarkMode.Value)
{
chatBubble.transform.Find("ChatText (TMP)").GetComponentInChildren<TextMeshPro>(true).color = new Color(1f, 1f, 1f, 1f);
chatBubble.transform.Find("ChatText (TMP)").GetComponentInChildren<TextMeshPro>(true).color = Color.white;
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = new Color(0.15f, 0.15f, 0.15f, 1f);
// Make dead player chat bubbles more transparent
if (chatBubble.transform.Find("PoolablePlayer/xMark") != null)
var mark = chatBubble.transform.Find("PoolablePlayer/xMark");
if (mark != null && mark.GetComponentInChildren<SpriteRenderer>(true).enabled)
{
if (chatBubble.transform.Find("PoolablePlayer/xMark").GetComponentInChildren<SpriteRenderer>(true).enabled == true)
{
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = new Color(0.15f, 0.15f, 0.15f, 0.5f);
}
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = new Color(0.15f, 0.15f, 0.15f, 0.5f);
}
}
else
{
chatBubble.transform.Find("ChatText (TMP)").GetComponentInChildren<TextMeshPro>(true).color = new Color(0f, 0f, 0f, 1f);
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = new Color(1f, 1f, 1f, 1f);
chatBubble.transform.Find("ChatText (TMP)").GetComponentInChildren<TextMeshPro>(true).color = Color.black;
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = Color.white;
// Make dead player chat bubbles more transparent
if (chatBubble.transform.Find("PoolablePlayer/xMark") != null)
var mark = chatBubble.transform.Find("PoolablePlayer/xMark");
if (mark != null && mark.GetComponentInChildren<SpriteRenderer>(true).enabled)
{
if (chatBubble.transform.Find("PoolablePlayer/xMark").GetComponentInChildren<SpriteRenderer>(true).enabled == true)
{
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = new Color(1f, 1f, 1f, 0.5f);
}
chatBubble.transform.Find("Background").GetComponentInChildren<SpriteRenderer>(true).color = new Color(1f, 1f, 1f, 0.5f);
}
}
@@ -292,7 +267,6 @@ internal static class ChatPatch
[HarmonyPostfix]
private static void FreeChatInputField_Awake_Postfix(FreeChatInputField __instance)
{
// Enable extended character support for chat
__instance.textArea.allowAllCharacters = true;
__instance.textArea.AllowSymbols = true;
__instance.textArea.AllowPaste = true;
@@ -305,7 +279,6 @@ internal static class ChatPatch
[HarmonyPostfix]
private static void FreeChatInputField_UpdateCharCount_Postfix(FreeChatInputField __instance)
{
// Update character counter with color coding
int length = __instance.textArea.text.Length;
__instance.charCountText.text = string.Format("{0}/118", length);
__instance.charCountText.color = GetCharColor(length);
@@ -313,14 +286,7 @@ internal static class ChatPatch
private static Color GetCharColor(int length)
{
// Color gradient: green -> yellow -> red as text length increases
Color[] colorGradient =
[
Color.green,
Color.yellow,
Color.red
];
Color[] colorGradient = [Color.green, Color.yellow, Color.red];
(float min, float max) lerpRange = (0f, 117f);
return colorGradient.LerpColor(lerpRange, length);
}