Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of SansaraCharacters v0.2.1
plugins/SansaraCharacters.dll
Decompiled 7 hours ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Threading; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Jotunn.Utils; using Microsoft.CodeAnalysis; using Sansara.Admin; using SansaraCharacters.Core; using Splatform; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.6.2", FrameworkDisplayName = ".NET Framework 4.6.2")] [assembly: AssemblyCompany("SansaraCharacters")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("0.2.1.0")] [assembly: AssemblyInformationalVersion("0.2.1+b3c36db37415dcfd0ba5e58ce65d745e02185986")] [assembly: AssemblyProduct("SansaraCharacters")] [assembly: AssemblyTitle("SansaraCharacters")] [assembly: AssemblyVersion("0.2.1.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace Sansara.Admin { internal static class AdminChannel { private const float ScanSeconds = 1f; private const float DenialLogSeconds = 30f; private static ManualLogSource _log; private static bool _owner; private static bool _announced; private static float _nextScan; private static readonly HashSet<ZRpc> Registered = new HashSet<ZRpc>(); private static readonly Dictionary<ZRpc, float> LastDenial = new Dictionary<ZRpc, float>(); public static void Register(string module, string[] verbs, Func<string, string, string, string> handler, ManualLogSource log) { _log = log; if (AdminRegistry.Shared.Register(module, verbs, handler)) { log.LogWarning((object)("Админ-канал: модуль " + module + " зарегистрирован повторно — обработчик заменён.")); } if (AdminRegistry.Shared.ClaimOwner(typeof(AdminChannel).Assembly.GetName().Name)) { _owner = true; } } public static void Tick() { if (!_owner || (Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer() || Time.realtimeSinceStartup < _nextScan) { return; } _nextScan = Time.realtimeSinceStartup + 1f; try { Scan(); } catch (Exception ex) { _log.LogError((object)("Админ-канал: ошибка: " + ex)); } } public static void CheckFile(string guid, string logPrefix, Func<string, string, string, string> handler) { string path = Path.Combine(Paths.ConfigPath, guid + ".command"); if (!File.Exists(path)) { return; } string text; try { text = File.ReadAllText(path).Trim(); File.Delete(path); } catch (Exception ex) { _log.LogWarning((object)(logPrefix + ": файл команды не прочитан: " + ex.Message)); return; } _log.LogInfo((object)(logPrefix + ": команда «" + AdminProtocol.ForLog(text) + "».")); try { _log.LogInfo((object)(logPrefix + ": " + handler(text, "файл команд", "").Replace("\n", "; "))); } catch (Exception ex2) { _log.LogError((object)(logPrefix + ": ошибка команды: " + ex2)); } } private static void Scan() { if (!_announced) { _announced = true; _log.LogInfo((object)("Админ-канал: владелец " + AdminRegistry.Shared.Owner + ", модули: " + string.Join(", ", AdminRegistry.Shared.Modules().ToArray()) + ".")); } HashSet<ZRpc> live = new HashSet<ZRpc>(); foreach (ZNetPeer peer in ZNet.instance.GetPeers()) { if (peer != null && peer.m_rpc != null) { live.Add(peer.m_rpc); if (Registered.Add(peer.m_rpc)) { peer.m_rpc.Register<ZPackage>("Sansara_Admin", (Action<ZRpc, ZPackage>)OnRequest); } } } Registered.RemoveWhere((ZRpc r) => !live.Contains(r)); foreach (ZRpc item in new List<ZRpc>(LastDenial.Keys)) { if (!live.Contains(item)) { LastDenial.Remove(item); } } } private static void OnRequest(ZRpc rpc, ZPackage pkg) { //IL_00b7: Unknown result type (might be due to invalid IL or missing references) //IL_00bd: Expected O, but got Unknown int num = 0; AdminReply adminReply; try { int protocol = pkg.ReadInt(); num = pkg.ReadInt(); string module = pkg.ReadString(); string line = pkg.ReadString(); adminReply = Handle(rpc, protocol, module, line); } catch (Exception ex) { ZNetPeer val = ZNet.instance.GetPeers().Find((ZNetPeer p) => p.m_rpc == rpc); if (val == null || !val.IsReady()) { return; } LogThrottled(rpc, "Админ-канал: неверный запрос от " + AdminProtocol.ForLog(val.m_playerName) + ": " + AdminProtocol.ForLog(ex.Message)); adminReply = new AdminReply(4, "неверный запрос"); } if (adminReply != null) { ZPackage val2 = new ZPackage(); val2.Write(1); val2.Write(num); val2.Write(adminReply.Code); val2.Write(AdminProtocol.Truncate(adminReply.Text)); rpc.Invoke("Sansara_AdminReply", new object[1] { val2 }); } } private static void LogThrottled(ZRpc rpc, string message) { float realtimeSinceStartup = Time.realtimeSinceStartup; if (!LastDenial.TryGetValue(rpc, out var value) || realtimeSinceStartup - value >= 30f) { LastDenial[rpc] = realtimeSinceStartup; _log.LogWarning((object)message); } } private static AdminReply Handle(ZRpc rpc, int protocol, string module, string line) { ZNetPeer val = ZNet.instance.GetPeers().Find((ZNetPeer p) => p.m_rpc == rpc); if (val == null || !val.IsReady()) { return null; } AdminReply adminReply = AdminProtocol.Check(protocol, module, line); if (adminReply != null) { return adminReply; } string text = AdminProtocol.ForLog(val.m_playerName); string text2 = AdminProtocol.ForLog(val.m_socket.GetHostName()); string text3 = AdminProtocol.ForLog(AdminProtocol.Shown(module, line)); bool flag = ((ZDOID)(ref val.m_characterID)).IsNone(); if (flag || !ZNet.instance.IsAdmin(val.m_socket.GetHostName())) { LogThrottled(rpc, "Админ-канал: отказано " + text + " (" + text2 + "): " + text3); return new AdminReply(1, flag ? "отказано: персонаж не в мире (ещё не вошёл или погиб)" : "отказано: нужны права администратора"); } Exception error; AdminReply adminReply2 = AdminRegistry.Shared.Dispatch(module, line, val.m_playerName, val.m_socket.GetHostName(), out error); if (error != null) { _log.LogError((object)("Админ-канал: " + text + " (" + text2 + ") → " + text3 + ": " + AdminProtocol.ForLog(error.Message) + Environment.NewLine + error.StackTrace)); } else { _log.LogInfo((object)("Админ-канал: " + text + " (" + text2 + ") → " + text3 + ": " + AdminProtocol.ForLog(AdminProtocol.FirstLine(adminReply2.Text)))); } return adminReply2; } } public sealed class AdminCommand { public string Verb = ""; public readonly List<string> Args = new List<string>(); public string Argument => string.Join(" ", Args); public static AdminCommand Parse(string text) { AdminCommand adminCommand = new AdminCommand(); List<string> list = Tokens(text); if (list.Count == 0) { return adminCommand; } adminCommand.Verb = list[0].ToLowerInvariant(); adminCommand.Args.AddRange(list.GetRange(1, list.Count - 1)); return adminCommand; } public static List<string> Tokens(string text) { List<string> list = new List<string>(); StringBuilder stringBuilder = new StringBuilder(); bool flag = false; bool flag2 = false; string text2 = text ?? ""; foreach (char c in text2) { if (c == '"') { flag = !flag; flag2 = true; } else if (!flag && char.IsWhiteSpace(c)) { if (flag2) { list.Add(stringBuilder.ToString()); stringBuilder.Clear(); flag2 = false; } } else { stringBuilder.Append(c); flag2 = true; } } if (flag2) { list.Add(stringBuilder.ToString()); } return list; } public static bool TryId(string argument, out string id) { string text = (argument ?? "").Trim(); id = ((text.Length > 1 && text[0] == '@') ? text.Substring(1).Trim() : null); if (id == "") { id = null; } return id != null; } } public sealed class AdminReply { public readonly int Code; public readonly string Text; public AdminReply(int code, string text) { Code = code; Text = text ?? ""; } } public static class AdminProtocol { public const int Version = 1; public const string RequestRpc = "Sansara_Admin"; public const string ReplyRpc = "Sansara_AdminReply"; public const int MaxLine = 256; public const int MaxReply = 4000; public const int Done = 0; public const int Denied = 1; public const int NoModule = 2; public const int Failed = 3; public const int Invalid = 4; public const int MaxLogText = 300; public static string Truncate(string text) { string text2 = text ?? ""; if (text2.Length > 4000) { return text2.Substring(0, 3999) + "…"; } return text2; } public static AdminReply Check(int protocol, string module, string line) { if (protocol != 1) { return new AdminReply(4, $"неверный запрос: обновите Sansara Admin (протокол сервера {1})"); } if (module == null || line == null) { return new AdminReply(4, "неверный запрос"); } if (Shown(module, line).Length > 256) { return new AdminReply(4, $"строка длиннее {256} символов"); } return null; } public static string Shown(string module, string line) { string text = (module ?? "").Trim(); if (!(text == "")) { return (text + " " + (line ?? "").Trim()).Trim(); } return "help"; } public static string ForLog(string s) { char[] array = (s ?? "").ToCharArray(); for (int i = 0; i < array.Length; i++) { if (char.IsControl(array[i])) { array[i] = ' '; } } if (array.Length > 300) { return new string(array, 0, 300); } return new string(array); } public static string FirstLine(string text) { string text2 = (text ?? "").Replace("\r", ""); int num = text2.IndexOf('\n'); if (num >= 0) { return text2.Substring(0, num); } return text2; } public static string HelpLine(string module, IEnumerable<string> verbs) { return module + ": " + string.Join(", ", verbs); } public static Dictionary<string, string[]> ParseHelp(string text) { Dictionary<string, string[]> dictionary = new Dictionary<string, string[]>(StringComparer.Ordinal); string[] array = (text ?? "").Replace("\r", "").Split(new char[1] { '\n' }); foreach (string text2 in array) { int num = text2.IndexOf(':'); if (num <= 0) { continue; } string text3 = text2.Substring(0, num).Trim(); if (IsName(text3)) { dictionary[text3] = (from v in text2.Substring(num + 1).Split(new char[1] { ',' }) select v.Trim()).Where(IsName).ToArray(); } } return dictionary; } public static bool IsName(string s) { if (!string.IsNullOrEmpty(s)) { return s.All((char c) => c >= 'a' && c <= 'z'); } return false; } public static string Unknown(string verb, IEnumerable<string> verbs) { return (string.IsNullOrEmpty(verb) ? "укажите действие" : ("неизвестное действие «" + verb + "»")) + "; есть: " + string.Join(", ", verbs); } } public sealed class AdminRegistry { public const string SharedKey = "sansara.Admin.v1"; public static readonly AdminRegistry Shared = new AdminRegistry("sansara.Admin.v1"); private readonly string _key; private Dictionary<string, Tuple<string[], Func<string, string, string, string>>> Table { get { object data = AppDomain.CurrentDomain.GetData(_key); if (data == null) { Dictionary<string, Tuple<string[], Func<string, string, string, string>>> dictionary = new Dictionary<string, Tuple<string[], Func<string, string, string, string>>>(StringComparer.Ordinal); AppDomain.CurrentDomain.SetData(_key, dictionary); return dictionary; } return (data as Dictionary<string, Tuple<string[], Func<string, string, string, string>>>) ?? throw new InvalidOperationException("данные " + _key + " другого формата"); } } public string Owner => (AppDomain.CurrentDomain.GetData(_key + ".owner") as string) ?? ""; public AdminRegistry(string key) { _key = key; } public bool Register(string module, string[] verbs, Func<string, string, string, string> handler) { string text = (module ?? "").Trim().ToLowerInvariant(); if (!AdminProtocol.IsName(text)) { throw new ArgumentException("имя модуля — латинские буквы: " + module); } string[] array = verbs ?? new string[0]; foreach (string text2 in array) { if (!AdminProtocol.IsName(text2)) { throw new ArgumentException("глагол — латинские буквы: " + text2); } } if (handler == null) { throw new ArgumentNullException("handler"); } Dictionary<string, Tuple<string[], Func<string, string, string, string>>> table = Table; bool result = table.ContainsKey(text); table[text] = Tuple.Create(verbs ?? new string[0], handler); return result; } public bool ClaimOwner(string assembly) { if (string.IsNullOrEmpty(assembly)) { throw new ArgumentException("пустое имя сборки", "assembly"); } string name = _key + ".owner"; if (!(AppDomain.CurrentDomain.GetData(name) is string text)) { AppDomain.CurrentDomain.SetData(name, assembly); return true; } return text == assembly; } public List<string> Modules() { return Table.Keys.OrderBy<string, string>((string k) => k, StringComparer.Ordinal).ToList(); } public string HelpText() { Dictionary<string, Tuple<string[], Func<string, string, string, string>>> table = Table; if (table.Count != 0) { return string.Join("\n", from k in Modules() select AdminProtocol.HelpLine(k, table[k].Item1)); } return "на сервере нет модулей Sansara Admin"; } public AdminReply Dispatch(string module, string line, string adminName, string adminId, out Exception error) { error = null; try { string text = (module ?? "").Trim().ToLowerInvariant(); if (text == "") { return new AdminReply(0, AdminProtocol.Truncate(HelpText())); } Dictionary<string, Tuple<string[], Func<string, string, string, string>>> table = Table; if (!table.TryGetValue(text, out var value)) { return new AdminReply(2, "модуля " + text + " нет на сервере; есть: " + ((table.Count == 0) ? "ничего" : string.Join(", ", Modules()))); } string text2 = value.Item2((line ?? "").Trim(), adminName ?? "", adminId ?? ""); return new AdminReply(0, AdminProtocol.Truncate(string.IsNullOrEmpty(text2) ? "готово" : text2)); } catch (Exception ex) { return new AdminReply(3, AdminProtocol.Truncate("ошибка: " + (error = ex).Message)); } } } public static class AdminUi { public const string Verb = "ui"; public const string Head = "ui=1"; public const string NoteKey = "note"; public const string MoreKey = "more"; private const int Reserve = 16; public static string Field(object value) { char[] array = ((!(value is bool)) ? (Convert.ToString(value, CultureInfo.InvariantCulture) ?? "") : (((bool)value) ? "1" : "0")).ToCharArray(); for (int i = 0; i < array.Length; i++) { if (array[i] == ';') { array[i] = ','; } else if (char.IsControl(array[i])) { array[i] = ' '; } } return new string(array).Trim(); } public static string Row(string key, params object[] fields) { if (!AdminProtocol.IsName(key)) { throw new ArgumentException("ключ строки ui — латинские буквы в нижнем регистре: " + key); } return key + "=" + string.Join(";", (fields ?? new object[0]).Select(Field)); } public static string Note(string text) { return Text(new string[1] { Row("note", text) }); } public static string Text(IEnumerable<string> rows) { List<string> list = (rows ?? Enumerable.Empty<string>()).ToList(); StringBuilder stringBuilder = new StringBuilder("ui=1"); for (int i = 0; i < list.Count; i++) { if (stringBuilder.Length + 1 + list[i].Length > 3984) { stringBuilder.Append('\n').Append("more").Append('=') .Append((list.Count - i).ToString(CultureInfo.InvariantCulture)); break; } stringBuilder.Append('\n').Append(list[i]); } return stringBuilder.ToString(); } } } namespace SansaraCharacters { internal static class CharsAdmin { internal static readonly string[] Verbs = new string[15] { "status", "help", "ui", "list", "info", "backups", "snapshot", "restore", "strip", "skill", "resetskills", "unstuck", "clearpower", "import", "archive" }; private const string HelpText = "Персонаж — имя или @файл (Steam_<id>_<имя>), как в списке; игрока в сети изменения отключают от сервера.\nstatus — сколько персонажей, кто в сети, окно переноса\nlist — все персонажи: файл, в сети ли, когда сохранён, копий\ninfo <персонаж> — вещи, надетое, навыки, еда, сила Форсакена, трофеи\nbackups <персонаж> — резервные копии: дата, вещей, сумма навыков\nsnapshot <персонаж> — снять копию сейчас\nrestore <персонаж> <копия|last> — вернуть персонажа к копии (текущее состояние сначала тоже копируется)\nstrip <персонаж> — очистить, как при переносе: вещи, рецепты, сила, еда (навыки остаются)\nskill <персонаж> <навык> <уровень 0–100> — поставить уровень навыка (Swords, Run, WoodCutting…)\nresetskills <персонаж> — сбросить все навыки\nunstuck <персонаж> — забыть точку выхода: при входе — у кровати или у стартового алтаря\nclearpower <персонаж> — снять силу Форсакена\nimport status | import until <гггг-мм-дд чч:мм> | import close — окно переноса персонажей\narchive <персонаж> — убрать персонажа и его копии в characters_local/archive (вход — как новый)\nui — данные для окна Sansara Admin; ui @<файл> — его копии"; private static bool _namesLoaded; public static string Handle(string line, string admin, string adminId) { AdminCommand c = AdminCommand.Parse(line); if (!ServerSide.Active) { return "модуль работает только на выделенном сервере"; } try { return c.Verb switch { "help" => "Персонаж — имя или @файл (Steam_<id>_<имя>), как в списке; игрока в сети изменения отключают от сервера.\nstatus — сколько персонажей, кто в сети, окно переноса\nlist — все персонажи: файл, в сети ли, когда сохранён, копий\ninfo <персонаж> — вещи, надетое, навыки, еда, сила Форсакена, трофеи\nbackups <персонаж> — резервные копии: дата, вещей, сумма навыков\nsnapshot <персонаж> — снять копию сейчас\nrestore <персонаж> <копия|last> — вернуть персонажа к копии (текущее состояние сначала тоже копируется)\nstrip <персонаж> — очистить, как при переносе: вещи, рецепты, сила, еда (навыки остаются)\nskill <персонаж> <навык> <уровень 0–100> — поставить уровень навыка (Swords, Run, WoodCutting…)\nresetskills <персонаж> — сбросить все навыки\nunstuck <персонаж> — забыть точку выхода: при входе — у кровати или у стартового алтаря\nclearpower <персонаж> — снять силу Форсакена\nimport status | import until <гггг-мм-дд чч:мм> | import close — окно переноса персонажей\narchive <персонаж> — убрать персонажа и его копии в characters_local/archive (вход — как новый)\nui — данные для окна Sansara Admin; ui @<файл> — его копии", "status" => Status(), "ui" => Ui(c), "list" => List(), "info" => WithCharacter(c, 0, Info), "backups" => WithCharacter(c, 0, Backups), "snapshot" => WithCharacter(c, 0, (string f) => Snapshot(f, admin)), "restore" => WithCharacter(c, 1, (string f) => Restore(f, c.Args[c.Args.Count - 1], admin)), "strip" => WithCharacter(c, 0, (string f) => Change(f, admin, "очищен (вещи, рецепты, сила, еда; навыки остались)", delegate { ServerSide.RewritePlayerData(f, PlayerDataStrip.Strip); })), "skill" => WithCharacter(c, 2, (string f) => Skill(f, c.Args[c.Args.Count - 2], c.Args[c.Args.Count - 1], admin)), "resetskills" => WithCharacter(c, 0, (string f) => Change(f, admin, "навыки сброшены", delegate { ServerSide.RewritePlayerData(f, (byte[] d) => PlayerDataView.WithSkills(d, (List<SkillInfo> _) => new List<SkillInfo>())); })), "clearpower" => WithCharacter(c, 0, (string f) => Change(f, admin, "сила Форсакена снята", delegate { ServerSide.RewritePlayerData(f, (byte[] d) => PlayerDataView.WithGuardianPower(d, "")); })), "unstuck" => WithCharacter(c, 0, (string f) => Change(f, admin, "точка выхода в этом мире забыта: при входе — у кровати или у стартового алтаря", delegate { ServerSide.RewriteWithGame(f, delegate(PlayerProfile p) { p.ClearLoguoutPoint(); }); })), "archive" => WithCharacter(c, 0, (string f) => Archive(f, admin)), "import" => Import(c, admin), _ => AdminProtocol.Unknown(c.Verb, Verbs), }; } catch (Exception ex) { Plugin.Log.LogError((object)$"Персонажи: {admin}: «{AdminProtocol.ForLog(line)}»: {ex}"); return "ошибка: " + ex.Message; } } private static List<string> Files() { if (!Directory.Exists(Transport.CharacterFolder)) { return new List<string>(); } return (from f in Directory.GetFiles(Transport.CharacterFolder, "*.fch").Select(Path.GetFileNameWithoutExtension) where !ServerSide.IsTemporary(f) select f).OrderBy<string, string>((string f) => f, StringComparer.Ordinal).ToList(); } private static List<string> BackupNames(string file) { string path = ServerSide.BackupFolder(file); if (!Directory.Exists(path)) { return new List<string>(); } return Directory.GetFiles(path, "*.fch").Select(Path.GetFileNameWithoutExtension).OrderBy<string, string>((string n) => n, StringComparer.Ordinal) .ToList(); } private static string Saved(string file) { return File.GetLastWriteTime(ServerSide.PathOf(file)).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); } private static string Status() { List<string> list = Files(); int num = list.Count((string f) => ServerSide.OnlinePeer(f) != null); return $"персонажей на сервере {list.Count}, в сети {num}, резервных копий {list.Sum((string f) => BackupNames(f).Count)}; " + ImportState(); } private static string ImportState() { try { DateTime? until = ImportWindow.ParseUntil(PluginConfig.ImportUntil.Value); if (!until.HasValue) { return "перенос закрыт"; } return ImportWindow.IsOpen(until, DateTime.Now) ? ("перенос открыт до " + until.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) : ("перенос закрыт (срок " + until.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " прошёл)"); } catch (FormatException) { return "перенос закрыт (ImportUntil в cfg не разобран)"; } } private static string List() { List<string> list = Files(); if (list.Count == 0) { return "персонажей на сервере нет"; } return string.Join("\n", list.Select((string f) => string.Format("{0} — {1}, сохранён {2}, копий {3}", f, (ServerSide.OnlinePeer(f) != null) ? "в сети" : "не в сети", Saved(f), BackupNames(f).Count))); } private static string Ui(AdminCommand c) { List<string> list = new List<string>(); if (c.Args.Count > 0) { string error; string text = CharacterQuery.Resolve(Files(), c.Argument, out error); if (text == null) { return AdminUi.Note(error); } foreach (string item in BackupNames(text).AsEnumerable().Reverse()) { PlayerDataView playerDataView = TryView(File.ReadAllBytes(Path.Combine(ServerSide.BackupFolder(text), item + ".fch"))); list.Add(AdminUi.Row("backup", text, item, playerDataView?.Items.Count ?? (-1), (playerDataView == null) ? (-1) : ((int)playerDataView.Skills.Sum((SkillInfo s) => s.Level)))); } return AdminUi.Text(list); } foreach (string item2 in Files()) { list.Add(AdminUi.Row("char", item2, CharacterQuery.NameOf(item2), ServerSide.OnlinePeer(item2) != null, Saved(item2), BackupNames(item2).Count)); } return AdminUi.Text(list); } private static string Info(string file) { //IL_0002: Unknown result type (might be due to invalid IL or missing references) //IL_0008: Expected O, but got Unknown PlayerProfile val = new PlayerProfile(file, (FileSource)2); if (!val.Load()) { return file + ": файл не читается загрузчиком игры"; } byte[] array = ServerSide.PlayerDataRef.Invoke(val); string text = val.GetName() + " (" + file + "), " + ((ServerSide.OnlinePeer(file) != null) ? "в сети — данные на момент последней записи" : "не в сети") + ", " + $"сохранён {Saved(file)}, копий {BackupNames(file).Count}"; if (array == null) { return text + "\nданных игрока ещё нет (не появлялся в мире)"; } PlayerDataView playerDataView = PlayerDataView.Parse(array); IEnumerable<string> values = from i in playerDataView.Items group i by i.PrefabHash into g select ItemName(g.Key) + ((g.Sum((ItemInfo i) => i.Stack) > 1) ? (" ×" + g.Sum((ItemInfo i) => i.Stack)) : "") + ((g.Max((ItemInfo i) => i.Quality) > 1) ? (" ур." + g.Max((ItemInfo i) => i.Quality)) : "") + (g.Any((ItemInfo i) => i.Equipped) ? " (надето)" : ""); IEnumerable<string> values2 = from s in playerDataView.Skills where s.Level >= 1f orderby s.Level descending select SkillName(s.Type) + " " + Math.Floor(s.Level).ToString(CultureInfo.InvariantCulture); return text + "\n" + string.Format("здоровье {0:0}/{1:0}, выносливость {2:0}; сила Форсакена: {3}\n", playerDataView.Health, playerDataView.MaxHealth, playerDataView.MaxStamina, (playerDataView.GuardianPower == "") ? "нет" : playerDataView.GuardianPower) + string.Format("вещей {0}: {1}\n", playerDataView.Items.Count, (playerDataView.Items.Count == 0) ? "—" : string.Join(", ", values)) + "навыки: " + ((playerDataView.Skills.Count == 0) ? "—" : string.Join(", ", values2)) + "\nеда: " + ((playerDataView.Foods.Count == 0) ? "—" : string.Join(", ", playerDataView.Foods.Select((KeyValuePair<string, float> f) => $"{ItemName(f.Key)} ({f.Value / 60f:0} мин)"))) + "\n" + $"рецептов {playerDataView.KnownRecipes}, станций {playerDataView.KnownStations}, трофеев {playerDataView.Trophies.Count}, биомов {playerDataView.Biomes.Count}"; } private static string Backups(string file) { List<string> list = BackupNames(file); if (list.Count == 0) { return file + ": резервных копий нет"; } string folder = ServerSide.BackupFolder(file); return $"{file}: копий {list.Count} (новые сверху)\n" + string.Join("\n", list.AsEnumerable().Reverse().Select(delegate(string n) { byte[] array = File.ReadAllBytes(Path.Combine(folder, n + ".fch")); PlayerDataView playerDataView = TryView(array); return (playerDataView != null) ? $"{n} — вещей {playerDataView.Items.Count}, сумма навыков {playerDataView.Skills.Sum((SkillInfo s) => s.Level):0}, {array.Length / 1024} КБ" : $"{n} — {array.Length / 1024} КБ, данные не разобраны"; })); } private static PlayerDataView TryView(byte[] file) { try { byte[] array = ProfileFile.Unframe(file); for (int i = 4; i < array.Length - 5; i++) { int num = array.Length - i - 5; if (array[num] == 1 && BitConverter.ToInt32(array, num + 1) == i) { byte[] array2 = new byte[i]; Buffer.BlockCopy(array, num + 5, array2, 0, i); return PlayerDataView.Parse(array2); } } } catch (Exception) { } return null; } private static void EnsureNames() { if (!_namesLoaded) { _namesLoaded = true; ItemNames.Load(); } } private static string ItemName(int hash) { EnsureNames(); ObjectDB instance = ObjectDB.instance; GameObject val = ((instance != null) ? instance.GetItemPrefab(hash) : null); if (!((Object)(object)val == (Object)null)) { return ItemNames.Of(((Object)val).name); } return "?" + hash; } private static string ItemName(string prefab) { EnsureNames(); ObjectDB instance = ObjectDB.instance; if (!((Object)(object)((instance != null) ? instance.GetItemPrefab(prefab) : null) == (Object)null)) { return ItemNames.Of(prefab); } return prefab; } private static string SkillName(int type) { //IL_002a: Unknown result type (might be due to invalid IL or missing references) if (!Enum.IsDefined(typeof(SkillType), type)) { return "#" + type; } return ((object)(SkillType)type/*cast due to .constrained prefix*/).ToString(); } private static string WithCharacter(AdminCommand c, int extraArgs, Func<string, string> action) { if (c.Args.Count < 1 + extraArgs) { return "не хватает аргументов; справка: sansara chars help"; } string query = string.Join(" ", c.Args.Take(c.Args.Count - extraArgs)); string error; string text = CharacterQuery.Resolve(Files(), query, out error); if (text != null) { return action(text); } return error; } private static string Change(string file, string admin, string what, Action change) { bool flag = KickIfOnline(file); string text; lock (ServerSide.FileLock) { text = ServerSide.Backup(file, ServerSide.PathOf(file), force: true); change(); } Plugin.Log.LogInfo((object)("Персонажи: " + admin + ": " + file + " — " + what + "; копия до изменения " + text)); return file + ": " + what + "." + (flag ? " Игрок был в сети — отключён." : "") + " Копия до изменения: " + text + "."; } private static bool KickIfOnline(string file) { ZNetPeer val = ServerSide.OnlinePeer(file); if (val == null) { return false; } Plugin.Log.LogInfo((object)("Персонажи: " + file + " в сети — отключаю перед изменением")); ServerSide.Kick(val); return true; } private static string Snapshot(string file, string admin) { string text; lock (ServerSide.FileLock) { text = ServerSide.Backup(file, ServerSide.PathOf(file), force: true); } Plugin.Log.LogInfo((object)("Персонажи: " + admin + ": " + file + " — копия " + text)); return file + ": копия " + text + ((ServerSide.OnlinePeer(file) != null) ? " (игрок в сети: копия — последнее записанное сервером состояние)" : ""); } private static string Restore(string file, string which, string admin) { List<string> list = BackupNames(file); if (list.Count == 0) { return file + ": резервных копий нет"; } string text = (which.Equals("last", StringComparison.OrdinalIgnoreCase) ? list[list.Count - 1] : (list.FirstOrDefault((string n) => n == which) ?? ((list.Count((string n) => n.StartsWith(which, StringComparison.Ordinal)) == 1) ? list.First((string n) => n.StartsWith(which, StringComparison.Ordinal)) : null))); if (text == null) { return file + ": копии «" + which + "» нет; список — sansara chars backups " + file; } string path = Path.Combine(ServerSide.BackupFolder(file), text + ".fch"); byte[] bytes = File.ReadAllBytes(path); ProfileFile.Unframe(bytes); return Change(file, admin, "восстановлен из копии " + text, delegate { Transport.WriteFileAtomic(ServerSide.PathOf(file), bytes); }); } private static string Skill(string file, string skill, string level, string admin) { //IL_001d: Unknown result type (might be due to invalid IL or missing references) //IL_0025: Unknown result type (might be due to invalid IL or missing references) //IL_002f: Invalid comparison between Unknown and I4 //IL_00cd: Unknown result type (might be due to invalid IL or missing references) if (!Enum.TryParse<SkillType>(skill, ignoreCase: true, out SkillType type) || (int)type == 0 || (int)type == 999) { return "навык «" + skill + "» не найден; навыки: " + string.Join(", ", from n in Enum.GetNames(typeof(SkillType)) where n != "None" && n != "All" select n); } if (!float.TryParse(level.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || value < 0f || value > 100f) { return "уровень — число от 0 до 100"; } return Change(file, admin, $"навык {type} = {value.ToString(CultureInfo.InvariantCulture)}", delegate { ServerSide.RewritePlayerData(file, (byte[] d) => PlayerDataView.WithSkillLevel(d, (int)type, value)); }); } private static string Archive(string file, string admin) { bool flag = KickIfOnline(file); string text = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); string text2 = Path.Combine(Path.Combine(Transport.CharacterFolder, "archive"), text + "_" + file); lock (ServerSide.FileLock) { Directory.CreateDirectory(text2); File.Move(ServerSide.PathOf(file), Path.Combine(text2, file + ".fch")); string text3 = ServerSide.BackupFolder(file); if (Directory.Exists(text3)) { Directory.Move(text3, Path.Combine(text2, "backups")); } } Plugin.Log.LogInfo((object)("Персонажи: " + admin + ": " + file + " — в архиве " + text2)); return file + ": убран в архив characters_local/archive/" + text + "_" + file + "." + (flag ? " Игрок был в сети — отключён." : "") + " При следующем входе этим персонажем — как новый (или перенос, если окно открыто)."; } private static string Import(AdminCommand c, string admin) { switch ((c.Args.Count > 0) ? c.Args[0].ToLowerInvariant() : "status") { case "status": return ImportState(); case "close": PluginConfig.ImportUntil.Value = ""; Plugin.Log.LogInfo((object)("Персонажи: " + admin + ": перенос закрыт")); return "перенос закрыт (ImportUntil пуст, записано в cfg)"; case "until": { string text = string.Join(" ", c.Args.Skip(1)); DateTime? dateTime; try { dateTime = ImportWindow.ParseUntil(text); } catch (FormatException) { return "дата «" + text + "» не разобрана; формат yyyy-MM-dd HH:mm, например 2027-01-31 23:59"; } if (!dateTime.HasValue) { return "укажите дату: import until 2027-01-31 23:59"; } PluginConfig.ImportUntil.Value = dateTime.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); Plugin.Log.LogInfo((object)("Персонажи: " + admin + ": перенос до " + PluginConfig.ImportUntil.Value)); return ImportState() + " (записано в cfg)"; } default: return "import status | import until <гггг-мм-дд чч:мм> | import close"; } } } internal static class ClientSide { internal enum State { Idle, WaitingServer, Importing, Ready, Refused } private const float FirstSaveDelaySeconds = 5f; private static readonly FieldRef<PlayerProfile, IDictionary> WorldDataRef = AccessTools.FieldRefAccess<PlayerProfile, IDictionary>("m_worldData"); private static readonly Reassembler Reassembler = new Reassembler(); internal static State Current = State.Idle; private static float _waitingSince; private static long _sequence; private static bool _inventoryDirty; private static bool _skillsDirty; private static bool _changedSinceFullSave; private static bool _firstSaveDue; private static float _lastLive; private static float _lastFullSave; private static float _lastPush = -1E+09f; private static float _spawnedAt = -1f; private static bool _giveDefaultItems; private static bool _importNoticeDue; public static void Register(ZNetPeer serverPeer) { Reset(); serverPeer.m_rpc.Register<ZPackage>("SansaraCharacters_Load", (Action<ZRpc, ZPackage>)OnLoad); } public static void SayHello(ZRpc rpc) { rpc.Invoke("SansaraCharacters_Hello", new object[1] { "0.2.1" }); } public static void OnPeerInfo() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Invalid comparison between Unknown and I4 if ((int)ZNet.GetConnectionStatus() == 2 && Current == State.Idle) { Current = State.WaitingServer; _waitingSince = Time.realtimeSinceStartup; Plugin.Log.LogInfo((object)"connected, waiting for the server copy of the character"); } } public static bool MaySpawn() { if ((Object)(object)ZNet.instance == (Object)null || ZNet.instance.IsServer()) { return true; } if (Current != State.WaitingServer && Current != State.Importing) { return true; } if (Time.realtimeSinceStartup - _waitingSince > PluginConfig.ServerCopyTimeoutSeconds.Value) { Plugin.Log.LogError((object)$"the server did not send the character in {PluginConfig.ServerCopyTimeoutSeconds.Value:0} s"); Fail(7104); } return false; } public static bool MaySaveLocally(PlayerProfile profile) { if (Current == State.Refused && !((Object)(object)ZNet.instance == (Object)null) && !ZNet.instance.IsServer() && !((Object)(object)Game.instance == (Object)null)) { return profile != Game.instance.GetPlayerProfile(); } return true; } public static void OnSaved(PlayerProfile profile, bool saved) { //IL_003f: Unknown result type (might be due to invalid IL or missing references) //IL_0045: Invalid comparison between Unknown and I4 if (!saved || !IsReadyClient() || profile != Game.instance.GetPlayerProfile()) { return; } ZNetPeer serverPeer = ZNet.instance.GetServerPeer(); if (Transport.Connected(serverPeer)) { byte[] array = ReadLocalProfile(profile); bool flag = Game.instance.IsShuttingDown() || (int)ZNet.GetConnectionStatus() != 2; if (flag) { Transport.SendBlocking(serverPeer, "SansaraCharacters_Save", ++_sequence, array); } else { Transport.SendAsync(serverPeer, "SansaraCharacters_Save", ++_sequence, array); } _lastPush = (_lastFullSave = Time.realtimeSinceStartup); _changedSinceFullSave = false; _firstSaveDue = false; Plugin.Log.LogInfo((object)($"full save sent ({array.Length} B)" + (flag ? ", blocking" : ""))); } } public static void OnInventoryChanged(Inventory inventory) { if ((Object)(object)Player.m_localPlayer != (Object)null && inventory == ((Humanoid)Player.m_localPlayer).GetInventory() && IsReadyClient()) { _inventoryDirty = (_changedSinceFullSave = true); } } public static void OnSkillsChanged(Skills skills) { if ((Object)(object)Player.m_localPlayer != (Object)null && (Object)(object)skills == (Object)(object)((Character)Player.m_localPlayer).GetSkills() && IsReadyClient()) { _skillsDirty = (_changedSinceFullSave = true); } } public static void Tick() { //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0026: Invalid comparison between Unknown and I4 if (!IsReadyClient() || (Object)(object)Player.m_localPlayer == (Object)null || Game.instance.IsShuttingDown() || (int)ZNet.GetConnectionStatus() != 2) { if ((Object)(object)Player.m_localPlayer == (Object)null) { _spawnedAt = -1f; } return; } float realtimeSinceStartup = Time.realtimeSinceStartup; if (_spawnedAt < 0f) { _spawnedAt = realtimeSinceStartup; } if (_giveDefaultItems) { _giveDefaultItems = false; ((Humanoid)Player.m_localPlayer).GiveDefaultItems(); } if (_importNoticeDue && !Game.instance.InIntro(false)) { _importNoticeDue = false; ((Character)Player.m_localPlayer).Message((MessageType)2, "Персонаж перенесён в новый мир: вещи, рецепты, сила Форсакена и еда сброшены, навыки сохранены.", 0, (Sprite)null, false); } if (_firstSaveDue && realtimeSinceStartup - _spawnedAt >= 5f) { _firstSaveDue = false; _lastFullSave = realtimeSinceStartup; Plugin.Log.LogInfo((object)"new character: first save to the server"); Game.instance.SavePlayerProfile(true, false); return; } int value = PluginConfig.FullSaveMinutes.Value; if (value > 0 && _changedSinceFullSave && realtimeSinceStartup - _lastFullSave >= (float)value * 60f) { _changedSinceFullSave = false; _lastFullSave = realtimeSinceStartup; Game.instance.SavePlayerProfile(true, false); return; } float num = realtimeSinceStartup - _lastLive; if ((_inventoryDirty && num >= PluginConfig.InventorySendSeconds.Value) || (_skillsDirty && num >= PluginConfig.SkillSendSeconds.Value) || num >= PluginConfig.IdleSendSeconds.Value) { SendLive(realtimeSinceStartup); } } public static void BeforeSocketClose(ISocket socket) { if (!((Object)(object)ZNet.instance == (Object)null) && !ZNet.instance.IsServer() && Current != State.Idle && !(Time.realtimeSinceStartup - _lastPush > 30f)) { Stopwatch stopwatch = Stopwatch.StartNew(); Transport.TryFlush(socket); while (socket.GetSendQueueSize() > 0 && stopwatch.ElapsedMilliseconds < 3000) { Thread.Sleep(20); Transport.TryFlush(socket); } Plugin.Log.LogInfo((object)$"socket close: waited {stopwatch.ElapsedMilliseconds} ms, {socket.GetSendQueueSize()} B left"); } } private static void SendLive(float now) { //IL_0014: Unknown result type (might be due to invalid IL or missing references) //IL_001a: Expected O, but got Unknown ZNetPeer serverPeer = ZNet.instance.GetServerPeer(); if (Transport.Connected(serverPeer)) { ZPackage val = new ZPackage(); Player.m_localPlayer.Save(val); Transport.SendAsync(serverPeer, "SansaraCharacters_Live", ++_sequence, val.GetArray()); _lastLive = (_lastPush = now); _inventoryDirty = (_skillsDirty = false); } } private static void OnLoad(ZRpc rpc, ZPackage pkg) { try { if (Transport.Receive(Reassembler, "server", pkg, out var payload, out var sequence)) { Apply(rpc, payload, (LoadMode)sequence); } } catch (Exception ex) { Plugin.Log.LogError((object)("server copy: " + ex)); Fail(7103); } } private static void Apply(ZRpc rpc, byte[] data, LoadMode mode) { //IL_0131: Unknown result type (might be due to invalid IL or missing references) //IL_0138: Expected O, but got Unknown //IL_018a: Unknown result type (might be due to invalid IL or missing references) //IL_018f: Unknown result type (might be due to invalid IL or missing references) //IL_01a8: Unknown result type (might be due to invalid IL or missing references) //IL_01ca: Unknown result type (might be due to invalid IL or missing references) if (Current != State.WaitingServer && Current != State.Idle && Current != State.Importing) { Plugin.Log.LogWarning((object)$"server copy ignored in state {Current}"); return; } Game instance = Game.instance; PlayerProfile val = ((instance != null) ? instance.GetPlayerProfile() : null); if (val == null) { Plugin.Log.LogWarning((object)"server copy arrived without a local profile"); return; } if (mode == LoadMode.ImportFailed) { Plugin.Log.LogInfo((object)"the server could not import this character"); Fail(7105); return; } int num = WorldDataRef.Invoke(val)?.Count ?? 0; switch (Admission.OnServerAnswer(data.Length != 0, mode, val.m_firstSpawn, num)) { case JoinDecision.Import: SendImport(rpc, val); return; case JoinDecision.AcceptNew: BecomeReady(); _firstSaveDue = true; Plugin.Log.LogInfo((object)"no server copy, new character accepted"); return; case JoinDecision.RefuseHasProgress: Plugin.Log.LogInfo((object)"no server copy and the character has progress elsewhere: refused"); Fail(7102); return; } string text = "sansara-probe_" + val.GetFilename(); string path = Transport.CharacterFolder + text + ".fch"; try { Directory.CreateDirectory(Transport.CharacterFolder); WriteProfile(path, data, (FileSource)2); PlayerProfile val2 = new PlayerProfile(text, (FileSource)2); if (!val2.Load() || !string.Equals(val2.GetName(), val.GetName(), StringComparison.OrdinalIgnoreCase)) { Plugin.Log.LogError((object)$"server copy refused by the game's loader ({data.Length} B); local file untouched"); Fail(7103); return; } string path2 = val.GetPath(); FileSource fileSource = val.m_fileSource; if (num > 0) { KeepOriginal(val); } WriteProfile(path2 + ".new", data, fileSource); FileHelpers.ReplaceOldFile(path2, path2 + ".new", path2 + ".old", (CloudStorageFileGrouping)1, fileSource); if (!val.Load()) { Plugin.Log.LogError((object)"server copy written but the profile did not load"); Fail(7103); return; } BecomeReady(); if (mode == LoadMode.Imported) { _giveDefaultItems = (_importNoticeDue = true); Traverse.Create((object)Game.instance).Field("m_queuedIntro").SetValue((object)val.m_firstSpawn); } Plugin.Log.LogInfo((object)($"server copy loaded ({data.Length} B) for '{val.GetName()}'" + ((mode == LoadMode.Imported) ? ", imported" : ""))); } finally { try { if (File.Exists(path)) { File.Delete(path); } } catch (Exception) { } } } private static void SendImport(ZRpc rpc, PlayerProfile profile) { ZNetPeer peer = ((IEnumerable<ZNetPeer>)ZNet.instance.GetPeers()).FirstOrDefault((Func<ZNetPeer, bool>)((ZNetPeer p) => p != null && p.m_rpc == rpc)); if (!Transport.Connected(peer)) { Plugin.Log.LogError((object)"import offered, but the server connection is gone"); Fail(7105); return; } byte[] array = ReadLocalProfile(profile); Current = State.Importing; _waitingSince = Time.realtimeSinceStartup; Transport.SendAsync(peer, "SansaraCharacters_Import", ++_sequence, array); Plugin.Log.LogInfo((object)$"character with progress sent for import ({array.Length} B)"); } private static void KeepOriginal(PlayerProfile profile) { //IL_0027: Unknown result type (might be due to invalid IL or missing references) //IL_002d: Unknown result type (might be due to invalid IL or missing references) //IL_0033: Expected O, but got Unknown string text = Transport.CharacterFolder + profile.GetFilename() + ".fch.beforesansara"; if (File.Exists(text)) { return; } try { FileReader val = new FileReader(profile.GetPath(), profile.m_fileSource, (FileHelperType)0); byte[] bytes; try { bytes = val.m_binary.ReadBytes((int)val.m_binary.BaseStream.Length); } finally { val.Dispose(); } Directory.CreateDirectory(Transport.CharacterFolder); File.WriteAllBytes(text, bytes); Plugin.Log.LogInfo((object)("kept the original local file as " + text)); } catch (Exception ex) { Plugin.Log.LogWarning((object)("could not keep the original local file: " + ex.Message)); } } private static void BecomeReady() { Current = State.Ready; _lastLive = (_lastFullSave = Time.realtimeSinceStartup); } private static void Fail(int code) { Current = State.Refused; SetStatus(code); try { Game instance = Game.instance; if (instance != null) { instance.Logout(false, true); } } catch (Exception ex) { Plugin.Log.LogWarning((object)("logout: " + ex.Message)); } SetStatus(code); } private static void SetStatus(int code) { Traverse.Create(typeof(ZNet)).Field("m_connectionStatus").SetValue((object)(ConnectionStatus)code); } private static bool IsReadyClient() { if (Current == State.Ready && (Object)(object)ZNet.instance != (Object)null && !ZNet.instance.IsServer()) { return (Object)(object)Game.instance != (Object)null; } return false; } private static void Reset() { Current = State.Idle; _sequence = 0L; _inventoryDirty = (_skillsDirty = (_changedSinceFullSave = (_firstSaveDue = false))); _giveDefaultItems = (_importNoticeDue = false); _spawnedAt = -1f; } private static byte[] ReadLocalProfile(PlayerProfile profile) { //IL_0007: Unknown result type (might be due to invalid IL or missing references) //IL_000d: Unknown result type (might be due to invalid IL or missing references) //IL_0013: Expected O, but got Unknown FileReader val = new FileReader(profile.GetPath(), profile.m_fileSource, (FileHelperType)0); try { int num = val.m_binary.ReadInt32(); if (num <= 0 || num > 67108864) { throw new IOException("implausible profile length " + num); } return val.m_binary.ReadBytes(num); } finally { val.Dispose(); } } private static void WriteProfile(string path, byte[] data, FileSource source) { //IL_000a: Unknown result type (might be due to invalid IL or missing references) //IL_000b: Unknown result type (might be due to invalid IL or missing references) //IL_0011: Expected O, but got Unknown //IL_0024: Unknown result type (might be due to invalid IL or missing references) //IL_002a: Invalid comparison between Unknown and I4 //IL_0032: Unknown result type (might be due to invalid IL or missing references) byte[] buffer = ProfileFile.Frame(data); FileWriter val = new FileWriter(path, (CloudStorageFileGrouping)1, (FileHelperType)0, source); val.m_binary.Write(buffer); val.Finish(); if ((int)val.Status != 2) { throw new IOException($"FileWriter status {val.Status} for {path}"); } } } internal static class ItemNames { private static readonly Dictionary<string, string> Russian = new Dictionary<string, string>(); public static void Load() { try { foreach (TextAsset item in Assets()) { ReadColumn(item, "Russian", Russian); } } catch (Exception ex) { Plugin.Log.LogWarning((object)("Персонажи: русская локализация не прочитана: " + ex.Message)); } Plugin.Log.LogInfo((object)$"Персонажи: строк русской локализации {Russian.Count}."); } public static string Of(string prefab) { GameObject itemPrefab = ObjectDB.instance.GetItemPrefab(prefab); string text = ((itemPrefab == null) ? null : itemPrefab.GetComponent<ItemDrop>()?.m_itemData.m_shared.m_name) ?? prefab; string key = text.TrimStart(new char[1] { '$' }); if (Russian.TryGetValue(key, out var value) && value != "") { return value; } if (Localization.instance == null) { return prefab; } return Localization.instance.Localize(text); } public static string Token(string prefab) { GameObject itemPrefab = ObjectDB.instance.GetItemPrefab(prefab); return ((itemPrefab == null) ? null : itemPrefab.GetComponent<ItemDrop>()?.m_itemData.m_shared.m_name) ?? ""; } private static IEnumerable<TextAsset> Assets() { _ = Localization.instance; object obj = AccessTools.Field(typeof(Localization), "m_localizationSettings")?.GetValue(null); if (obj == null) { yield break; } Traverse val = Traverse.Create(obj); if (!((val.Field("Localizations").GetValue() ?? val.Property("Localizations", (object[])null).GetValue()) is IEnumerable enumerable)) { yield break; } foreach (object item in enumerable) { TextAsset val2 = (TextAsset)((item is TextAsset) ? item : null); if (val2 != null && (Object)(object)val2 != (Object)null) { yield return val2; } } } private static void ReadColumn(TextAsset asset, string language, Dictionary<string, string> into) { List<List<string>> list = SplitCsv(asset.text); if (list.Count == 0) { return; } int num = list[0].FindIndex((string h) => StripQuotes(h).Trim() == language); if (num < 0) { return; } foreach (List<string> item in list.Skip(1)) { if (item.Count != 0 && item[0].Length != 0 && !item[0].StartsWith("//")) { string text = ((item.Count > num) ? item[num].Trim() : ""); if (text.Length == 0 && item.Count > 1) { text = item[1].Trim(); } into[item[0]] = text; } } } private static string StripQuotes(string s) { if (s.Length < 2 || s[0] != '"' || s[s.Length - 1] != '"') { return s; } return s.Substring(1, s.Length - 2); } private static List<List<string>> SplitCsv(string text) { List<List<string>> list = new List<List<string>>(); List<string> list2 = new List<string>(); StringBuilder stringBuilder = new StringBuilder(); bool flag = false; for (int i = 0; i < text.Length; i++) { char c = text[i]; switch (c) { case '"': if (flag && i + 1 < text.Length && text[i + 1] == '"') { stringBuilder.Append('"'); i++; } else { flag = !flag; } continue; case ',': if (!flag) { list2.Add(stringBuilder.ToString()); stringBuilder.Length = 0; continue; } break; } if (c == '\n' && !flag) { list2.Add(stringBuilder.ToString().TrimEnd(new char[1] { '\r' })); stringBuilder.Length = 0; list.Add(list2); list2 = new List<string>(); } else { stringBuilder.Append(c); } } if (stringBuilder.Length > 0 || list2.Count > 0) { list2.Add(stringBuilder.ToString()); list.Add(list2); } return list; } } [HarmonyPatch(typeof(ZNet), "OnNewConnection")] internal static class NewConnectionPatch { private static void Postfix(ZNet __instance, ZNetPeer peer) { try { if (peer?.m_rpc == null) { return; } if (__instance.IsServer()) { if (ServerSide.Active) { ServerSide.Register(peer); } } else { ClientSide.Register(peer); } } catch (Exception ex) { Plugin.Log.LogError((object)("OnNewConnection: " + ex)); } } } [HarmonyPatch(typeof(ZNet), "SendPeerInfo")] internal static class SendPeerInfoPatch { private static void Prefix(ZNet __instance, ZRpc rpc) { try { if (!__instance.IsServer()) { ClientSide.SayHello(rpc); } } catch (Exception ex) { Plugin.Log.LogError((object)("SendPeerInfo: " + ex)); } } } [HarmonyPatch(typeof(ZNet), "RPC_PeerInfo")] internal static class PeerInfoPatch { private static bool Prefix(ZNet __instance, ZRpc rpc) { try { return !__instance.IsServer() || !ServerSide.Active || ServerSide.MayProceed(rpc); } catch (Exception ex) { Plugin.Log.LogError((object)("RPC_PeerInfo check: " + ex)); return false; } } private static void Postfix(ZNet __instance, ZRpc rpc) { try { if (__instance.IsServer()) { if (ServerSide.Active) { ServerSide.OnPeerInfo(__instance, rpc); } } else { ClientSide.OnPeerInfo(); } } catch (Exception ex) { Plugin.Log.LogError((object)("RPC_PeerInfo: " + ex)); } } } [HarmonyPatch(typeof(ZNet), "Disconnect")] internal static class DisconnectPatch { private static void Prefix(ZNet __instance, ZNetPeer peer) { try { if (ServerSide.Active) { ServerSide.OnDisconnect(peer); } } catch (Exception ex) { Plugin.Log.LogError((object)("Disconnect: " + ex)); } } } [HarmonyPatch(typeof(ZNet), "SaveWorld")] internal static class SaveWorldPatch { private static void Prefix() { try { if (ServerSide.Active) { ServerSide.OnWorldSave(); } } catch (Exception ex) { Plugin.Log.LogError((object)("SaveWorld: " + ex)); } } } [HarmonyPatch(typeof(Game), "UpdateRespawn")] internal static class UpdateRespawnPatch { private static bool Prefix() { try { return ClientSide.MaySpawn(); } catch (Exception ex) { Plugin.Log.LogError((object)("UpdateRespawn: " + ex)); return true; } } } [HarmonyPatch(typeof(PlayerProfile), "Save")] internal static class ProfileSavePatch { private static bool Prefix(PlayerProfile __instance, ref bool __result) { try { if (ClientSide.MaySaveLocally(__instance)) { return true; } Plugin.Log.LogInfo((object)"save skipped: the server refused this character, the local file stays as it was"); __result = false; return false; } catch (Exception ex) { Plugin.Log.LogError((object)("Save prefix: " + ex)); return true; } } private static void Postfix(PlayerProfile __instance, bool __result) { try { ClientSide.OnSaved(__instance, __result); } catch (Exception ex) { Plugin.Log.LogError((object)("Save postfix: " + ex)); } } } [HarmonyPatch(typeof(Inventory), "Changed")] internal static class InventoryChangedPatch { private static void Postfix(Inventory __instance) { try { ClientSide.OnInventoryChanged(__instance); } catch (Exception ex) { Plugin.Log.LogWarning((object)("Inventory.Changed: " + ex.Message)); } } } [HarmonyPatch] internal static class SkillsChangedPatch { private static IEnumerable<MethodBase> TargetMethods() { yield return AccessTools.Method(typeof(Skills), "RaiseSkill", (Type[])null, (Type[])null); yield return AccessTools.Method(typeof(Skills), "LowerAllSkills", (Type[])null, (Type[])null); yield return AccessTools.Method(typeof(Skills), "ResetSkill", (Type[])null, (Type[])null); } private static void Postfix(Skills __instance) { try { ClientSide.OnSkillsChanged(__instance); } catch (Exception ex) { Plugin.Log.LogWarning((object)("Skills: " + ex.Message)); } } } [HarmonyPatch(typeof(ZSteamSocket), "Close")] internal static class SteamSocketClosePatch { private static void Prefix(ZSteamSocket __instance) { try { ClientSide.BeforeSocketClose((ISocket)(object)__instance); } catch (Exception ex) { Plugin.Log.LogWarning((object)("socket close: " + ex.Message)); } } } [HarmonyPatch(typeof(FejdStartup), "ShowConnectError")] internal static class ConnectErrorPatch { private static void Postfix(FejdStartup __instance) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_000a: Expected I4, but got Unknown try { string text = Texts.ForCode((int)ZNet.GetConnectionStatus()); if (text != null && !((Object)(object)__instance.m_connectionFailedError == (Object)null)) { __instance.m_connectionFailedError.text = text; if ((Object)(object)__instance.m_connectionFailedPanel != (Object)null) { __instance.m_connectionFailedPanel.SetActive(true); } } } catch (Exception ex) { Plugin.Log.LogWarning((object)("connect error text: " + ex.Message)); } } } [BepInPlugin("sansara.Characters", "Sansara Characters", "0.2.1")] [BepInDependency(/*Could not decode attribute arguments.*/)] [BepInIncompatibility("org.bepinex.plugins.servercharacters")] [BepInIncompatibility("reef.characters")] [BepInIncompatibility("Landoria.CharacterVault")] [BepInIncompatibility("chazman.RunicCharacterVault")] [NetworkCompatibility(/*Could not decode attribute arguments.*/)] public sealed class Plugin : BaseUnityPlugin { public const string Guid = "sansara.Characters"; public const string Version = "0.2.1"; internal static ManualLogSource Log; internal static Plugin Instance; private void Awake() { //IL_0021: Unknown result type (might be due to invalid IL or missing references) Instance = this; Log = ((BaseUnityPlugin)this).Logger; PluginConfig.Bind(((BaseUnityPlugin)this).Config); new Harmony("sansara.Characters").PatchAll(typeof(Plugin).Assembly); AdminChannel.Register("chars", CharsAdmin.Verbs, CharsAdmin.Handle, Log); Log.LogInfo((object)("Sansara Characters 0.2.1 loaded: " + PluginConfig.Describe())); } private void Update() { try { ClientSide.Tick(); if (ServerSide.Active) { AdminChannel.Tick(); AdminChannel.CheckFile("sansara.Characters", "Персонажи", CharsAdmin.Handle); } } catch (Exception ex) { Log.LogError((object)("client tick: " + ex)); } } } internal static class PluginConfig { public static ConfigEntry<int> BackupsToKeep; public static ConfigEntry<int> BackupIntervalMinutes; public static ConfigEntry<bool> OneCharacterPerAccount; public static ConfigEntry<float> ServerCopyTimeoutSeconds; public static ConfigEntry<float> InventorySendSeconds; public static ConfigEntry<float> SkillSendSeconds; public static ConfigEntry<float> IdleSendSeconds; public static ConfigEntry<int> FullSaveMinutes; public static ConfigEntry<string> ImportUntil; public static void Bind(ConfigFile cfg) { BackupsToKeep = cfg.Bind<int>("Server", "BackupsToKeep", 25, Description("Сколько датированных копий хранить на персонажа в characters_local/backups/<файл>/. Копия снимается перед перезаписью файла сервером, лишние старые удаляются.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 200))); BackupIntervalMinutes = cfg.Bind<int>("Server", "BackupIntervalMinutes", 30, Description("Не чаще одной копии на персонажа за столько минут; перезаписи между ними копий не создают. 0 — копия перед каждой перезаписью.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(0, 1440))); OneCharacterPerAccount = cfg.Bind<bool>("Server", "OneCharacterPerAccount", false, Description("true — аккаунт Steam, у которого уже есть персонаж на сервере, не может зайти другим (администраторы из adminlist.txt — могут).")); ImportUntil = cfg.Bind<string>("Import", "ImportUntil", "", Description("До какого момента (время сервера, формат yyyy-MM-dd HH:mm) сервер принимает персонажей, уже игравших в других мирах: при первом входе такой персонаж очищается — вещи, рецепты, станции, материалы, сила Форсакена и еда сбрасываются, навыки, внешность и трофеи остаются. Пусто — перенос закрыт, принимаются только новые персонажи.")); ServerCopyTimeoutSeconds = cfg.Bind<float>("Client", "ServerCopyTimeoutSeconds", 30f, Description("Сколько секунд клиент ждёт копию персонажа от сервера, прежде чем отключиться с ошибкой.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(5f, 120f))); InventorySendSeconds = cfg.Bind<float>("Client", "InventorySendSeconds", 0.25f, Description("Не чаще чем раз в столько секунд клиент отправляет серверу состояние игрока после изменения инвентаря. Чем меньше, тем уже окно для дюпа через вылет.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0.1f, 5f))); SkillSendSeconds = cfg.Bind<float>("Client", "SkillSendSeconds", 5f, Description("То же после роста навыков (навыки растут при каждом ударе, поэтому реже).", (AcceptableValueBase)(object)new AcceptableValueRange<float>(1f, 60f))); IdleSendSeconds = cfg.Bind<float>("Client", "IdleSendSeconds", 30f, Description("Раз в столько секунд состояние отправляется и без изменений (место игрока, еда).", (AcceptableValueBase)(object)new AcceptableValueRange<float>(5f, 600f))); FullSaveMinutes = cfg.Bind<int>("Client", "FullSaveMinutes", 10, Description("Раз в столько минут клиент сохраняет персонажа целиком (карта, точка выхода), если что-то менялось. Игра сама сохраняет раз в 30 минут и при выходе. 0 — только сохранения игры.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(0, 60))); } public static string Describe() { return $"копий {BackupsToKeep.Value} (не чаще раза в {BackupIntervalMinutes.Value} мин), один персонаж на аккаунт: " + string.Format("{0}, инвентарь каждые {1} с, ", OneCharacterPerAccount.Value ? "да" : "нет", InventorySendSeconds.Value) + $"полное сохранение раз в {FullSaveMinutes.Value} мин, перенос: " + (string.IsNullOrWhiteSpace(ImportUntil.Value) ? "закрыт" : ("до " + ImportUntil.Value)); } private static ConfigDescription Description(string text, AcceptableValueBase range = null) { //IL_000a: Unknown result type (might be due to invalid IL or missing references) //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0017: Expected O, but got Unknown //IL_0017: Unknown result type (might be due to invalid IL or missing references) //IL_001d: Expected O, but got Unknown return new ConfigDescription(text, range, new object[1] { (object)new ConfigurationManagerAttributes { IsAdminOnly = true } }); } } internal static class ServerSide { private sealed class Session { public ZNetPeer Peer; public string FileName; public readonly LiveLedger Ledger = new LiveLedger(); public byte[] StoredPlayerData; public bool StoredKnown; public bool ImportOffered; } private const string IncomingPrefix = "sansara-incoming_"; internal static readonly FieldRef<PlayerProfile, byte[]> PlayerDataRef = AccessTools.FieldRefAccess<PlayerProfile, byte[]>("m_playerData"); private static readonly FieldRef<PlayerProfile, bool> CreateBackupRef = AccessTools.FieldRefAccess<PlayerProfile, bool>("m_createBackupBeforeSaving"); private static readonly Dictionary<ZRpc, Session> Sessions = new Dictionary<ZRpc, Session>(); private static readonly HashSet<ZRpc> Greeted = new HashSet<ZRpc>(); private static readonly Reassembler Reassembler = new Reassembler(); internal static readonly object FileLock = new object(); public static bool Active { get { if ((Object)(object)ZNet.instance != (Object)null && ZNet.instance.IsServer()) { return ZNet.instance.IsDedicated(); } return false; } } public static void Register(ZNetPeer peer) { peer.m_rpc.Register<ZPackage>("SansaraCharacters_Save", (Action<ZRpc, ZPackage>)OnSave); peer.m_rpc.Register<ZPackage>("SansaraCharacters_Live", (Action<ZRpc, ZPackage>)OnLive); peer.m_rpc.Register<string>("SansaraCharacters_Hello", (Action<ZRpc, string>)OnHello); peer.m_rpc.Register<ZPackage>("SansaraCharacters_Import", (Action<ZRpc, ZPackage>)OnImport); } public static bool MayProceed(ZRpc rpc) { if (Greeted.Contains(rpc)) { return true; } ISocket socket = rpc.GetSocket(); string text = ((socket != null) ? socket.GetHostName() : null) ?? "?"; Plugin.Log.LogWarning((object)("refused " + text + ": no Sansara Characters 0.2.1 on the client")); rpc.Invoke("Error", new object[1] { 3 }); return false; } private static void OnHello(ZRpc rpc, string version) { if (VersionsMatch(version, "0.2.1")) { Greeted.Add(rpc); return; } ManualLogSource log = Plugin.Log; ISocket socket = rpc.GetSocket(); log.LogWarning((object)(((socket != null) ? socket.GetHostName() : null) + ": client has Sansara Characters " + version + ", server 0.2.1")); } private static bool VersionsMatch(string a, string b) { string[] array = (a ?? "").Split(new char[1] { '.' }); string[] array2 = (b ?? "").Split(new char[1] { '.' }); if (array.Length >= 2 && array2.Length >= 2 && array[0] == array2[0]) { return array[1] == array2[1]; } return false; } public static void OnPeerInfo(ZNet net, ZRpc rpc) { ZNetPeer val = ((IEnumerable<ZNetPeer>)net.GetPeers()).FirstOrDefault((Func<ZNetPeer, bool>)((ZNetPeer p) => p != null && p.m_rpc == rpc)); if (val == null || val.m_uid == 0L || string.IsNullOrEmpty(val.m_playerName)) { return; } string hostName = val.m_socket.GetHostName(); string text = CharacterFileName.For(hostName, val.m_playerName); string path = PathOf(text); try { Directory.CreateDirectory(Transport.CharacterFolder); bool flag = File.Exists(path); IEnumerable<string> existingFileNames = Directory.GetFiles(Transport.CharacterFolder, "*.fch").Select(Path.GetFileNameWithoutExtension); if (Admission.IsSecondCharacter(PluginConfig.OneCharacterPerAccount.Value, net.IsAdmin(hostName), flag, CharacterFileName.Account(hostName), existingFileNames)) { Plugin.Log.LogInfo((object)("refused " + text + ": the account already has a character")); rpc.Invoke("Error", new object[1] { 7101 }); net.Disconnect(val); return; } Session session = new Session { Peer = val, FileName = text }; Sessions[rpc] = session; if (!flag) { session.ImportOffered = ImportOpen(); LoadMode loadMode = (session.ImportOffered ? LoadMode.ImportAllowed : LoadMode.Normal); Transport.SendAsync(val, "SansaraCharacters_Load", (long)loadMode, null); Plugin.Log.LogInfo((object)(text + ": no server copy, a new character may join" + (session.ImportOffered ? ", import open" : ""))); } else { byte[] array = ProfileFile.Unframe(File.ReadAllBytes(path)); Transport.SendAsync(val, "SansaraCharacters_Load", 0L, array); Plugin.Log.LogInfo((object)$"{text}: sent the server copy ({array.Length} B)"); } } catch (Exception arg) { Plugin.Log.LogError((object)$"join of {text}: {arg}"); } } public static void OnDisconnect(ZNetPeer peer) { if (peer?.m_rpc != null) { Greeted.Remove(peer.m_rpc); if (Sessions.TryGetValue(peer.m_rpc, out var value)) { Flush(value, "disconnect"); Sessions.Remove(peer.m_rpc); Reassembler.Forget(SenderOf(peer.m_rpc)); } } } public static void OnWorldSave() { foreach (Session item in Sessions.Values.ToList()) { Flush(item, "world save"); } } private static void OnSave(ZRpc rpc, ZPackage pkg) { try { if (Sessions.TryGetValue(rpc, out var value) && Transport.Receive(Reassembler, SenderOf(rpc), pkg, out var payload, out var sequence)) { StoreFullSave(value, payload, sequence); } } catch (Exception ex) { Plugin.Log.LogError((object)("full save: " + ex)); } } private static void OnImport(ZRpc rpc, ZPackage pkg) { if (!Sessions.TryGetValue(rpc, out var value)) { return; } try { if (Transport.Receive(Reassembler, SenderOf(rpc), pkg, out var payload, out var sequence)) { if (!value.ImportOffered) { Plugin.Log.LogWarning((object)(value.FileName + ": import not offered on this connection, ignored")); return; } value.ImportOffered = false; byte[] payload2 = Import(value, payload, sequence); Transport.SendAsync(value.Peer, "SansaraCharacters_Load", 2L, payload2); } } catch (Exception ex) { Plugin.Log.LogError((object)(value.FileName + ": import failed: " + ex.Message)); Transport.SendAsync(value.Peer, "SansaraCharacters_Load", 3L, null); } } private static byte[] Import(Session session, byte[] data, long sequence) { //IL_0066: Unknown result type (might be due to invalid IL or missing references) //IL_006d: Expected O, but got Unknown if (data.Length == 0) { throw new InvalidDataException("empty profile"); } lock (FileLock) { string text = PathOf(session.FileName); if (File.Exists(text)) { throw new InvalidOperationException("the server already has a copy"); } string text2 = "sansara-incoming_" + session.FileName; string text3 = PathOf(text2); try { File.WriteAllBytes(text3, ProfileFile.Frame(data)); PlayerProfile val = new PlayerProfile(text2, (FileSource)2); if (!val.Load()) { throw new InvalidDataException("refused by the game's loader"); } string text4 = (val.GetName() ?? "").ToLowerInvariant(); if (text4 != session.Peer.m_playerName.ToLowerInvariant()) { throw new InvalidDataException("profile of another character '" + text4 + "'"); } byte[] array = PlayerDataRef.Invoke(val); byte[] array2 = PlayerDataStrip.Strip(array); PlayerDataRef.Invoke(val) = array2; val.m_firstSpawn = true; Traverse.Create((object)val).Field("m_worldData").GetValue<IDictionary>() .Clear(); CreateBackupRef.Invoke(val) = false; if (!val.Save()) { throw new IOException("the game's writer failed"); } byte[] result = ProfileFile.Unframe(File.ReadAllBytes(text3)); Transport.MoveOver(text3, text); session.Ledger.OnFullSave(sequence); session.StoredPlayerData = array2; session.StoredKnown = true; Plugin.Log.LogInfo((object)($"{session.FileName}: imported, player data {array.Length} B -> {array2.Length} B " + "(items, recipes, power, food reset; skills kept)")); return result; } finally { TryDelete(text3); TryDelete(text3 + ".old"); string[] files = Directory.GetFiles(Transport.CharacterFolder, text2 + "_backup*"); for (int i = 0; i < files.Length; i++) { TryDelete(files[i]); } } } } private static bool ImportOpen() { try { return ImportWindow.IsOpen(ImportWindow.ParseUntil(PluginConfig.ImportUntil.Value), DateTime.Now); } catch (FormatException ex) { Plugin.Log.LogError((object)(ex.Message + "; import stays closed")); return false; } } private static void OnLive(ZRpc rpc, ZPackage pkg) { try { if (Sessions.TryGetValue(rpc, out var value) && Transport.Receive(Reassembler, SenderOf(rpc), pkg, out var payload, out var sequence)) { byte[] array = payload; if (!ProfileFile.IsPlausiblePlayerData(value.StoredPlayerData, array)) { Plugin.Log.LogWarning((object)$"{value.FileName}: implausible live state ({array.Length} B) ignored"); } else { value.Ledger.OnLive(new LiveSnapshot(sequence, array)); } } } catch (Exception ex) { Plugin.Log.LogError((object)("live state: " + ex)); } } private static void StoreFullSave(Session session, byte[] data, long sequence) { //IL_003b: Unknown result type (might be due to invalid IL or missing references) //IL_0042: Expected O, but got Unknown if (data.Length == 0) { return; } lock (FileLock) { string text = "sansara-incoming_" + session.FileName; string text2 = PathOf(text); try { File.WriteAllBytes(text2, ProfileFile.Frame(data)); PlayerProfile val = new PlayerProfile(text, (FileSource)2); if (!val.Load()) { Plugin.Log.LogWarning((object)$"{session.FileName}: full save refused by the game's loader ({data.Length} B)"); return; } string text3 = (val.GetName() ?? "").ToLowerInvariant(); if (text3 != session.Peer.m_playerName.ToLowerInvariant()) { Plugin.Log.LogWarning((object)(session.FileName + ": full save of another character '" + text3 + "' refused")); } else if (session.Ledger.OnFullSave(sequence)) { string text4 = PathOf(session.FileName); Backup(session.FileName, text4); Transport.MoveOver(text2, text4); session.StoredPlayerData = PlayerDataRef.Invoke(val); session.StoredKnown = true; Plugin.Log.LogInfo((object)$"{session.FileName}: full save stored ({data.Length} B)"); } } finally { TryDelete(text2); } } } private static void Flush(Session session, string reason) { //IL_0074: Unknown result type (might be due to invalid IL or missing references) //IL_007b: Expected O, but got Unknown LiveSnapshot liveSnapshot = session.Ledger.TakePending(); if (liveSnapshot == null) { return; } lock (FileLock) { string path = PathOf(session.FileName); try { if (!File.Exists(path)) { Plugin.Log.LogInfo((object)(session.FileName + ": no server file yet, live state dropped (" + reason + ")")); session.Ledger.Drop(liveSnapshot); return; } if (!session.StoredKnown) { PlayerProfile val = new PlayerProfile(session.FileName, (FileSource)2); if (!val.Load()) { throw new InvalidDataException("server file refused by the game's loader"); } session.StoredPlayerData = PlayerDataRef.Invoke(val); session.StoredKnown = true; } byte[] data = ProfileFile.ReplacePlayerData(ProfileFile.Unframe(File.ReadAllBytes(path)), session.StoredPlayerData, liveSnapshot.PlayerData); Backup(session.FileName, path); Transport.WriteFileAtomic(path, ProfileFile.Frame(data)); session.StoredPlayerData = liveSnapshot.PlayerData; session.Ledger.MarkApplied(liveSnapshot); Plugin.Log.LogInfo((object)$"{session.FileName}: live state written ({liveSnapshot.PlayerData.Length} B, {reason})"); } catch (Exception ex) { session.StoredKnown = false; session.Ledger.Drop(liveSnapshot); Plugin.Log.LogError((object)(session.FileName + ": live state not written (" + reason + "): " + ex.Message)); } } } internal static string BackupFolder(string fileName) { return Path.Combine(Path.Combine(Transport.CharacterFolder, "backups"), fileName); } internal static string Backup(string fileName, string path, bool force = false) { if (!File.Exists(path)) { return null; } try { string text = BackupFolder(fileName); Directory.CreateDirectory(text); List<string> list = Directory.GetFiles(text, "*.fch").Select(Path.GetFileNameWithoutExtension).ToList(); if (!force && !BackupPlan.IsDue(list, DateTime.Now, PluginConfig.BackupIntervalMinutes.Value)) { return null; } string text2 = BackupPlan.NameFor(DateTime.Now, list); File.Copy(path, Path.Combine(text, text2 + ".fch")); list.Add(text2); foreach (string item in BackupPlan.ToDelete(list, PluginConfig.BackupsToKeep.Value)) { TryDelete(Path.Combine(text, item + ".fch")); } return text2; } catch (Exception ex) { Plugin.Log.LogWarning((object)(fileName + ": backup failed: " + ex.Message)); if (force) { throw; } return null; } } internal static ZNetPeer OnlinePeer(string fileName) { return Sessions.Values.FirstOrDefault((Session x) => x.FileName == fileName && x.Peer?.m_socket != null && x.Peer.m_socket.IsConnected())?.Peer; } internal static void Kick(ZNetPeer peer) { try { peer.m_rpc.Invoke("Error", new object[1] { 4 }); } catch (Exception) { } ZNet.instance.Disconnect(peer); } internal static void RewriteWithGame(string fileName, Action<PlayerProfile> change) { //IL_0024: Unknown result type (might be due to invalid IL or missing references) //IL_002a: Expected O, but got Unknown string text = PathOf(fileName); string text2 = "sansara-incoming_" + fileName; string text3 = PathOf(text2); try { File.Copy(text, text3, overwrite: true); PlayerProfile val = new PlayerProfile(text2, (FileSource)2); if (!val.Load()) { throw new InvalidDataException("refused by the game's loader"); } change(val); CreateBackupRef.Invoke(val) = false; if (!val.Save()) { throw new IOException("the game's writer failed"); } ProfileFile.Unframe(File.ReadAllBytes(text3)); Transport.MoveOver(text3, text); } finally { TryDelete(text3); TryDelete(text3 + ".old"); string[] files = Directory.GetFiles(Transport.CharacterFolder, text2 + "_backup*"); for (int i = 0; i < files.Length; i++) { TryDelete(files[i]); } } } internal static void RewritePlayerData(string fileName, Func<byte[], byte[]> change) { //IL_0008: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Expected O, but got Unknown string path = PathOf(fileName); PlayerProfile val = new PlayerProfile(fileName, (FileSource)2); if (!val.Load()) { throw new InvalidDataException("server file refused by the game's loader"); } byte[] array = PlayerDataRef.Invoke(val); if (array == null) { throw new InvalidDataException("the character has no player data yet"); } byte[] profileData = ProfileFile.Unframe(File.ReadAllBytes(path)); Transport.WriteFileAtomic(path, ProfileFile.Frame(ProfileFile.ReplacePlayerData(profileData, array, change(array)))); } internal static string PathOf(string fileName) { return Transport.CharacterFolder + fileName + ".fch"; } internal static bool IsTemporary(string fileName) { return fileName.StartsWith("sansara-incoming_", StringComparison.Ordinal); } private static string SenderOf(ZRpc rpc) { return ((object)rpc).GetHashCode().ToString(); } internal static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch (Exception) { } } } internal static class Texts { public const int ErrSecondCharacter = 7101; public const int ErrHasProgress = 7102; public const int ErrServerCopyBroken = 7103; public const int ErrTimeout = 7104; public const int ErrImportFailed = 7105; public const string Imported = "Персонаж перенесён в новый мир: вещи, рецепты, сила Форсакена и еда сброшены, навыки сохранены."; public static string ForCode(int code) { return code switch { 7101 => "На сервере «Сансара» у вашего аккаунта уже есть персонаж. Войдите им.", 7102 => "Этот персонаж уже играл в другом мире, а на сервере «Сансара» его нет, и перенос персонажей закрыт. Создайте нового персонажа для сервера.", 7103 => "Не удалось прочитать копию персонажа с сервера. Сообщите администратору; ваш файл не изменён.", 7104 => "Сервер не прислал персонажа вовремя. Попробуйте зайти ещё раз.", 7105 => "Не удалось перенести персонажа. Зайдите им в любой мир Valheim 1.0.20 (например, в одиночную игру), выйдите и попробуйте снова — или создайте нового персонажа.", _ => null, }; } } internal static class Transport { public const string RpcLoad = "SansaraCharacters_Load"; public const string RpcSave = "SansaraCharacters_Save"; public const string RpcLive = "SansaraCharacters_Live"; public const string RpcHello = "SansaraCharacters_Hello"; public const string RpcImport = "SansaraCharacters_Import"; private const int SendQueueLimit = 100000; private const float SendTimeoutSeconds = 30f; private static long _messageId = DateTime.UtcNow.Ticks; public static string CharacterFolder => SaveSystem.GetCharacterFolderPath((FileSource)2); public static IEnumerable<bool> Send(ZNetPeer peer, string rpc, long sequence, byte[] payload) { long id = ++_messageId; List<byte[]> parts = ((payload == null || payload.Length == 0) ? new List<byte[]>() : Chunks.Split(Chunks.Deflate(payload))); if (parts.Count == 0) { Invoke(peer, rpc, id, sequence, 0, 0, Array.Empty<byte>()); yield return true; yield break; } float deadline = Time.realtimeSinceStartup + 30f; for (int i = 0; i < parts.Count; i++) { while (Connected(peer) && peer.m_socket.GetSendQueueSize() > 100000) { if (Time.realtimeSinceStartup > deadline) { Plugin.Log.LogWarning((object)$"{rpc}: send queue did not drain in {30f:0} s, stopped at {i}/{parts.Count}"); yield break; } yield return false; } if (!Connected(peer)) { Plugin.Log.LogWarning((object)$"{rpc}: peer gone after {i}/{parts.Count} parts"); break; } Invoke(peer, rpc, id, sequence, i, parts.Count, parts[i]); yield return true; } } public static void SendAsync(ZNetPeer peer, string rpc, long sequence, byte[] payload) { ((MonoBehaviour)ZNet.instance).StartCoroutine(Run(Send(peer, rpc, sequence, payload))); } public static void SendBlocking(ZNetPeer peer, string rpc, long sequence, byte[] payload) { foreach (bool item in Send(peer, rpc, sequence, payload)) { if (!item) { TryFlush(peer.m_socket); Thread.Sleep(10); } } TryFlush(peer.m_socket); } public static bool Receive(Reassembler reassembler, string sender, ZPackage pkg, out byte[] payload, out long sequence) { long messageId = pkg.ReadLong(); sequence = pkg.ReadLong(); int index = pkg.ReadInt(); int total = pkg.ReadInt(); byte[] part = pkg.ReadByteArray(); byte[] array = reassembler.Add(sender, messageId, index, total, part, Time.realtimeSinceStartup); payload = ((array == null) ? null : ((array.Length == 0) ? Array.Empty<byte>() : Chunks.Inflate(array))); return payload != null; } public static bool Connected(ZNetPeer peer) { if (peer?.m_socket != null) { return peer.m_socket.IsConnected(); } return false; } public static void TryFlush(ISocket socket) { try { if (socket != null) { socket.Flush(); } } catch (Exception) { } } public static void WriteFileAtomic(string path, byte[] bytes) { string text = path + ".tmp"; File.WriteAllBytes(text, bytes); MoveOver(text, path); } public static void MoveOver(string source, string target) { if (!File.Exists(target)) { File.Move(source, target); return; } try { File.Replace(source, target, null); } catch (Exception ex) when (ex is PlatformNotSupportedException || ex is NotSupportedException || ex is IOException) { File.Copy(source, target, overwrite: true); File.Delete(source); } } private static void Invoke(ZNetPeer peer, string rpc, long id, long sequence, int index, int total, byte[] part) { //IL_0000: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Expected O, but got Unknown ZPackage val = new ZPackage(); val.Write(id); val.Write(sequence); val.Write(index); val.Write(total); val.Write(part); peer.m_rpc.Invoke(rpc, new object[1] { val }); } private static IEnumerator Run(IEnumerable<bool> steps) { foreach (bool step in steps) { if (!step) { yield return null; } } } } } namespace SansaraCharacters.Core { public enum LoadMode { Normal, ImportAllowed, Imported, ImportFailed } public enum JoinDecision { UseServerCopy, AcceptNew, Import, RefuseHasProgress, RefuseSecondCharacter } public static class Admission { public static bool IsSecondCharacter(bool oneCharacterPerAccount, bool isAdmin, bool hasServerCopy, string account, IEnumerable<string> existingFileNames) { if (!oneCharacterPerAccount || isAdmin || hasServerCopy) { return false; } return existingFileNames.Any((string n) => CharacterFileName.BelongsTo(n, account)); } public static JoinDecision OnServerAnswer(bool serverSentCopy, LoadMode mode, bool firstSpawn, int worldDataCount) { if (serverSentCopy) { return JoinDecision.UseServerCopy; } if (firstSpawn && worldDataCount == 0) { return JoinDecision.AcceptNew; } if (mode != LoadMode.ImportAllowed) { return JoinDecision.RefuseHasProgress; } return JoinDecision.Import; } } public static class BackupPlan { public const string Format = "yyyy-MM-ddTHH-mm-ss"; public static string NameFor(DateTime now, ICollection<string> existing) { string text = now.ToString("yyyy-MM-ddTHH-mm-ss", CultureInfo.InvariantCulture); if (!existing.Contains(text)) { return text; } int num = 2; while (existing.Contains(text + "-" + num)) { num++; } return text + "-" + num; } public static bool IsDue(IEnumerable<string> existing, DateTime now, int intervalMinutes) { if (intervalMinutes <= 0) { return true; } DateTime dateTime = (from t in existing.Select(ParseTime) where t.HasValue select t.Value).DefaultIfEmpty(DateTime.MinValue).Max(); if (dateTime == DateTime.MinValue || dateTime > now) { return true; } return now - dateTime >= TimeSpan.FromMinutes(intervalMinutes); } public static List<string> ToDelete(IEnumerable<string> existing, int keep) { List<string> list = existing.Where((string n) => ParseTime(n).HasValue).OrderBy<string, string>((string n) => n, StringComparer.Ordinal).ToList(); int num = list.Count - Math.Max(1, keep); if (num <= 0) { return new List<string>(); } return list.Take(num).ToList(); } public static DateTime? ParseTime(string name) { if (name == null || name.Length < "yyyy-MM-ddTHH-mm-ss".Length) { return null; } if (!DateTime.TryParseExact(name.Substring(0, "yyyy-MM-ddTHH-mm-ss".Length), "yyyy-MM-ddTHH-mm-ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) { return null; } return result; } } public static class CharacterFileName { public const string BackupMarker = "_backup_"; private static readonly char[] Invalid = new char[11] { '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0', '.' }; public static string Account(string hostName) { if (string.IsNullOrEmpty(hostName)) { return "Unknown"; } if (!hostName.All(char.IsDigit)) { return Sanitize(hostName); } return "Steam_" + hostName; } public static string For(string hostName, string playerName) { return Account(hostName) + "_" + Sanitize((playerName ?? "").ToLowerInvariant()); } public static bool BelongsTo(string fileName, string account) { if (fileName != null && fileName.StartsWith(account + "_", StringComparison.Ordinal)) { return fileName.IndexOf("_backup_", StringComparison.Ordinal) < 0; } return false; } private static string Sanitize(string value) { StringBuilder stringBuilder = new StringBuilder(value.Length); foreach (char c in value) { stringBuilder.Append((Array.IndexOf(Invalid, c) >= 0 || char.IsControl(c)) ? '_' : c); } return stringBuilder.ToString(); } } public static class CharacterQuery { public static string Resolve(IEnumerable<string> fileNames, string query, out string error) { List<string> source = fileNames.ToList(); string q = (query ?? "").Trim(); error = null; if (q.StartsWith("@", StringComparison.Ordinal)) { q = q.Substring(1).Trim(); } if (q == "") { error = "не указан персонаж"; return null; } string text = source.FirstOrDefault((string f) => string.Equals(f, q, StringComparison.Ordinal)); if (text != null) { return text; } string lower = q.ToLowerInvariant(); List<string> list = source.Where((string f) => NameOf(f) == lower).ToList(); if (list.Count == 1) { return list[0]; } if (list.Count > 1) { error = "персонажей с именем «" + q + "» несколько: " + string.Join(", ", list) + "; укажите @файл"; return null; } error = "персонаж «" + q + "» не найден"; return null; } public static string NameOf(string fileName) { string[] array = (fileName ?? "").Split(new char[1] { '_' }, 3); object obj; if (array.Length != 3) { obj = fileName; if (obj == null) { return ""; } } else { obj = array[2]; } return (string)obj; } public static string AccountOf(string fileName) { string[] array = (fileName ?? "").Split(new char[1] { '_' }, 3); if (array.Length != 3) { return ""; } return array[0] + "_" + array[1]; } } public static class Chunks { public const int PartSize = 200000; public static byte[] Deflate(byte[] data) { using MemoryStream memoryStream = new MemoryStream(); using (DeflateStream deflateStream = new DeflateStream(memoryStream, CompressionLevel.Optimal, leaveOpen: true)) { deflateStream.Write(data, 0, data.Length); } return memoryStream.ToArray(); } public static byte[] Inflate(byte[] data, int maxLength = 67108864) { using MemoryStream stream = new MemoryStream(data); using DeflateStream deflateStream = new DeflateStream(stream, CompressionMode.Decompress); using MemoryStream memoryStream = new MemoryStream(); byte[] array = new byte[81920]; int num; while ((num = deflateStream.Read(array, 0, array.Length)) > 0) { if (memoryStream.Length + num > maxLength) { throw new InvalidDataException("payload too large"); } memoryStream.Write(array, 0, num); } return memoryStream.ToArray(); } public static List<byte[]> Split(byte[] data, int partSize = 200000) { List<byte[]> list = new List<byte[]>(); if (data == null || data.Length == 0) { return list; } for (int i = 0; i < data.Length; i += partSize) { byte[] array = new byte[Math.Min(partSize, data.Length - i)]; Buffer.BlockCopy(data, i, array, 0, array.Length); list.Add(array); } return list; } } public sealed class Reassembler { private sealed class Entry { public readonly SortedDictionary<int, byte[]> Parts = new SortedDictionary<int, byte[]>(); public int Total; public double Expires; } public const double ExpirySeconds = 60.0; public const int MaxParts = 400; private readonly Dictionary<string, Entry> _entries = new Dictionary<string, Entry>(); public int Count => _entries.Count; public byte[] Add(string sender, long messageId, int index, int total, byte[] part, double now) { foreach (string item in (from kv in _entries where kv.Value.Expires < now select kv.Key).ToList()) { _entries.Remove(item); } if (total <= 0) { return Array.Empty<byte>(); } if (total > 400 || index < 0 || index >= total || part == null) { throw new InvalidDataException("bad part " + index + "/" + total); } string key = sender + ":" + messageId; if (!_entries.TryGetValue(key, out var value)) { value = new Entry { Total = total, Expires = now + 60.0 }; _entries[key] = value; } if (value.Total != total) { throw new InvalidDataException("part count changed for message " + messageId); } value.Parts[index] = part; if (value.Parts.Count < value.Total) { return null; } _entries.Remove(key); return value.Parts.Values.SelectMany((byte[] p) => p).ToArray(); } public void Forget(string sender) { foreach (string item in _entries.Keys.Where((string k) => k.StartsWith(sender + ":", StringComparison.Ordinal)).ToList()) { _entries.Remove(item); } } } public static class ImportWindow { public const string Format = "yyyy-MM-dd HH:mm"; public static DateTime? ParseUntil(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } if (DateTime.TryParseExact(value.Trim(), "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) { return result; } throw new FormatException("ImportUntil '" + value + "': expected yyyy-MM-dd HH:mm"); } public static bool IsOpen(DateTime? until, DateTime now) { if (until.HasValue) { return now <= until.Value; } return false; } } public sealed class LiveSnapshot { public long Sequence { get; } public byte[] PlayerData { get; } public LiveSnapshot(long sequence, byte[] playerData) { Sequence = sequence; PlayerData = playerData; } } public sealed class LiveLedger { public long StoredSequence { get; private set; } public LiveSnapshot Pending { get; private set; } public bool OnFullSave(long sequence) { if (sequence <= StoredSequence) { return false; } StoredSequence = sequence; if (Pending != null && Pending.Sequence <= sequence) { Pending = null; } return true; } public bool OnLive(LiveSnapshot snapshot) { if (snapshot == null || snapshot.Sequence <= StoredSequence) { return false; } if (Pending != null && snapshot.Sequence <= Pending.Sequence) { return false; } Pending = snapshot; return true; } public LiveSnapshot TakePending() { return Pending; } public void MarkApplied(LiveSnapshot snapshot) { if (snapshot.Sequence > StoredSequence) { StoredSequence = snapshot.Sequence; } if (Pending != null && Pending.Sequence <= snapshot.Sequence) { Pending = null; } } public void Drop(LiveSnapshot snapshot) { if (Pending == snapshot) { Pending = null; } } } public static class PlayerDataStrip { public const int SupportedVersion = 34; public const int InventoryVersion = 110; public const int SkillsVersion = 2; public const float BaseHealth = 25f; public const float BaseStamina = 75f; public const float NoDeath = 999999f; public static readonly string[] DroppedCustomData = new string[1] { "eaqs_backup" }; private static readonly UTF8Encoding Utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); public static byte[] Strip(byte[] playerData) { if (playerData == null || playerData.Length < 4) { throw new InvalidDataException("no player data"); } using MemoryStream memoryStream = new MemoryStream(playerData); BinaryReader r = new BinaryReader(memoryStream, Utf8); try { using MemoryStream memoryStream2 = new MemoryStream(playerData.Length); BinaryWriter w = new BinaryWriter(memoryStream2, Utf8); try { int num = r.ReadInt32(); if (num != 34) { throw new InvalidDataException($"player data version {num},