diff --git a/cheats/MovementAndInputHelpers.cs b/cheats/MovementAndInputHelpers.cs index 8935d64..5553a94 100644 --- a/cheats/MovementAndInputHelpers.cs +++ b/cheats/MovementAndInputHelpers.cs @@ -122,6 +122,17 @@ private static string SanitizeSpoofFriendCode(string input) return string.IsNullOrWhiteSpace(input) ? "" : input.Trim(); } + [HideFromIl2Cpp] + internal static bool CanSendSpoofFriendCode(string input) + { + string value = SanitizeSpoofFriendCode(input); + if (string.IsNullOrEmpty(value)) return false; + + int hash = value.LastIndexOf('#'); + string username = hash > 0 ? value.Substring(0, hash) : value; + return username.Length <= 10; + } + private static string SanitizeHexColor(string input, string fallback) { string value = (input ?? string.Empty).Trim(); diff --git a/features/FriendCodeSpoofPatch.cs b/features/FriendCodeSpoofPatch.cs index e874a7b..ff1db58 100644 --- a/features/FriendCodeSpoofPatch.cs +++ b/features/FriendCodeSpoofPatch.cs @@ -53,6 +53,11 @@ public static bool Prefix( string value = (ElysiumModMenuGUI.spoofFriendCodeInput ?? string.Empty).Trim(); if (string.IsNullOrEmpty(value)) return true; + if (!ElysiumModMenuGUI.CanSendSpoofFriendCode(value)) + { + ElysiumModMenuGUI.ShowNotification("[SPOOF FC] Server limit: 10 characters."); + return false; + } int hash = value.LastIndexOf('#'); if (hash <= 0) @@ -154,6 +159,11 @@ public static bool Prefix(EditAccountUsername __instance) string value = (ElysiumModMenuGUI.spoofFriendCodeInput ?? string.Empty).Trim(); if (string.IsNullOrEmpty(value)) return true; + if (!ElysiumModMenuGUI.CanSendSpoofFriendCode(value)) + { + ElysiumModMenuGUI.ShowNotification("[SPOOF FC] Server limit: 10 characters."); + return false; + } try { @@ -170,8 +180,9 @@ public static bool Prefix(EditAccountUsername __instance) mgr.SetFriendCode(value, callback); return false; } - catch + catch (Exception ex) { + Plugin.Instance?.Log?.LogWarning((object)$"[SPOOF FC] Submit failed: {ex.Message}"); return true; } } diff --git a/features/PlayerHistoryStore.cs b/features/PlayerHistoryStore.cs index 89c89db..9fe25c9 100644 --- a/features/PlayerHistoryStore.cs +++ b/features/PlayerHistoryStore.cs @@ -514,7 +514,8 @@ private static void RebuildPlayerHistoryViewCache() Identity = $"Lv: {e.Level} | FC: {e.FriendCode} | PUID: {e.Puid}", Times = $"Joined: {e.FirstSeenUtc:HH:mm:ss} | Left: {(e.LeftUtc.HasValue ? e.LeftUtc.Value.ToString("HH:mm:ss") : "online")}", Platform = $"Platform: {FormatPlatformHistory(e)}", - Rpc = $"RPC: {FormatRpcHistory(e)}" + Rpc = $"RPC: {FormatRpcHistory(e)}", + Puid = e.Puid }); } diff --git a/menu/MenuInputAndTabs.cs b/menu/MenuInputAndTabs.cs index 86885c2..d65544e 100644 --- a/menu/MenuInputAndTabs.cs +++ b/menu/MenuInputAndTabs.cs @@ -490,6 +490,7 @@ private sealed class PlayerHistoryViewRow public string Times; public string Platform; public string Rpc; + public string Puid; } private static readonly Dictionary playerHistoryEntryLookup = new Dictionary(); @@ -498,7 +499,7 @@ private sealed class PlayerHistoryViewRow private static bool playerHistoryViewDirty = true; -private const float PlayerHistoryRowHeight = 92f; +private const float PlayerHistoryRowHeight = 108f; private static bool playerHistoryLoaded = false; diff --git a/uimenu/PlayerAccounts.cs b/uimenu/PlayerAccounts.cs new file mode 100644 index 0000000..b21933f --- /dev/null +++ b/uimenu/PlayerAccounts.cs @@ -0,0 +1,525 @@ +#nullable disable +using Epic.OnlineServices; +using Epic.OnlineServices.Connect; +using Il2CppInterop.Runtime.Attributes; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace ElysiumModMenu +{ + public partial class ElysiumModMenuGUI + { + private sealed class PlayerAccountEntry + { + public ExternalAccountType type; + public string name; + public string id; + public string lastLogin; + } + + private readonly Dictionary> playerAccountsByPuid = new Dictionary>(); + private readonly Dictionary playerAccountTextByPuid = new Dictionary(); + private readonly HashSet playerAccountLoaded = new HashSet(); + private readonly HashSet playerAccountFailed = new HashSet(); + private readonly HashSet playerAccountQueued = new HashSet(); + private readonly Queue playerAccountQueue = new Queue(); + private string playerAccountLookupPuid; + private int playerAccountPendingId; + private float playerAccountRequestAt; + + [HideFromIl2Cpp] + private string GetPlayerHistoryAccountText(string puid) + { + UpdatePlayerAccountLookup(); + + if (string.IsNullOrWhiteSpace(puid) || puid == "Unknown") + return L("Accounts: unavailable", "Аккаунты: недоступны"); + + puid = puid.Trim(); + if (playerAccountLoaded.Contains(puid)) + { + if (playerAccountTextByPuid.TryGetValue(puid, out string text)) + return $"{L("Accounts:", "Аккаунты:")} {text}"; + + return playerAccountFailed.Contains(puid) + ? L("Accounts: cache unavailable", "Аккаунты: кэш недоступен") + : L("Accounts: not cached", "Аккаунты: нет в кэше"); + } + + LoadCachedPlayerAccounts(puid); + UpdatePlayerAccountLookup(); + if (!playerAccountLoaded.Contains(puid)) + return L("Accounts: searching...", "Аккаунты: поиск..."); + + if (playerAccountTextByPuid.TryGetValue(puid, out string accountText)) + return $"{L("Accounts:", "Аккаунты:")} {accountText}"; + + return playerAccountFailed.Contains(puid) + ? L("Accounts: cache unavailable", "Аккаунты: кэш недоступен") + : L("Accounts: not cached", "Аккаунты: нет в кэше"); + } + + [HideFromIl2Cpp] + private void LoadCachedPlayerAccounts(string puid) + { + if (playerAccountLoaded.Contains(puid) || playerAccountQueued.Contains(puid) || playerAccountLookupPuid == puid) return; + + playerAccountsByPuid[puid] = new List(); + ReadLocalPlayerAccounts(puid); + ReadCachedPlayerAccounts(puid); + playerAccountQueued.Add(puid); + playerAccountQueue.Enqueue(puid); + } + + [HideFromIl2Cpp] + private void ReadLocalPlayerAccounts(string puid) + { + try + { + EOSManager eos = EOSManager.Instance; + ProductUserId local = eos?.userId; + if (local == null || local.ToString() != puid) return; + + if (eos.linkedExternalAccounts != null) + { + for (int i = 0; i < eos.linkedExternalAccounts.Count; i++) + { + try + { + AddPlayerAccount(puid, eos.linkedExternalAccounts[i]); + } + catch (Exception ex) + { + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] Local account {i} read failed: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + playerAccountFailed.Add(puid); + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] Local cache read failed: {ex.Message}"); + } + } + + [HideFromIl2Cpp] + private void ReadCachedPlayerAccounts(string puid) + { + try + { + EOSManager eos = EOSManager.Instance; + var connect = eos?.PlatformInterface?.GetConnectInterface(); + ProductUserId targetPuid = ProductUserId.FromString(puid); + if (connect == null || targetPuid == null || !targetPuid.IsValid()) + { + playerAccountFailed.Add(puid); + return; + } + + var countOptions = new GetProductUserExternalAccountCountOptions + { + TargetUserId = targetPuid + }; + uint count = connect.GetProductUserExternalAccountCount(ref countOptions); + for (uint i = 0; i < count; i++) + { + try + { + var copyOptions = new CopyProductUserExternalAccountByIndexOptions + { + TargetUserId = targetPuid, + ExternalAccountInfoIndex = i + }; + Result result = connect.CopyProductUserExternalAccountByIndex(ref copyOptions, out var info); + if (result == Result.Success && info.HasValue) + AddPlayerAccount(puid, info.Value); + else if (result != Result.Success) + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] EOS account {i}/{count} copy failed: {result}"); + } + catch (Exception ex) + { + playerAccountFailed.Add(puid); + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] EOS account {i}/{count} read failed: {ex.Message}"); + } + } + } + catch (Exception ex) + { + playerAccountFailed.Add(puid); + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] EOS cache read failed: {ex.Message}"); + } + } + + [HideFromIl2Cpp] + private void UpdatePlayerAccountLookup() + { + if (playerAccountPendingId != 0) + { + if (!PlayerAccountNative.TryTake(playerAccountPendingId, out string puid, out int result)) + { + if (UnityEngine.Time.realtimeSinceStartup - playerAccountRequestAt < 12f) + return; + + PlayerAccountNative.Forget(playerAccountPendingId); + puid = playerAccountLookupPuid; + result = -1; + } + + playerAccountPendingId = 0; + if (result == (int)Result.Success) + ReadCachedPlayerAccounts(puid); + else + { + playerAccountFailed.Add(puid); + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] PUID mapping failed for {puid}: {result}"); + } + + FinishPlayerAccountLookup(puid); + playerAccountQueued.Remove(puid); + playerAccountLookupPuid = null; + } + + if (string.IsNullOrEmpty(playerAccountLookupPuid)) + { + if (playerAccountQueue.Count == 0) return; + playerAccountLookupPuid = playerAccountQueue.Dequeue(); + } + + try + { + EOSManager eos = EOSManager.Instance; + var connect = eos?.PlatformInterface?.GetConnectInterface(); + ProductUserId local = eos?.userId; + ProductUserId target = ProductUserId.FromString(playerAccountLookupPuid); + if (connect == null || local == null || target == null || !local.IsValid() || !target.IsValid()) + { + playerAccountFailed.Add(playerAccountLookupPuid); + FinishPlayerAccountLookup(playerAccountLookupPuid); + playerAccountQueued.Remove(playerAccountLookupPuid); + playerAccountLookupPuid = null; + return; + } + + playerAccountPendingId = PlayerAccountNative.Start( + connect.InnerHandle, + local.InnerHandle, + target.InnerHandle, + playerAccountLookupPuid); + playerAccountRequestAt = UnityEngine.Time.realtimeSinceStartup; + } + catch (Exception ex) + { + playerAccountFailed.Add(playerAccountLookupPuid); + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] Native PUID lookup failed: {ex.Message}"); + FinishPlayerAccountLookup(playerAccountLookupPuid); + playerAccountQueued.Remove(playerAccountLookupPuid); + playerAccountLookupPuid = null; + playerAccountPendingId = 0; + } + } + + [HideFromIl2Cpp] + private void AddPlayerAccount(string puid, ExternalAccountInfo account) + { + if (account == null) return; + + ExternalAccountType type = account.AccountIdType; + string name = string.Empty; + string id = string.Empty; + string lastLogin = string.Empty; + + try + { + if (account.DisplayName != null) + name = account.DisplayName.ToString(); + } + catch (Exception ex) + { + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] {type} name read failed: {ex.Message}"); + } + + try + { + if (account.AccountId != null) + id = account.AccountId.ToString(); + } + catch (Exception ex) + { + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] {type} id read failed: {ex.Message}"); + } + + try + { + var login = account.LastLoginTime; + if (login.HasValue) + lastLogin = login.Value.ToString("dd.MM.yyyy HH:mm:ss"); + } + catch (Exception ex) + { + Plugin.Instance?.Log?.LogWarning((object)$"[ACCOUNTS] {type} login time read failed: {ex.Message}"); + } + + AddPlayerAccount(puid, type, name, id, lastLogin); + } + + [HideFromIl2Cpp] + private void AddPlayerAccount(string puid, ExternalAccountType type, string name, string id, string lastLogin) + { + if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(id)) return; + + if (!playerAccountsByPuid.TryGetValue(puid, out List accounts)) + { + accounts = new List(); + playerAccountsByPuid[puid] = accounts; + } + + PlayerAccountEntry entry = accounts.Find(x => + x.type == type && + ((!string.IsNullOrWhiteSpace(id) && x.id == id) || + (!string.IsNullOrWhiteSpace(name) && x.name == name && + (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(x.id))))); + if (entry == null) + { + entry = new PlayerAccountEntry(); + accounts.Add(entry); + } + + entry.type = type; + if (!string.IsNullOrWhiteSpace(name)) entry.name = name; + if (!string.IsNullOrWhiteSpace(id)) entry.id = id; + if (!string.IsNullOrWhiteSpace(lastLogin)) entry.lastLogin = lastLogin; + } + + [HideFromIl2Cpp] + private void FinishPlayerAccountLookup(string puid) + { + if (playerAccountsByPuid.TryGetValue(puid, out List accounts) && accounts.Count > 0) + { + string[] names = new string[accounts.Count]; + var typeCounts = new Dictionary(); + for (int i = 0; i < accounts.Count; i++) + { + PlayerAccountEntry account = accounts[i]; + string name = string.IsNullOrWhiteSpace(account.name) ? account.id : account.name; + typeCounts.TryGetValue(account.type, out int count); + count++; + typeCounts[account.type] = count; + string type = AccountTypeName(account.type); + if (count > 1) type += $" ({count})"; + names[i] = string.IsNullOrWhiteSpace(account.lastLogin) + ? $"{type}: {name}" + : $"{type}: {name} [{account.lastLogin}]"; + } + playerAccountTextByPuid[puid] = string.Join(" | ", names); + } + + playerAccountLoaded.Add(puid); + } + + private void ClearPlayerAccountCache() + { + if (playerAccountPendingId != 0) + PlayerAccountNative.Forget(playerAccountPendingId); + + playerAccountsByPuid.Clear(); + playerAccountTextByPuid.Clear(); + playerAccountLoaded.Clear(); + playerAccountFailed.Clear(); + playerAccountQueued.Clear(); + playerAccountQueue.Clear(); + playerAccountLookupPuid = null; + playerAccountPendingId = 0; + playerAccountRequestAt = 0f; + } + + private static string AccountTypeName(ExternalAccountType type) + { + switch (type) + { + case ExternalAccountType.Epic: return "Epic"; + case ExternalAccountType.Steam: return "Steam"; + case ExternalAccountType.Psn: return "PlayStation"; + case ExternalAccountType.Xbl: return "Microsoft / Xbox"; + case ExternalAccountType.Discord: return "Discord"; + case ExternalAccountType.Gog: return "GOG"; + case ExternalAccountType.Nintendo: return "Nintendo"; + case ExternalAccountType.Uplay: return "Ubisoft"; + case ExternalAccountType.Openid: return "OpenID"; + case ExternalAccountType.Apple: return "Apple"; + case ExternalAccountType.Google: return "Google"; + case ExternalAccountType.Oculus: return "Oculus"; + case ExternalAccountType.Itchio: return "Itch"; + case ExternalAccountType.Amazon: return "Amazon"; + case ExternalAccountType.Viveport: return "Viveport"; + default: return type.ToString(); + } + } + } + + internal static class PlayerAccountNative + { + [StructLayout(LayoutKind.Sequential)] + private struct QueryOptions + { + public int ApiVersion; + public IntPtr LocalUserId; + public int AccountIdTypeDeprecated; + public IntPtr ProductUserIds; + public uint ProductUserIdCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct QueryCallbackInfo + { + public int ResultCode; + public IntPtr ClientData; + public IntPtr LocalUserId; + } + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private delegate void QueryCallback(IntPtr data); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private delegate void QueryProductUserIdMappings( + IntPtr handle, + ref QueryOptions options, + IntPtr clientData, + QueryCallback completion); + + private sealed class QueryState + { + public string puid; + public IntPtr ids; + public bool complete; + public bool discard; + public int result; + } + + private static readonly object sync = new object(); + private static readonly Dictionary queries = new Dictionary(); + private static readonly QueryCallback callback = OnQueryComplete; + private static QueryProductUserIdMappings queryMappings; + private static IntPtr eosLibrary; + private static int nextId; + + private static void LoadEos() + { + if (queryMappings != null) return; + + lock (sync) + { + if (queryMappings != null) return; + + bool is64 = IntPtr.Size == 8; + string path = System.IO.Path.Combine(UnityEngine.Application.dataPath, + "Plugins", is64 ? "x86_64" : "x86", + is64 ? "EOSSDK-Win64-Shipping.dll" : "EOSSDK-Win32-Shipping.dll"); + string entry = is64 + ? "EOS_Connect_QueryProductUserIdMappings" + : "_EOS_Connect_QueryProductUserIdMappings@16"; + eosLibrary = NativeLibrary.Load(path); + IntPtr ptr = NativeLibrary.GetExport(eosLibrary, entry); + queryMappings = Marshal.GetDelegateForFunctionPointer(ptr); + } + } + + internal static int Start(IntPtr connect, IntPtr local, IntPtr target, string puid) + { + LoadEos(); + + IntPtr ids = Marshal.AllocHGlobal(IntPtr.Size); + Marshal.WriteIntPtr(ids, target); + + int id; + lock (sync) + { + id = ++nextId; + if (id == 0) id = ++nextId; + queries[id] = new QueryState + { + puid = puid, + ids = ids + }; + } + + var options = new QueryOptions + { + ApiVersion = 2, + LocalUserId = local, + AccountIdTypeDeprecated = 0, + ProductUserIds = ids, + ProductUserIdCount = 1 + }; + + try + { + queryMappings(connect, ref options, new IntPtr(id), callback); + return id; + } + catch + { + lock (sync) + queries.Remove(id); + Marshal.FreeHGlobal(ids); + throw; + } + } + + internal static bool TryTake(int id, out string puid, out int result) + { + lock (sync) + { + if (queries.TryGetValue(id, out QueryState state) && state.complete) + { + puid = state.puid; + result = state.result; + queries.Remove(id); + return true; + } + } + + puid = null; + result = 0; + return false; + } + + internal static void Forget(int id) + { + lock (sync) + { + if (!queries.TryGetValue(id, out QueryState state)) return; + if (state.complete) + queries.Remove(id); + else + state.discard = true; + } + } + + private static void OnQueryComplete(IntPtr data) + { + try + { + if (data == IntPtr.Zero) return; + QueryCallbackInfo info = Marshal.PtrToStructure(data); + int id = info.ClientData.ToInt32(); + + lock (sync) + { + if (!queries.TryGetValue(id, out QueryState state)) return; + state.result = info.ResultCode; + state.complete = true; + if (state.ids != IntPtr.Zero) + { + Marshal.FreeHGlobal(state.ids); + state.ids = IntPtr.Zero; + } + if (state.discard) + queries.Remove(id); + } + } + catch { } + } + } + +} diff --git a/uimenu/PlayersHistoryTab.cs b/uimenu/PlayersHistoryTab.cs index ea73772..e0ad180 100644 --- a/uimenu/PlayersHistoryTab.cs +++ b/uimenu/PlayersHistoryTab.cs @@ -57,7 +57,17 @@ private void DrawPlayersHistoryTab() GUILayout.Label($"Entries: {playerHistoryEntries.Count}", historyInfoStyle, GUILayout.MinWidth(128), GUILayout.ExpandWidth(false), GUILayout.Height(24)); GUILayout.Label(L("File: ElysiumPlayerHistory.txt", "Файл: ElysiumPlayerHistory.txt"), historyInfoStyle, GUILayout.MinWidth(220), GUILayout.ExpandWidth(false), GUILayout.Height(24)); GUILayout.FlexibleSpace(); - if (GUILayout.Button(L("Clear History", "ОЧИСТИТЬ ИСТОРИЮ"), btnStyle, GUILayout.Width(136), GUILayout.Height(24))) + } + finally { GUILayout.EndHorizontal(); } + + GUILayout.BeginHorizontal(); + try + { + GUILayout.FlexibleSpace(); + if (GUILayout.Button(L("Refresh Accounts", "ОБНОВИТЬ АККАУНТЫ"), btnStyle, GUILayout.Width(154), GUILayout.Height(24))) + ClearPlayerAccountCache(); + GUILayout.Space(4); + if (GUILayout.Button(L("Clear History", "ОЧИСТИТЬ ИСТОРИЮ"), btnStyle, GUILayout.Width(136), GUILayout.Height(24))) { playerHistoryEntries.Clear(); playerHistoryEntryLookup.Clear(); @@ -65,6 +75,7 @@ private void DrawPlayersHistoryTab() playerHistoryKeysById.Clear(); playerHistoryKeysByClientId.Clear(); playerHistoryLoaded = true; + ClearPlayerAccountCache(); InvalidatePlayerHistoryViewCache(); WritePlayerHistoryFile(); } @@ -85,7 +96,7 @@ private void DrawPlayersHistoryTab() int rowCount = playerHistoryViewRows.Count; int firstIndex = Mathf.Clamp(Mathf.FloorToInt(playersHistoryScroll.y / PlayerHistoryRowHeight), 0, Mathf.Max(0, rowCount - 1)); - int visibleRows = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(180f, windowRect.height - 170f) / PlayerHistoryRowHeight) + 3, 6, 30); + int visibleRows = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(180f, windowRect.height - 198f) / PlayerHistoryRowHeight) + 3, 6, 30); int endIndex = Mathf.Min(rowCount, firstIndex + visibleRows); GUILayout.Space(firstIndex * PlayerHistoryRowHeight); @@ -100,6 +111,7 @@ private void DrawPlayersHistoryTab() GUILayout.Label(row.Times, historyLineStyle, GUILayout.Height(16)); GUILayout.Label(row.Platform, historyWrapStyle, GUILayout.Height(16)); GUILayout.Label(row.Rpc, historyWrapStyle, GUILayout.Height(16)); + GUILayout.Label(GetPlayerHistoryAccountText(row.Puid), historyWrapStyle, GUILayout.Height(16)); } finally { GUILayout.EndVertical(); } GUILayout.Space(2); diff --git a/uimenu/SelfSpoofVisualsTab.cs b/uimenu/SelfSpoofVisualsTab.cs index cfe5983..d5d338c 100644 --- a/uimenu/SelfSpoofVisualsTab.cs +++ b/uimenu/SelfSpoofVisualsTab.cs @@ -234,6 +234,11 @@ private void ApplyPublicFriendCode() { spoofFriendCodeInput = SanitizeSpoofFriendCode(spoofFriendCodeInput); if (string.IsNullOrEmpty(spoofFriendCodeInput)) return; + if (!CanSendSpoofFriendCode(spoofFriendCodeInput)) + { + ShowNotification("[SPOOF FC] Server limit: 10 characters."); + return; + } try {