diff --git a/AquaMai.Mods/WorldsLink/FutariClient.cs b/AquaMai.Mods/WorldsLink/FutariClient.cs new file mode 100644 index 0000000..653b5fa --- /dev/null +++ b/AquaMai.Mods/WorldsLink/FutariClient.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using AMDaemon; +using PartyLink; +using static Manager.Accounting; + +namespace AquaMai.Mods.WorldsLink; + +public class FutariClient(string keychip, string host, int port, int _) +{ + // public static string LOBBY_BASE = "http://localhost/mai2-futari/recruit"; + public const string LOBBY_BASE = "https://aquadx.net/aqua/mai2-futari/recruit"; + public static FutariClient Instance { get; private set; } + + public FutariClient(string keychip, string host, int port) : this(keychip, host, port, 0) + { + Instance = this; + } + + public string keychip { get; set; } = keychip; + + private TcpClient _tcpClient; + private StreamWriter _writer; + private StreamReader _reader; + + public readonly ConcurrentQueue sendQ = new(); + // + public readonly ConcurrentDictionary> tcpRecvQ = new(); + // + public readonly ConcurrentDictionary> udpRecvQ = new(); + // + public readonly ConcurrentDictionary> acceptQ = new(); + // + public readonly ConcurrentDictionary> acceptCallbacks = new(); + + private Thread _sendThread; + private Thread _recvThread; + + private bool _reconnecting = false; + + private readonly Stopwatch _heartbeat = new Stopwatch().Also(it => it.Start()); + private readonly long[] _delayWindow = new int[20].Select(_ => -1L).ToArray(); + public int _delayIndex = 0; + public long _delayAvg = 0; + + public IPAddress StubIP => FutariExt.KeychipToStubIp(keychip).ToIP(); + + /// + /// -1: Failed to connect + /// 0: Not connect + /// 1: Connecting + /// 2: Connected + /// + public int StatusCode { get; private set; } = 0; + public string ErrorMsg { get; private set; } = ""; + + public void ConnectAsync() => new Thread(Connect) { IsBackground = true }.Start(); + + private void Connect() + { + _tcpClient = new TcpClient(); + + try + { + StatusCode = 1; + _tcpClient.Connect(host, port); + StatusCode = 2; + } + catch (Exception ex) + { + StatusCode = -1; + ErrorMsg = ex.Message; + Log.Error($"Error connecting to server:\nHost:{host}:{port}\n{ex.Message}"); + ConnectAsync(); + return; + } + var networkStream = _tcpClient.GetStream(); + _writer = new StreamWriter(networkStream, Encoding.UTF8) { AutoFlush = true }; + _reader = new StreamReader(networkStream, Encoding.UTF8); + _reconnecting = false; + + // Register + Send(new Msg { cmd = Cmd.CTL_START, data = keychip }); + Log.Info($"Connected to server at {host}:{port}"); + + // Start communication and message receiving in separate threads + _sendThread = 10.Interval(() => + { + if (_heartbeat.ElapsedMilliseconds > 1000) + { + _heartbeat.Restart(); + Send(new Msg { cmd = Cmd.CTL_HEARTBEAT }); + } + + // Send any data in the send queue + while (sendQ.TryDequeue(out var msg)) Send(msg); + + }, final: Reconnect, name: "SendThread", stopOnError: true); + + _recvThread = 10.Interval(() => + { + var line = _reader.ReadLine(); + if (line == null) return; + + var message = Msg.FromString(line); + HandleIncomingMessage(message); + + }, final: Reconnect, name: "RecvThread", stopOnError: true); + } + + public void Bind(int bindPort, ProtocolType proto) + { + if (proto == ProtocolType.Tcp) + acceptQ.TryAdd(bindPort, new ConcurrentQueue()); + else if (proto == ProtocolType.Udp) + udpRecvQ.TryAdd(bindPort, new ConcurrentQueue()); + } + + private void Reconnect() + { + Log.Warn("Reconnect Entered"); + if (_reconnecting) return; + _reconnecting = true; + + try { _tcpClient.Close(); } + catch { /* ignored */ } + + try { _sendThread.Abort(); } + catch { /* ignored */ } + + try { _recvThread.Abort(); } + catch { /* ignored */ } + + _sendThread = null; + _recvThread = null; + _tcpClient = null; + + // Reconnect + Log.Warn("Reconnecting..."); + ConnectAsync(); + } + + private void HandleIncomingMessage(Msg msg) + { + if (msg.cmd != Cmd.CTL_HEARTBEAT) + Log.Info($"{StubIP} <<< {msg.ToReadableString()}"); + + switch (msg.cmd) + { + // Heartbeat + case Cmd.CTL_HEARTBEAT: + var delay = _heartbeat.ElapsedMilliseconds; + _delayWindow[_delayIndex] = delay; + _delayIndex = (_delayIndex + 1) % _delayWindow.Length; + _delayAvg = (long) _delayWindow.Where(x => x != -1).Average(); + Log.Info($"Heartbeat: {delay}ms, Avg: {_delayAvg}ms"); + break; + + // UDP message + case Cmd.DATA_SEND or Cmd.DATA_BROADCAST when msg is { proto: ProtocolType.Udp, dPort: not null }: + udpRecvQ.Get(msg.dPort.Value)?.Also(q => + { + Log.Info($"+ Added to UDP queue, there are {q.Count + 1} messages in queue"); + })?.Enqueue(msg); + break; + + // TCP message + case Cmd.DATA_SEND when msg.proto == ProtocolType.Tcp && msg is { sid: not null, dPort: not null }: + tcpRecvQ.Get(msg.sid.Value + msg.dPort.Value)?.Also(q => + { + Log.Info($"+ Added to TCP queue, there are {q.Count + 1} messages in queue for port {msg.dPort}"); + })?.Enqueue(msg); + break; + + // TCP connection request + case Cmd.CTL_TCP_CONNECT when msg.dPort != null: + acceptQ.Get(msg.dPort.Value)?.Also(q => + { + Log.Info($"+ Added to Accept queue, there are {q.Count + 1} messages in queue"); + })?.Enqueue(msg); + break; + + // TCP connection accept + case Cmd.CTL_TCP_ACCEPT when msg is { sid: not null, dPort: not null }: + acceptCallbacks.Get(msg.sid.Value + msg.dPort.Value)?.Invoke(msg); + break; + } + } + + private void Send(Msg msg) + { + // Check if msg's destination ip is the same as my local ip. If so, handle it locally + if (msg.dst == StubIP.ToU32()) + { + Log.Debug($"Loopback @@@ {msg.ToReadableString()}"); + HandleIncomingMessage(msg); + return; + } + + _writer.WriteLine(msg); + if (msg.cmd != Cmd.CTL_HEARTBEAT) + Log.Info($"{StubIP} >>> {msg.ToReadableString()}"); + } +} diff --git a/AquaMai.Mods/WorldsLink/FutariExt.cs b/AquaMai.Mods/WorldsLink/FutariExt.cs new file mode 100644 index 0000000..17b28d1 --- /dev/null +++ b/AquaMai.Mods/WorldsLink/FutariExt.cs @@ -0,0 +1,108 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Manager.Party.Party; +using PartyLink; + +namespace AquaMai.Mods.WorldsLink; + +public static class FutariExt +{ + private static uint HashStringToUInt(string input) + { + using var md5 = MD5.Create(); + var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(input)); + return ((uint)(hashBytes[0] & 0xFF) << 24) | + ((uint)(hashBytes[1] & 0xFF) << 16) | + ((uint)(hashBytes[2] & 0xFF) << 8) | + ((uint)(hashBytes[3] & 0xFF)); + } + + public static uint KeychipToStubIp(string keychip) => HashStringToUInt(keychip); + + public static IPAddress ToIP(this uint val) => new(new IpAddress(val).GetAddressBytes()); + public static uint ToU32(this IPAddress ip) => ip.ToNetworkByteOrderU32(); + + public static void Do(this T x, Action f) => f(x); + public static R Let(this T x, Func f) => f(x); + public static T Also(this T x, Action f) { f(x); return x; } + + public static List Each(this IEnumerable enu, Action f) => + enu.ToList().Also(x => x.ForEach(f)); + + public static byte[] View(this byte[] buffer, int offset, int size) + { + var array = new byte[size]; + Array.Copy(buffer, offset, array, 0, size); + return array; + } + + public static string B64(this byte[] buffer) => Convert.ToBase64String(buffer); + public static byte[] B64(this string str) => Convert.FromBase64String(str); + + public static V? Get(this ConcurrentDictionary dict, K key) where V : class + { + return dict.GetValueOrDefault(key); + } + + // Call a function using reflection + public static void Call(this object obj, string method, params object[] args) + { + obj.GetType().GetMethod(method)?.Invoke(obj, args); + } + + public static uint MyStubIP() => KeychipToStubIp(AMDaemon.System.KeychipId.ShortValue); + + public static string Post(this string url, string body) => new WebClient().UploadString(url, body); + public static void PostAsync(this string url, string body, UploadStringCompletedEventHandler? callback = null) => + new WebClient().Also(web => + { + callback?.Do(it => web.UploadStringCompleted += it); + web.UploadStringAsync(new Uri(url), body); + }); + + public static Thread Interval( + this int delay, Action action, bool stopOnError = false, + Action? error = null, Action? final = null, string? name = null + ) => new Thread(() => + { + name ??= $"Interval {Thread.CurrentThread.ManagedThreadId} for {action}"; + try + { + while (true) + { + try + { + Thread.Sleep(delay); + action(); + } + catch (ThreadInterruptedException) + { + break; + } + catch (Exception e) + { + if (stopOnError) throw; + Log.Error($"Error in {name}: {e}"); + } + } + } + catch (Exception e) + { + Log.Error($"Fatal error in {name}: {e}"); + error?.Invoke(e); + } + finally + { + Log.Warn($"{name} stopped"); + final?.Invoke(); + } + }).Also(x => x.Start()); + +} \ No newline at end of file diff --git a/AquaMai.Mods/WorldsLink/FutariPatch.cs b/AquaMai.Mods/WorldsLink/FutariPatch.cs new file mode 100644 index 0000000..ac0a778 --- /dev/null +++ b/AquaMai.Mods/WorldsLink/FutariPatch.cs @@ -0,0 +1,710 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Threading; +using AquaMai.Config.Attributes; +using DB; +using HarmonyLib; +using Manager; +using PartyLink; +using Process; +using Manager.Party.Party; +using AquaMai.Core.Attributes; +using MAI2.Util; +using Mai2.Mai2Cue; +using static Process.MusicSelectProcess; +using Monitor; +using TMPro; +using AquaMai.Mods.WorldsLink; +using MelonLoader.TinyJSON; +using UnityEngine; + +namespace AquaMai.Mods.WorldsLink; + +[ConfigSection( + en: "Enable WorldsLink Multiplayer", + zh: "启用 WorldsLink 多人游戏", + defaultOn: false)] +public static class Futari +{ + private static readonly Dictionary redirect = new(); + private static FutariClient client; + private static bool isInit = false; + + private static MethodBase packetWriteUInt; + private static System.Type StartUpStateType; + + [ConfigEntry(hideWhenDefault: true)] + public static bool Debug = false; + + #region Init + private static readonly MethodInfo SetRecruitData = typeof(MusicSelectProcess).GetProperty("RecruitData")!.SetMethod; + public static void OnBeforePatch() + { + Log.Info("Starting WorldsLink patch..."); + + packetWriteUInt = typeof(Packet).GetMethod("write_uint", BindingFlags.NonPublic | BindingFlags.Static, null, + [typeof(PacketType), typeof(int), typeof(uint)], null); + if (packetWriteUInt == null) Log.Error("write_uint not found"); + + StartUpStateType = typeof(StartupProcess).GetField("_state", BindingFlags.NonPublic | BindingFlags.Instance)!.FieldType; + if (StartUpStateType == null) Log.Error("StartUpStateType not found"); + + // TODO: Make IP configurable + client = new FutariClient("A1234567890", "futari.aquadx.net", 20101); + } + + // Entrypoint + // private void CheckAuth_Proc() + [HarmonyPrefix] + [HarmonyPatch(typeof(OperationManager), "CheckAuth_Proc")] + private static bool CheckAuth_Proc() + { + if (isInit) return PrefixRet.RUN_ORIGINAL; + Log.Info("CheckAuth_Proc"); + + var keychip = AMDaemon.System.KeychipId.ShortValue; + Log.Info($"Keychip ID: {keychip}"); + if (string.IsNullOrEmpty(keychip)) Log.Error("Keychip ID is empty. WorldsLink will not work."); + client.keychip = keychip; + client.ConnectAsync(); + + isInit = true; + return PrefixRet.RUN_ORIGINAL; + } + + #endregion + + #region Misc + //Force Enable LanAvailable + [HarmonyPrefix] + [HarmonyPatch(typeof(AMDaemon.Network), "IsLanAvailable", MethodType.Getter)] + private static bool PreIsLanAvailable(ref bool __result) + { + __result = true; + return false; + } + //Online Display + [HarmonyPostfix] + [HarmonyPatch(typeof(CommonMonitor), "ViewUpdate")] + private static void CommonMonitorViewUpdate(CommonMonitor __instance,TextMeshProUGUI ____buildVersionText, GameObject ____developmentBuildText) + { + ____buildVersionText.transform.position = ____developmentBuildText.transform.position; + ____buildVersionText.gameObject.SetActive(true); + switch (client.StatusCode) + { + case -1: + ____buildVersionText.text = $"WorldLink Offline"; + ____buildVersionText.color = Color.red; + break; + case 0: + ____buildVersionText.text = $"WorldLink Disconnect"; + ____buildVersionText.color = Color.gray; + break; + case 1: + ____buildVersionText.text = $"WorldLink Connecting"; + ____buildVersionText.color = Color.yellow; + break; + case 2: + ____buildVersionText.color = Color.cyan; + ____buildVersionText.text = PartyMan == null ? $"WorldLink Waiting" : $"WorldLink Recruiting: {PartyMan.GetRecruitList().Count}"; + break; + } + } + + // Block irrelevant packets + [HarmonyPrefix] + [HarmonyPatch(typeof(SocketBase), "sendClass", typeof(ICommandParam))] + private static bool sendClass(SocketBase __instance, ICommandParam info) + { + switch (info) + { + // Block AdvocateDelivery, SettingHostAddress + case AdvocateDelivery or Setting.SettingHostAddress: + return PrefixRet.BLOCK_ORIGINAL; + + // If it's a Start/FinishRecruit message, send it using http instead + case StartRecruit or FinishRecruit: + var inf = info is StartRecruit o ? o.RecruitInfo : ((FinishRecruit) info).RecruitInfo; + var start = info is StartRecruit ? "start" : "finish"; + var msg = JsonUtility.ToJson(new RecruitRecord { + Keychip = client.keychip, + RecruitInfo = inf + }, false); + $"{FutariClient.LOBBY_BASE}/{start}".PostAsync(msg); + Log.Info($"Sent {start} recruit message: {msg}"); + return PrefixRet.BLOCK_ORIGINAL; + + // Log the actual type of info and the actual type of this class + default: + Log.Debug($"SendClass: {Log.BRIGHT_RED}{info.GetType().Name}{Log.RESET} from {__instance.GetType().Name}"); + return PrefixRet.RUN_ORIGINAL; + } + } + + // Patch for error logging + // SocketBase:: protected void error(string message, int no) + [HarmonyPrefix] + [HarmonyPatch(typeof(SocketBase), "error", typeof(string), typeof(int))] + private static bool error(string message, int no) + { + Log.Error($"Error: {message} ({no})"); + return PrefixRet.RUN_ORIGINAL; + } + + // Force isSameVersion to return true + // Packet:: public bool isSameVersion() + [HarmonyPostfix] + [HarmonyPatch(typeof(Packet), "isSameVersion")] + private static void isSameVersion(ref bool __result) + { + Log.Debug($"isSameVersion (original): {__result}, forcing true"); + __result = true; + } + + // Patch my IP address to a stub + // public static IPAddress MyIpAddress(int mockID) + [HarmonyPrefix] + [HarmonyPatch(typeof(PartyLink.Util), "MyIpAddress", typeof(int))] + private static bool MyIpAddress(int mockID, ref IPAddress __result) + { + __result = FutariExt.MyStubIP().ToIP(); + return PrefixRet.BLOCK_ORIGINAL; + } + + #endregion + + #region Recruit Information + + private static readonly MethodInfo RStartRecruit = typeof(Client) + .GetMethod("RecvStartRecruit", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly MethodInfo RFinishRecruit = typeof(Client) + .GetMethod("RecvFinishRecruit", BindingFlags.NonPublic | BindingFlags.Instance); + private static Dictionary lastRecruits = []; + + private static string Identity(this RecruitInfo x) => $"{x.IpAddress} : {x.MusicID}"; + + // Client Constructor + // Client:: public Client(string name, PartyLink.Party.InitParam initParam) + [HarmonyPostfix] + [HarmonyPatch(typeof(Client), MethodType.Constructor, typeof(string), typeof(PartyLink.Party.InitParam))] + private static void Client(Client __instance, string name, PartyLink.Party.InitParam initParam) + { + Log.Debug($"new Client({name}, {initParam})"); + 10000.Interval(() => + lastRecruits = $"{FutariClient.LOBBY_BASE}/list" + .Post("").Trim().Split('\n') + .Where(x => !string.IsNullOrEmpty(x)) + .Select(JsonUtility.FromJson).ToList() + .Also(lst => lst + .Select(x => x.RecruitInfo.Identity()) + .Do(ids => lastRecruits.Keys + .Where(key => !ids.Contains(key)) + .Each(key => RFinishRecruit.Invoke(__instance, [new Packet(lastRecruits[key].IpAddress) + .Also(p => p.encode(new FinishRecruit(lastRecruits[key]))) + ])) + ) + ) + .Each(x => RStartRecruit.Invoke(__instance, [new Packet(x.RecruitInfo.IpAddress) + .Also(p => p.encode(new StartRecruit(x.RecruitInfo))) + ])) + .Select(x => x.RecruitInfo) + .ToDictionary(x => x.Identity()) + ); + } + + // Block start recruit if the song is not available + // Client:: private void RecvStartRecruit(Packet packet) + [HarmonyPrefix] + [HarmonyPatch(typeof(Client), "RecvStartRecruit", typeof(Packet))] + private static bool RecvStartRecruit(Packet packet) + { + var inf = packet.getParam().RecruitInfo; + Log.Info($"RecvStartRecruit: {JsonUtility.ToJson(inf)}"); + if (Singleton.Instance.GetMusic(inf.MusicID) == null) + { + Log.Error($"Recruit received but music {inf.MusicID} is not available."); + Log.Error($"If you want to play with {string.Join(" and ", inf.MechaInfo.UserNames)},"); + Log.Error("make sure you have the same game version and option packs installed."); + return PrefixRet.BLOCK_ORIGINAL; + } + return PrefixRet.RUN_ORIGINAL; + } + + #endregion + + private static IManager PartyMan => Manager.Party.Party.Party.Get(); + + //Skip StartupNetworkChecker + [HarmonyPostfix] + [HarmonyPatch(nameof(StartupProcess), nameof(StartupProcess.OnUpdate))] + private static void SkipStartupNetworkCheck(ref byte ____state, string[] ____statusMsg, string[] ____statusSubMsg) + { + // Status code + ____statusMsg[7] = "WORLD LINK"; + ____statusSubMsg[7] = client.StatusCode switch + { + -1 => "BAD", + 0 => "Not Connect", + 1 => "Connecting", + 2 => "GOOD", + _ => "Waiting..." + }; + + // Delay + ____statusMsg[8] = "PING"; + ____statusSubMsg[8] = client._delayAvg == 0 ? "N/A" : $"{client._delayAvg} ms"; + ____statusMsg[9] = "CAT :3"; + ____statusSubMsg[9] = client._delayIndex % 2 == 0 ? "" : "MEOW"; + + // If it is in the wait link delivery state, change to ready immediately + if (____state != 0x04) return; + ____state = 0x08; + + // Start the services that would have been started by the StartupNetworkChecker + DeliveryChecker.get().start(true); + Setting.get().setData(new Setting.Data().Also(x => x.set(false, 4))); + Setting.get().setRetryEnable(true); + Advertise.get().initialize(MachineGroupID.ON); + PartyMan.Start(MachineGroupID.ON); + Log.Info("Skip Startup Network Check"); + } + + #region NFSocket + [HarmonyPostfix] + [HarmonyPatch(typeof(NFSocket), MethodType.Constructor, typeof(AddressFamily), typeof(SocketType), typeof(ProtocolType), typeof(int))] + private static void NFCreate(NFSocket __instance, AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType, int mockID) + { + Log.Debug($"new NFSocket({addressFamily}, {socketType}, {protocolType}, {mockID})"); + if (mockID == 3939) return; // Created in redirected NFAccept as a stub + var futari = new FutariSocket(addressFamily, socketType, protocolType, mockID); + redirect.Add(__instance, futari); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(NFSocket), MethodType.Constructor, typeof(Socket))] + private static void NFCreate2(NFSocket __instance, Socket nfSocket) + { + Log.Error("new NFSocket(Socket) -- We shouldn't get here."); + throw new NotImplementedException(); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Poll")] + private static bool NFPoll(NFSocket socket, SelectMode mode, ref bool __result) + { + __result = FutariSocket.Poll(redirect[socket], mode); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Send")] + private static bool NFSend(NFSocket __instance, byte[] buffer, int offset, int size, SocketFlags socketFlags, ref int __result) + { + __result = redirect[__instance].Send(buffer, offset, size, socketFlags); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "SendTo")] + private static bool NFSendTo(NFSocket __instance, byte[] buffer, int offset, int size, SocketFlags socketFlags, EndPoint remoteEP, ref int __result) + { + __result = redirect[__instance].SendTo(buffer, offset, size, socketFlags, remoteEP); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Receive")] + private static bool NFReceive(NFSocket __instance, byte[] buffer, int offset, int size, SocketFlags socketFlags, out SocketError errorCode, ref int __result) + { + __result = redirect[__instance].Receive(buffer, offset, size, socketFlags, out errorCode); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "ReceiveFrom")] + private static bool NFReceiveFrom(NFSocket __instance, byte[] buffer, SocketFlags socketFlags, ref EndPoint remoteEP, ref int __result) + { + __result = redirect[__instance].ReceiveFrom(buffer, socketFlags, ref remoteEP); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Bind")] + private static bool NFBind(NFSocket __instance, EndPoint localEndP) + { + Log.Debug("NFBind"); + redirect[__instance].Bind(localEndP); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Listen")] + private static bool NFListen(NFSocket __instance, int backlog) + { + Log.Debug("NFListen"); + redirect[__instance].Listen(backlog); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Accept")] + private static bool NFAccept(NFSocket __instance, ref NFSocket __result) + { + Log.Debug("NFAccept"); + var futariSocket = redirect[__instance].Accept(); + var mockSocket = new NFSocket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp, 3939); + redirect[mockSocket] = futariSocket; + __result = mockSocket; + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "ConnectAsync")] + private static bool NFConnectAsync(NFSocket __instance, SocketAsyncEventArgs e, int mockID, ref bool __result) + { + Log.Debug("NFConnectAsync"); + __result = redirect[__instance].ConnectAsync(e, mockID); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "SetSocketOption")] + private static bool NFSetSocketOption(NFSocket __instance, SocketOptionLevel optionLevel, SocketOptionName optionName, bool optionValue) + { + redirect[__instance].SetSocketOption(optionLevel, optionName, optionValue); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Close")] + private static bool NFClose(NFSocket __instance) + { + Log.Debug("NFClose"); + redirect[__instance].Close(); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "Shutdown")] + private static bool NFShutdown(NFSocket __instance, SocketShutdown how) + { + Log.Debug("NFShutdown"); + redirect[__instance].Shutdown(how); + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "RemoteEndPoint", MethodType.Getter)] + private static bool NFGetRemoteEndPoint(NFSocket __instance, ref EndPoint __result) + { + Log.Debug("NFGetRemoteEndPoint"); + __result = redirect[__instance].RemoteEndPoint; + return PrefixRet.BLOCK_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NFSocket), "LocalEndPoint", MethodType.Getter)] + private static bool NFGetLocalEndPoint(NFSocket __instance, ref EndPoint __result) + { + Log.Debug("NFGetLocalEndPoint"); + __result = redirect[__instance].LocalEndPoint; + return PrefixRet.BLOCK_ORIGINAL; + } + #endregion + + #region Packet codec + + // Disable encryption + [HarmonyPrefix] + [HarmonyPatch(typeof(Packet), "encrypt")] + private static bool PacketEncrypt(Packet __instance, PacketType ____encrypt, PacketType ____plane) + { + ____encrypt.ClearAndResize(____plane.Count); + Array.Copy(____plane.GetBuffer(), 0, ____encrypt.GetBuffer(), 0, ____plane.Count); + ____encrypt.ChangeCount(____plane.Count); + packetWriteUInt.Invoke(null, [____plane, 0, (uint)____plane.Count]); + return PrefixRet.BLOCK_ORIGINAL; + } + + // Disable decryption + [HarmonyPrefix] + [HarmonyPatch(typeof(Packet), "decrypt")] + private static bool PacketDecrypt(Packet __instance, PacketType ____encrypt, PacketType ____plane) + { + ____plane.ClearAndResize(____encrypt.Count); + Array.Copy(____encrypt.GetBuffer(), 0, ____plane.GetBuffer(), 0, ____encrypt.Count); + ____plane.ChangeCount(____encrypt.Count); + packetWriteUInt.Invoke(null, [____plane, 0, (uint)____plane.Count]); + return PrefixRet.BLOCK_ORIGINAL; + } + + #endregion + + #region Recruit UI + + private static int musicIdSum; + private static bool sideMessageFlag; + + [HarmonyPrefix] + [HarmonyPatch(typeof(MusicSelectProcess), "OnStart")] + private static bool MusicSelectProcessOnStart(MusicSelectProcess __instance) + { + // 每次重新进入选区菜单之后重新初始化变量 + musicIdSum = 0; + sideMessageFlag = false; + return PrefixRet.RUN_ORIGINAL; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(MusicSelectProcess), "PartyExec")] + private static bool PartyExec(MusicSelectProcess __instance) + { + // 检查联机房间是否有更新,如果更新的话设置 IsConnectingMusic=false 然后刷新列表 + var checkDiff = PartyMan.GetRecruitListWithoutMe().Sum(item => item.MusicID); + if (musicIdSum != checkDiff) + { + musicIdSum = checkDiff; + __instance.IsConnectingMusic = false; + } + + if (__instance.IsConnectingMusic && __instance.RecruitData != null && __instance.IsConnectionFolder()) + { + // 设置房间信息显示 + var info = __instance.RecruitData.MechaInfo; + var players = "WorldLink Room! Players: " + + string.Join(" and ", info.UserNames.Where((_, i) => info.FumenDifs[i] != -1)); + + __instance.MonitorArray.Where((_, i) => __instance.IsEntry(i)) + .Each(x => x.SetSideMessage(players)); + + sideMessageFlag = true; + } + else if(!__instance.IsConnectionFolder() && sideMessageFlag) + { + __instance.MonitorArray.Where((_, i) => __instance.IsEntry(i)) + .Each(x => x.SetSideMessage(CommonMessageID.Scroll_Music_Select.GetName())); + + sideMessageFlag = false; + } + return PrefixRet.RUN_ORIGINAL; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MusicSelectProcess), "RecruitData", MethodType.Getter)] + private static void RecruitDataOverride(MusicSelectProcess __instance, ref RecruitInfo __result) + { + // 开歌时设置当前选择的联机数据 + if (!__instance.IsConnectionFolder() || __result == null) return; + + var list = PartyMan.GetRecruitListWithoutMe(); + if (list == null) return; + if (__instance.CurrentMusicSelect >= 0 && __instance.CurrentMusicSelect < list.Count) + { + __result = list[__instance.CurrentMusicSelect]; + } + } + + private static readonly MethodInfo SetConnData = typeof(MusicSelectProcess).GetMethod("SetConnectData")!; + + [HarmonyPrefix] + [HarmonyPatch(typeof(MusicSelectProcess), "IsConnectStart")] + private static bool RecruitDataOverride(MusicSelectProcess __instance, + List ____connectCombineMusicDataList, + SubSequence[] ____currentPlayerSubSequence, + ref bool __result) + { + __result = false; + + // 修正 SetConnectData 触发条件,阻止原有 IP 判断重新设置 + var recruits = PartyMan.GetRecruitListWithoutMe(); + if (!__instance.IsConnectingMusic && recruits.Count > 0) + { + // var recruit = recruits.Count > 0 ? recruits[0] : null; + var recruit = recruits[0]; + Log.Debug($"MusicSelectProcess::IsConnectStart recruit data has been set to {recruit}"); + SetRecruitData.Invoke(__instance, [recruit]); + SetConnectData(__instance, ____connectCombineMusicDataList, ____currentPlayerSubSequence); + __result = true; + } + return PrefixRet.BLOCK_ORIGINAL; + } + + private static readonly MethodInfo SetConnectCategoryEnable = typeof(MusicSelectProcess).GetProperty("IsConnectCategoryEnable")!.SetMethod; + + [HarmonyPrefix] + [HarmonyPatch(typeof(MusicSelectProcess), "SetConnectData")] + private static bool SetConnectData(MusicSelectProcess __instance, + List ____connectCombineMusicDataList, + SubSequence[] ____currentPlayerSubSequence) + { + ____connectCombineMusicDataList.Clear(); + SetConnectCategoryEnable.Invoke(__instance, [false]); + + // 遍历所有房间并且显示 + foreach (var item in PartyMan.GetRecruitListWithoutMe()) + { + var musicID = item.MusicID; + var combineMusicSelectData = new CombineMusicSelectData(); + var music = Singleton.Instance.GetMusic(musicID); + var notesList = Singleton.Instance.GetNotesList()[musicID].NotesList; + + switch (musicID) + { + case < 10000: + combineMusicSelectData.existStandardScore = true; + break; + case > 10000 and < 20000: + combineMusicSelectData.existDeluxeScore = true; + break; + } + + for (var i = 0; i < 2; i++) + { + combineMusicSelectData.musicSelectData.Add(new MusicSelectData(music, notesList, 0)); + } + ____connectCombineMusicDataList.Add(combineMusicSelectData); + try + { + var thumbnailName = music.thumbnailName; + for (var j = 0; j < __instance.MonitorArray.Length; j++) + { + if (!__instance.IsEntry(j)) continue; + + __instance.MonitorArray[j].SetRecruitInfo(thumbnailName); + SoundManager.PlaySE(Cue.SE_INFO_NORMAL, j); + } + } + catch { /* 防止有可能的空 */ } + + __instance.IsConnectingMusic = true; + } + + // No data available, add a dummy entry + if (PartyMan.GetRecruitListWithoutMe().Count == 0) + { + ____connectCombineMusicDataList.Add(new CombineMusicSelectData + { + musicSelectData = [null, null], + isWaitConnectScore = true + }); + __instance.IsConnectingMusic = false; + } + + if (__instance.MonitorArray == null) return PrefixRet.BLOCK_ORIGINAL; + + for (var l = 0; l < __instance.MonitorArray.Length; l++) + { + if (____currentPlayerSubSequence[l] != SubSequence.Music) continue; + + __instance.MonitorArray[l].SetDeployList(false); + if (!__instance.IsConnectionFolder(0)) continue; + + __instance.ChangeBGM(); + if (!__instance.IsEntry(l)) continue; + + __instance.MonitorArray[l].SetVisibleButton(__instance.IsConnectingMusic, InputManager.ButtonSetting.Button04); + } + return PrefixRet.BLOCK_ORIGINAL; + } + #endregion + + #region Debug + + [EnableIf(typeof(Futari), nameof(Debug))] + public class FutariDebug + { + // Log ListenSocket creation + // ListenSocket:: public ListenSocket(string name, int mockID) + [HarmonyPostfix] + [HarmonyPatch(typeof(ListenSocket), MethodType.Constructor, typeof(string), typeof(int))] + private static void ListenSocket(ListenSocket __instance, string name, int mockID) + { + Log.Debug($"new ListenSocket({name}, {mockID})"); + } + + // Log ListenSocket open + // ListenSocket:: public bool open(ushort portNumber) + [HarmonyPrefix] + [HarmonyPatch(typeof(ListenSocket), "open", typeof(ushort))] + private static bool open(ListenSocket __instance, ushort portNumber) + { + Log.Debug($"ListenSocket.open({portNumber}) - {__instance}"); + return PrefixRet.RUN_ORIGINAL; + } + + // Log packet type + // Analyzer:: private void procPacketData(Packet packet) + [HarmonyPrefix] + [HarmonyPatch(typeof(Analyzer), "procPacketData", typeof(Packet))] + private static bool procPacketData(Packet packet, Dictionary ____commandMap) + { + var keys = string.Join(", ", ____commandMap.Keys); + Log.Debug($"procPacketData: {Log.BRIGHT_RED}{packet.getCommand()}{Log.RESET} in {keys}"); + return PrefixRet.RUN_ORIGINAL; + } + + // Log host creation + // Host:: public Host(string name) + [HarmonyPostfix] + [HarmonyPatch(typeof(Host), MethodType.Constructor, typeof(string))] + private static void Host(Host __instance, string name) + { + Log.Debug($"new Host({name})"); + } + + // Log host state change + // Host:: private void SetCurrentStateID(PartyPartyHostStateID nextState) + [HarmonyPrefix] + [HarmonyPatch(typeof(Host), "SetCurrentStateID", typeof(PartyPartyHostStateID))] + private static bool SetCurrentStateID(PartyPartyHostStateID nextState) + { + Log.Debug($"Host::SetCurrentStateID: {nextState}"); + return PrefixRet.RUN_ORIGINAL; + } + + // Log Member creation + // Member:: public Member(string name, Host host, NFSocket socket) + [HarmonyPostfix] + [HarmonyPatch(typeof(Member), MethodType.Constructor, typeof(string), typeof(Host), typeof(NFSocket))] + private static void Member(Member __instance, string name, Host host, NFSocket socket) + { + Log.Debug($"new Member({name}, {host}, {socket})"); + } + + // Log Member state change + // Member:: public void SetCurrentStateID(PartyPartyClientStateID state) + [HarmonyPrefix] + [HarmonyPatch(typeof(Member), "SetCurrentStateID", typeof(PartyPartyClientStateID))] + private static bool SetCurrentStateID(PartyPartyClientStateID state) + { + Log.Debug($"Member::SetCurrentStateID: {state}"); + return PrefixRet.RUN_ORIGINAL; + } + + // Log Member RecvRequestJoin + // Member:: private void RecvRequestJoin(Packet packet) + [HarmonyPrefix] + [HarmonyPatch(typeof(Member), "RecvRequestJoin", typeof(Packet))] + private static bool RecvRequestJoin(Packet packet) + { + Log.Debug($"Member::RecvRequestJoin: {packet.getParam()}"); + return PrefixRet.RUN_ORIGINAL; + } + + // Log Member RecvClientState + // Member:: private void RecvClientState(Packet packet) + [HarmonyPrefix] + [HarmonyPatch(typeof(Member), "RecvClientState", typeof(Packet))] + private static bool RecvClientState(Packet packet) + { + Log.Debug($"Member::RecvClientState: {packet.getParam()}"); + return PrefixRet.RUN_ORIGINAL; + } + } + + #endregion +} \ No newline at end of file diff --git a/AquaMai.Mods/WorldsLink/FutariSocket.cs b/AquaMai.Mods/WorldsLink/FutariSocket.cs new file mode 100644 index 0000000..be6b6ae --- /dev/null +++ b/AquaMai.Mods/WorldsLink/FutariSocket.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using HarmonyLib; +using PartyLink; + +namespace AquaMai.Mods.WorldsLink; + +public class FutariSocket +{ + private int _bindPort = -1; + private readonly FutariClient _client; + private readonly ProtocolType _proto; + private int _streamId = -1; + + // ConnectSocket.Enter_Active (doesn't seem to be actually used) + public EndPoint LocalEndPoint => new IPEndPoint(_client.StubIP, 0); + + // ConnectSocket.Enter_Active, ListenSocket.acceptClient (TCP) + // Each client's remote endpoint must be different + public EndPoint RemoteEndPoint { get; private set; } + + private FutariSocket(FutariClient client, ProtocolType proto) + { + _client = client; + _proto = proto; + RemoteEndPoint = new IPEndPoint(_client.StubIP, 0); + } + + // Compatibility constructor + public FutariSocket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType, int mockID) : + this(FutariClient.Instance, protocolType) { } + + // ListenSocket.open (TCP) + public void Listen(int backlog) { } + + // ListenSocket.open, UdpRecvSocket.open + public void Bind(EndPoint localEndP) + { + if (localEndP is IPEndPoint ep) _client.Bind(_bindPort = ep.Port, _proto); + } + + // Only used in BroadcastSocket + public void SetSocketOption(SocketOptionLevel l, SocketOptionName n, bool o) { } + + // SocketBase.checkRecvEnable, checkSendEnable + // This is the Select step called before blocking calls (e.g. Accept) + public static bool Poll(FutariSocket socket, SelectMode mode) + { + if (mode == SelectMode.SelectRead) + { + return (socket._proto == ProtocolType.Udp) // Check is UDP + ? !socket._client.udpRecvQ.Get(socket._bindPort)?.IsEmpty ?? false + : (socket._streamId == -1) // Check is TCP stream or TCP server + ? !socket._client.acceptQ.Get(socket._bindPort)?.IsEmpty ?? false + : !socket._client.tcpRecvQ.Get(socket._streamId + socket._bindPort)?.IsEmpty ?? false; + } + // Write is always ready? + return mode == SelectMode.SelectWrite; + } + + private static readonly FieldInfo CompletedField = typeof(SocketAsyncEventArgs) + .GetField("Completed", BindingFlags.Instance | BindingFlags.NonPublic); + + // ConnectSocket.Enter_Connect (TCP) + // The destination address is obtained from a RecruitInfo packet sent by the host. + // The host should patch their PartyLink.Util.MyIpAddress() to return a mock address instead of the real one + // Returns true if IO is pending and false if the operation completed synchronously + // When it's false, e will contain the result of the operation + public bool ConnectAsync(SocketAsyncEventArgs e, int mockID) + { + if (e.RemoteEndPoint is not IPEndPoint remote) return false; + var addr = remote.Address.ToU32(); + + // Change Localhost to the local keychip address + if (addr is 2130706433 or 16777343) addr = _client.StubIP.ToU32(); + + // Random stream ID and port + _streamId = new Random().Next(); + _bindPort = new Random().Next(55535, 65535); + + _client.tcpRecvQ[_streamId + _bindPort] = new ConcurrentQueue(); + _client.acceptCallbacks[_streamId + _bindPort] = msg => + { + Log.Info("ConnectAsync: Accept callback, invoking Completed event"); + var events = (MulticastDelegate) CompletedField.GetValue(e); + foreach (var handler in events?.GetInvocationList() ?? []) + { + Log.Debug($"ConnectAsync: Invoking {handler.Method.Name}"); + handler.DynamicInvoke(e, new SocketAsyncEventArgs { SocketError = SocketError.Success }); + } + }; + _client.sendQ.Enqueue(new Msg + { + cmd = Cmd.CTL_TCP_CONNECT, + proto = _proto, + sid = _streamId, + src = _client.StubIP.ToU32(), sPort = _bindPort, + dst = addr, dPort = remote.Port + }); + RemoteEndPoint = new IPEndPoint(addr.ToIP(), remote.Port); + return true; + } + + // Accept is blocking + public FutariSocket Accept() + { + // Check if accept queue has any pending connections + if (!_client.acceptQ.TryGetValue(_bindPort, out var q) || + !q.TryDequeue(out var msg) || + msg.sid == null || msg.src == null || msg.sPort == null) + { + Log.Warn("Accept: No pending connections"); + return null; + } + + _client.tcpRecvQ[msg.sid.Value + _bindPort] = new ConcurrentQueue(); + _client.sendQ.Enqueue(new Msg + { + cmd = Cmd.CTL_TCP_ACCEPT, proto = _proto, sid = msg.sid, + src = _client.StubIP.ToU32(), sPort = _bindPort, + dst = msg.src, dPort = msg.sPort + }); + + return new FutariSocket(_client, _proto) + { + _streamId = msg.sid.Value, + _bindPort = _bindPort, + RemoteEndPoint = new IPEndPoint(msg.src.Value.ToIP(), msg.sPort.Value) + }; + } + + public int Send(byte[] buffer, int offset, int size, SocketFlags socketFlags) + { + if (RemoteEndPoint is not IPEndPoint remote) throw new InvalidOperationException("RemoteEndPoint is not set"); + Log.Debug($"Send: {size} bytes"); + // Remote EP is not relevant here, because the stream is already established, + // there can only be one remote endpoint + _client.sendQ.Enqueue(new Msg + { + cmd = Cmd.DATA_SEND, proto = _proto, data = buffer.View(offset, size).B64(), + sid = _streamId == -1 ? null : _streamId, + src = _client.StubIP.ToU32(), sPort = _bindPort, + dst = remote.Address.ToU32(), dPort = remote.Port + }); + return size; + } + + // Only used in BroadcastSocket + public int SendTo(byte[] buffer, int offset, int size, SocketFlags socketFlags, EndPoint remoteEP) + { + Log.Error("SendTo: Blocked"); + return 0; + + Log.Debug($"SendTo: {size} bytes"); + if (remoteEP is not IPEndPoint remote) return 0; + _client.sendQ.Enqueue(new Msg + { + cmd = Cmd.DATA_BROADCAST, proto = _proto, data = buffer.View(offset, size).B64(), + src = _client.StubIP.ToU32(), sPort = _bindPort, + dst = remote.Address.ToU32(), dPort = remote.Port + }); + return size; + } + + // Only used in TCP ConnectSocket + public int Receive(byte[] buffer, int offset, int size, SocketFlags socketFlags, out SocketError errorCode) + { + if (!_client.tcpRecvQ.TryGetValue(_streamId + _bindPort, out var q) || + !q.TryDequeue(out var msg)) + { + Log.Warn("Receive: No data to receive"); + errorCode = SocketError.WouldBlock; + return 0; + } + var data = msg.data!.B64(); + Log.Debug($"Receive: {data.Length} bytes, {q.Count} left in queue"); + + Buffer.BlockCopy(data, 0, buffer, 0, data.Length); + errorCode = SocketError.Success; + return data.Length; + } + + // Only used in UdpRecvSocket to receive from 0 (broadcast) + public int ReceiveFrom(byte[] buffer, SocketFlags socketFlags, ref EndPoint remoteEP) + { + Log.Error("ReceiveFrom: Blocked"); + return 0; + + if (!_client.udpRecvQ.TryGetValue(_bindPort, out var q) || + !q.TryDequeue(out var msg)) + { + Log.Warn("ReceiveFrom: No data to receive"); + return 0; + } + var data = msg.data?.B64() ?? []; + Log.Debug($"ReceiveFrom: {data.Length} bytes"); + + // Set remote endpoint to the sender + if (msg.src.HasValue) + remoteEP = new IPEndPoint(msg.src.Value.ToIP(), msg.sPort ?? 0); + + Buffer.BlockCopy(data, 0, buffer, 0, data.Length); + return data.Length; + } + + // Called everywhere, but only relevant for TCP + public void Close() + { + // TCP FIN/RST + if (_proto == ProtocolType.Tcp) + _client.sendQ.Enqueue(new Msg { cmd = Cmd.CTL_TCP_CLOSE, proto = _proto }); + } + + public void Shutdown(SocketShutdown how) => Close(); +} \ No newline at end of file diff --git a/AquaMai.Mods/WorldsLink/FutariTypes.cs b/AquaMai.Mods/WorldsLink/FutariTypes.cs new file mode 100644 index 0000000..552c5ff --- /dev/null +++ b/AquaMai.Mods/WorldsLink/FutariTypes.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; +using System.Text; +using Manager.Party.Party; +using MelonLoader; + +namespace AquaMai.Mods.WorldsLink; + +public static class PrefixRet +{ + public const bool BLOCK_ORIGINAL = false; + public const bool RUN_ORIGINAL = true; + + public static bool Run(Action action) => RUN_ORIGINAL.Also(_ => action()); + public static bool Block(Action action) => BLOCK_ORIGINAL.Also(_ => action()); +} + +public enum Cmd +{ + // Control plane + CTL_START = 1, + CTL_HEARTBEAT = 3, + CTL_TCP_CONNECT = 4, // Accept a new multiplexed TCP stream + CTL_TCP_ACCEPT = 5, + CTL_TCP_CLOSE = 7, + + // Data plane + DATA_SEND = 21, + DATA_BROADCAST = 22, +} + +public class RecruitRecord +{ + public RecruitInfo RecruitInfo; + public string Keychip; +} + +public struct Msg +{ + public Cmd cmd; + public ProtocolType? proto; + public int? sid; + public uint? src; + public int? sPort; + public uint? dst; + public int? dPort; + public string? data; + + public override string ToString() + { + int? proto_ = proto == null ? null : (int) proto; + var arr = new object[] { + 1, (int) cmd, proto_, sid, src, sPort, dst, dPort, + null, null, null, null, null, null, null, null, // reserved for future use + data + }; + + // Map nulls to empty strings + return string.Join(",", arr.Select(x => x ?? "")).TrimEnd(','); + } + + private static T? Parse(string[] fields, int i) where T : struct + => fields.Length <= i || fields[i] == "" ? null + : (T) Convert.ChangeType(fields[i], typeof(T)); + + + public static Msg FromString(string str) + { + var fields = str.Split(','); + return new Msg + { + cmd = (Cmd) (Parse(fields, 1) ?? throw new InvalidOperationException("cmd is required")), + proto = Parse(fields, 2)?.Let(it => (ProtocolType) it), + sid = Parse(fields, 3), + src = Parse(fields, 4), + sPort = Parse(fields, 5), + dst = Parse(fields, 6), + dPort = Parse(fields, 7), + data = string.Join(",", fields.Skip(16)) + }; + } + + public string ToReadableString() + { + var parts = new List { cmd.ToString() }; + + if (proto.HasValue) parts.Add(proto.ToString()); + if (sid.HasValue) parts.Add($"Stream: {sid}"); + if (src.HasValue) parts.Add($"Src: {src?.ToIP()}:{sPort}"); + if (dst.HasValue) parts.Add($"Dst: {dst?.ToIP()}:{dPort}"); + if (!string.IsNullOrEmpty(data)) + { + try { parts.Add(Encoding.UTF8.GetString(data.B64())); } + catch { parts.Add(data); } + } + + return string.Join(" | ", parts); + } +} + +public static class Log +{ + private static readonly object _lock = new object(); + + // Text colors + public const string BLACK = "\u001b[30m"; + public const string RED = "\u001b[31m"; + public const string GREEN = "\u001b[32m"; + public const string YELLOW = "\u001b[33m"; + public const string BLUE = "\u001b[34m"; + public const string MAGENTA = "\u001b[35m"; + public const string CYAN = "\u001b[36m"; + public const string WHITE = "\u001b[37m"; + + // Bright text colors + public const string BRIGHT_BLACK = "\u001b[90m"; + public const string BRIGHT_RED = "\u001b[91m"; + public const string BRIGHT_GREEN = "\u001b[92m"; + public const string BRIGHT_YELLOW = "\u001b[93m"; + public const string BRIGHT_BLUE = "\u001b[94m"; + public const string BRIGHT_MAGENTA = "\u001b[95m"; + public const string BRIGHT_CYAN = "\u001b[96m"; + public const string BRIGHT_WHITE = "\u001b[97m"; + + // Reset + public const string RESET = "\u001b[0m"; + + // Remove all non-printable characters + private static string Norm(this string msg) => + string.IsNullOrEmpty(msg) ? msg : + new string(msg.Where(ch => !char.IsControl(ch)).ToArray()); + + public static void Error(string msg) + { + lock (_lock) + { + MelonLogger.Error($"[FUTARI] {RED}ERROR {RESET}{msg.Norm()}{RESET}"); + } + } + + public static void Warn(string msg) + { + lock (_lock) + { + MelonLogger.Warning($"[FUTARI] {YELLOW}WARN {RESET}{msg.Norm()}{RESET}"); + } + } + + public static void Debug(string msg) + { + if (!Futari.Debug) return; + lock (_lock) + { + MelonLogger.Msg($"[FUTARI] {CYAN}DEBUG {RESET}{msg.Norm()}{RESET}"); + } + } + + public static void Info(string msg) + { + lock (_lock) + { + if (msg.StartsWith("A001")) msg = MAGENTA + msg; + if (msg.StartsWith("A002")) msg = CYAN + msg; + MelonLogger.Msg($"[FUTARI] {GREEN}INFO {RESET}{msg.Norm()}{RESET}"); + } + } +}