From b7703831a969e802fa50c33328374e68ee8ebb9c Mon Sep 17 00:00:00 2001 From: Weird Phrog <23280161+WeirdPhrog@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:40:17 +0300 Subject: [PATCH 1/6] DetectBuildingAuth optimization - Optimize building auth detection by checking auth state during spatial query and stopping on the first matching block Co-authored-by: DezLife <69988794+DezLife@users.noreply.github.com> --- plugins/rust/RustApp.cs | 80 +++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/plugins/rust/RustApp.cs b/plugins/rust/RustApp.cs index 2ad543e..6cbc8ca 100644 --- a/plugins/rust/RustApp.cs +++ b/plugins/rust/RustApp.cs @@ -250,7 +250,7 @@ public class PluginStatePlayerMetaDto public class PluginStatePlayerDto { - public static PluginStatePlayerDto FromConnection(Network.Connection connection, string status) + public static PluginStatePlayerDto FromConnection(Connection connection, string status) { var userid = connection.userid; if (!players.TryGetValue(userid, out var payload)) @@ -4715,32 +4715,82 @@ private void SoundToast(BasePlayer player, string text, SoundToastType type) player.Command("gametip.showtoast", (int)type, text, 1); } - private static readonly BaseEntity[] _buildAuthArr = new BaseEntity[32]; - private static readonly Func _buildAuthFilter = static e => e is BuildingPrivlidge; + private const float FastSearchRadius = 16f; + private const float CheckRadius = FastSearchRadius + 2f; + private const float SqrCheckRadius = CheckRadius * CheckRadius; + + private static readonly Dictionary _buildAuthStates = new(128); + private static readonly BaseEntity[] _detectResult = new BaseEntity[1]; + private static readonly Func _detectFilter = DetectFilter; + + private static Vector3 _detectPos; + private static ulong _detectUserId; - // It is more optimized way to detect building authed instead of default BasePlayer.IsBuildingAuthed() private static bool DetectBuildingAuth(BasePlayer player) { - const float SearchRadius = 22f; - const float SqrRadius = SearchRadius * SearchRadius; - Vector3 pos = player.transform.position; - int count = BaseEntity.Query.Server.GetInSphereFast(pos, SearchRadius, _buildAuthArr, _buildAuthFilter); + _detectPos = pos; + _detectUserId = player.userID; try { + return BaseEntity.Query.Server.GetInSphereFast(pos, FastSearchRadius, _detectResult, _detectFilter) > 0; + } + finally + { + _buildAuthStates.Clear(); + _detectResult[0] = null; + } + } + + private static bool DetectFilter(BaseEntity ent) + { + if (ent is not BuildingBlock block) + { + return false; + } + + uint buildingId = block.buildingID; + if (!_buildAuthStates.TryGetValue(buildingId, out bool isAuthed)) + { + _buildAuthStates[buildingId] = isAuthed = IsAuthed(buildingId, _detectUserId); + } + + if (!isAuthed) + { + return false; + } + + return (block.transform.position - _detectPos).sqrMagnitude <= SqrCheckRadius; + + static bool IsAuthed(uint buildingId, ulong userid) + { + BuildingManager.Building building = BuildingManager.server.GetBuilding(buildingId); + if (building == null) + { + return false; + } + + ListHashSet privileges = building.buildingPrivileges; + if (privileges == null || privileges.Count == 0) + { + return false; + } + + int count = privileges.Count; for (int i = 0; i < count; i++) { - BuildingPrivlidge tc = (BuildingPrivlidge)_buildAuthArr[i]; - if ((tc.transform.position - pos).sqrMagnitude <= SqrRadius) - return tc.IsAuthed(player); + BuildingPrivlidge tc = privileges[i]; + if (tc == null || !tc.IsAuthed(userid)) + { + continue; + } + + return true; } + return false; } - finally - { - Array.Clear(_buildAuthArr, 0, count); - } } private static readonly HashSet _normalizeAllowed = new() From 62b25c56768959d4505f2bf07d867a31e5c7e0a8 Mon Sep 17 00:00:00 2001 From: Weird Phrog <23280161+WeirdPhrog@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:47:14 +0300 Subject: [PATCH 2/6] Improve paintable signage entity filtering - Restrict signage destroy handling to supported paintable entities - Add shared IsPaintable helper for signage hooks - Filter OnEntityBuilt signage updates by supported paintable types --- plugins/rust/RustApp.cs | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/plugins/rust/RustApp.cs b/plugins/rust/RustApp.cs index 6cbc8ca..5614ebe 100644 --- a/plugins/rust/RustApp.cs +++ b/plugins/rust/RustApp.cs @@ -5179,7 +5179,7 @@ public override byte[] GetImage() private void OnEntityKill(BaseNetworkable entity) { - if (entity is not ISignage || entity.net is null) + if (!IsPaintable(entity) || entity.net == null) { return; } @@ -5187,19 +5187,10 @@ private void OnEntityKill(BaseNetworkable entity) _RustAppEngine?.SignageWorker?.AddSignageDestroy(entity.net.ID.Value.ToString()); } - private void OnImagePost(BasePlayer player, string url, bool raw, ISignage signage, uint textureIndex) - { - _RustAppEngine?.SignageWorker?.SignageCreate(new SignageUpdate(player, signage, textureIndex, url)); - } - + // ISignage can only be Signage, PhotoFrame, or CarvablePumpkin here private void OnSignUpdated(ISignage signage, BasePlayer player, int textureIndex = 0) { - if (player == null) - { - return; - } - - if (signage.GetTextureCRCs()[textureIndex] == 0) + if (player == null || signage.GetTextureCRCs()[textureIndex] == 0) { return; } @@ -5207,6 +5198,7 @@ private void OnSignUpdated(ISignage signage, BasePlayer player, int textureIndex _RustAppEngine?.SignageWorker?.SignageCreate(new SignageUpdate(player, signage, (uint)textureIndex)); } + // Egg Suit private void OnItemPainted(PaintedItemStorageEntity entity, Item item, BasePlayer player, byte[] image) { if (entity._currentImageCrc == 0) @@ -5229,7 +5221,7 @@ private void OnFireworkDesignChanged(PatternFirework firework, ProtoBuf.PatternF private void OnEntityBuilt(Planner plan, GameObject go) { - if (go.ToBaseEntity() is not ISignage signage || plan.GetOwnerPlayer() is not BasePlayer player) + if (go.ToBaseEntity() is not ISignage signage || !IsPaintable(signage) || plan.GetOwnerPlayer() is not BasePlayer player) { return; } @@ -5244,5 +5236,16 @@ private void OnEntityBuilt(Planner plan, GameObject go) }); } + // Sil external hook + private void OnImagePost(BasePlayer player, string url, bool raw, ISignage signage, uint textureIndex) + { + _RustAppEngine?.SignageWorker?.SignageCreate(new SignageUpdate(player, signage, textureIndex, url)); + } + + private static bool IsPaintable(object entity) + { + return entity is Signage or PhotoFrame or CarvablePumpkin or PaintedItemStorageEntity; + } + #endregion } \ No newline at end of file From b769a70f8b5130020e42b99b260b52fdd1a7b8be Mon Sep 17 00:00:00 2001 From: Weird Phrog <23280161+WeirdPhrog@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:51:29 +0300 Subject: [PATCH 3/6] Non-alloc request serialization, refactor StableRequest - Refactored state update payload structure and removed duplicated server_info - Added custom JSON serialization for Vector3, Quaternion, and ulong values - no more ToString() allocs - Replaced string-based position, rotation, and team payload fields with typed values - Cached map grid coordinate strings to reduce repeated allocations - Added Execute overloads for common success/error callback combinations - Refactored response deserialization into TryDeserializeResponse - Preserved previous behavior on parse errors by skipping typed success callback - Simplified API request call sites and removed unused error parameters Serialization optimizations by Bombardir Co-authored-by: Bombardir <10889246+Bombardir@users.noreply.github.com> --- plugins/rust/RustApp.cs | 554 +++++++++++++++++++++++++++------------- 1 file changed, 380 insertions(+), 174 deletions(-) diff --git a/plugins/rust/RustApp.cs b/plugins/rust/RustApp.cs index 5614ebe..f4e3b18 100644 --- a/plugins/rust/RustApp.cs +++ b/plugins/rust/RustApp.cs @@ -255,36 +255,37 @@ public static PluginStatePlayerDto FromConnection(Connection connection, string var userid = connection.userid; if (!players.TryGetValue(userid, out var payload)) { - payload = new PluginStatePlayerDto(); - players[userid] = payload; - payload.steam_id = connection.player is BasePlayer basePlayer ? basePlayer.UserIDString : RustApp.GetSteamIdString(userid); - payload.steam_name = connection.username.Replace("", "blank"); - payload.ip = IPAddressWithoutPort(connection.ipaddress); - payload.no_license = DetectNoLicense(connection); - payload.team = Pool.Get>(); + players[userid] = payload = new PluginStatePlayerDto + { + steam_id = connection.player is BasePlayer basePlayer ? basePlayer.UserIDString : GetSteamIdString(userid), + steam_name = connection.username.Replace("", "blank"), + ip = IPAddressWithoutPort(connection.ipaddress), + no_license = DetectNoLicense(connection) + }; } payload.ping = Network.Net.sv.GetAveragePing(connection); payload.seconds_connected = (int)connection.GetSecondsConnected(); payload.language = _RustApp.lang.GetLanguage(payload.steam_id); payload.status = status; - try { payload.meta = CollectPlayerMeta(payload.steam_id, payload.meta); } catch (Exception ex) { Debug(ex.ToString()); } - - payload.team ??= Pool.Get>(); + payload.team ??= Pool.Get>(); payload.team.Clear(); - RelationshipManager.PlayerTeam? newTeam = RelationshipManager.ServerInstance.FindPlayersTeam(userid); - if (newTeam == null) + var newTeam = RelationshipManager.ServerInstance.FindPlayersTeam(userid); + if (newTeam != null) { - return payload; + var members = newTeam.members; + for (int i = 0; i < members.Count; i++) + { + ulong member = members[i]; + if (member != userid) + { + payload.team.Add(member); + } + } } - for (int index = 0; index < newTeam.members.Count; index++) - { - ulong member = newTeam.members[index]; - if (member != userid) - payload.team.Add(member.ToString()); - } + try { payload.meta = CollectPlayerMeta(payload.steam_id, payload.meta); } catch (Exception ex) { Debug(ex.ToString()); } return payload; } @@ -293,9 +294,9 @@ public static PluginStatePlayerDto FromPlayer(BasePlayer player) { PluginStatePlayerDto payload = FromConnection(player.Connection, "active"); - payload.position = player.transform.position.ToString(); - payload.rotation = player.eyes.rotation.ToString(); - payload.coords = MapHelper.PositionToString(player.transform.position); + payload.position = player.transform.position; + payload.rotation = player.eyes.rotation; + payload.coords = PositionToGridString(player.transform.position); payload.can_build = DetectBuildingAuth(player); payload.is_raiding = DetectIsRaidBlock(player); @@ -311,8 +312,10 @@ public static PluginStatePlayerDto FromPlayer(BasePlayer player) public int seconds_connected; public string language; - [CanBeNull] public string position; - [CanBeNull] public string rotation; + [JsonConverter(typeof(Vector3Converter))] + public Vector3 position; + [JsonConverter(typeof(QuaternionConverter))] + public Quaternion rotation; [CanBeNull] public string coords; public bool can_build = false; @@ -323,7 +326,9 @@ public static PluginStatePlayerDto FromPlayer(BasePlayer player) public string status; public PluginStatePlayerMetaDto meta = new(); - public List team; + + [JsonProperty(ItemConverterType = typeof(UlongConverter))] + public List team; public void FreePooledFields() { @@ -333,24 +338,13 @@ public void FreePooledFields() public class PluginStateUpdatePayload : PluginServerDto { - public PluginServerDto server_info; - public List players; public Dictionary disconnected; public Dictionary team_changes; - public override void LeavePool() - { - base.LeavePool(); - server_info = Pool.Get(); - } - public override void EnterPool() { base.EnterPool(); - - if (server_info != null) Pool.Free(ref server_info); - if (players != null) Pool.FreeUnmanaged(ref players); if (disconnected != null) Pool.FreeUnmanaged(ref disconnected); if (team_changes != null) Pool.FreeUnmanaged(ref team_changes); @@ -647,8 +641,10 @@ public void EnterPool() public class PluginPlayerAlertDugUpStashMeta { public string steam_id; - public string owner_steam_id; - public string position; + [JsonConverter(typeof(UlongConverter))] + public ulong owner_steam_id; + [JsonConverter(typeof(Vector3Converter))] + public Vector3 position; public string square; } @@ -730,10 +726,11 @@ public class PluginSignageCreateDto : Pool.IPooled public ulong net_id; public byte[] base64_image; public string type; - public string position; + [JsonConverter(typeof(Vector3Converter))] + public Vector3 position; public string square; - public static PluginSignageCreateDto Create(string steamId, ulong netId, byte[] base64Image, string type, string position, string square) + public static PluginSignageCreateDto Create(string steamId, ulong netId, byte[] base64Image, string type, Vector3 position, string square) { PluginSignageCreateDto? dto = Pool.Get(); dto.steam_id = steamId; @@ -753,7 +750,7 @@ public void EnterPool() net_id = 0; base64_image = null; type = null; - position = null; + position = default; square = null; } } @@ -1564,19 +1561,19 @@ public void StartPairing(string code) { InvokeRepeating(nameof(WaitPairFinish), 0f, 1f); }, - (err) => + (error) => { - if (Api.ErrorContains(err, "code not exists")) + if (Api.ErrorContains(error, "code not exists")) { Error("Pairing failed: requested code not exists"); } - else if (Api.ErrorContains(err, "pairing prevented from abuse")) + else if (Api.ErrorContains(error, "pairing prevented from abuse")) { Error("Pairing failed: seems this server was already connected to another project, please contact TG: @rustapp_help if you think, that it is wrong"); } else { - Debug($"Pairing failed: unknown exception {err}"); + Debug($"Pairing failed: unknown exception {error}"); } Destroy(this); @@ -1617,15 +1614,15 @@ void SaveData() Destroy(this); } }, - (err) => + (error) => { - if (Api.ErrorContains(err, "code not exists")) + if (Api.ErrorContains(error, "code not exists")) { Error("Pairing failed: seems you closed modal on site"); } else { - Error($"Pairing failed: unknown exception {err}"); + Error($"Pairing failed: unknown exception {error}"); } Destroy(this); @@ -1652,10 +1649,8 @@ public void CycleSendUpdate() public void SendUpdate(Action? onFinished = null) { - CourtApi.PluginStateUpdatePayload? payload = Pool.Get(); payload.FillSnapshot(); - payload.server_info.FillSnapshot(); payload.players = Pool.Get>(); CollectPlayers(payload.players); @@ -1675,10 +1670,10 @@ public void SendUpdate(Action? onFinished = null) Trace("State was sent successfull"); onFinished?.Invoke(); }, - (err) => + (error) => { onFinished?.Invoke(); - Debug($"State sent error: {err}"); + Debug($"State sent error: {error}"); ResurrectDictionary(payload.disconnected, DisconnectReasons); ResurrectDictionary(payload.team_changes, TeamChanges); @@ -1785,8 +1780,7 @@ private class QueueWorker : RustAppWorker private void GetQueueTasks() { - QueueApi.GetQueueTasks().Execute( - CallQueueTasks, + QueueApi.GetQueueTasks().Execute(CallQueueTasks, (error) => { Debug($"Queue retreive failed {error}"); @@ -1841,9 +1835,9 @@ private void ProcessQueueTasks(Dictionary queueResponses) QueueProcessedIds.Clear(); Trace("Ответ по очередям успешно доставлен"); }, - (err) => + (error) => { - Debug($"Failed to process queue: {err}"); + Debug($"Failed to process queue: {error}"); }); } @@ -1999,7 +1993,7 @@ private void CycleBanUpdateWrapper(Action callback) Pool.Free(ref payload); } }, - (_) => + () => { Error($"Failed to process ban checks ({payload.players.Count}), retrying..."); for (int i = 0; i < payload.players.Count; i++) @@ -2100,7 +2094,7 @@ private void SendChatMessages() Pool.Free(ref payload.messages, freeElements: true); Pool.Free(ref payload); }, - (_) => + () => { QueueMessages.AddRange(payload.messages); payload.messages.Clear(); @@ -2155,7 +2149,7 @@ private void CycleReportSend() Pool.Free(ref payload.reports, freeElements: true); Pool.Free(ref payload); }, - (_) => + () => { QueueReportSend.AddRange(payload.reports); payload.reports.Clear(); @@ -2206,7 +2200,7 @@ private void CycleSendPlayerAlerts() Pool.Free(ref payload.alerts, freeElements: true); Pool.Free(ref payload); }, - (_) => + () => { PlayerAlertQueue.AddRange(payload.alerts); payload.alerts.Clear(); @@ -2254,8 +2248,8 @@ public void SignageCreate(BaseImageUpdate update) update.Entity.net.ID.Value, update.GetImage(), update.Entity.ShortPrefabName, - pos.ToString(), - MapHelper.PositionToString(pos)); + pos, + PositionToGridString(pos)); CourtApi.SendSignage(obj).Execute(); @@ -2282,7 +2276,7 @@ private void CycleSendUpdate() { Pool.Free(ref payload); }, - (_) => + () => { DestroyedSignagesQueue.AddRange(payload.net_ids); payload.net_ids.Clear(); @@ -2329,7 +2323,7 @@ private void CycleSendSleepingBags() Pool.Free(ref payload.sleeping_bags, freeElements: true); Pool.Free(ref payload); }, - (_) => + () => { SleepingBags.AddRange(payload.sleeping_bags); payload.sleeping_bags.Clear(); @@ -2384,7 +2378,7 @@ private void CycleSendKills() Pool.Free(ref payload.kills, freeElements: true); Pool.Free(ref payload); }, - (_) => + () => { KillsQueue.AddRange(payload.kills); payload.kills.Clear(); @@ -2425,8 +2419,7 @@ private void CycleUpdateMutes() { PlayerMutes.Clear(); data?.data?.ForEach(AddPlayerMute); - }, - (_) => { }); + }); } public void AddPlayerMute(CourtApi.PlayerMuteDto playerMuteDto) @@ -2460,8 +2453,7 @@ private void CmdSendContact(BasePlayer player, string contact, string[] args) { SendMessage(player, lang.GetMessage("Contact.Sent", this, player.UserIDString) + $" {string.Join(" ", args)}"); SendMessage(player, lang.GetMessage("Contact.SentWait", this, player.UserIDString)); - }, - (_) => { }); + }); } private void CmdChatReportInterface(BasePlayer player) @@ -4008,6 +4000,21 @@ private void DrawNoticeInterface(BasePlayer player) #region Methods + private static readonly Dictionary _gridToStringCache = new(); + + public static string PositionToGridString(Vector3 position) + { + var grid = MapHelper.PositionToGrid(position); + if (_gridToStringCache.TryGetValue(grid, out var result)) + { + return result; + } + + result = MapHelper.GridToString(grid); + _gridToStringCache.Add(grid, result); + return result; + } + private static string ResolveWeaponName(HitInfo? info) { if (info == null) return "unknown"; @@ -4220,9 +4227,14 @@ private void BanCreate(string steamId, CourtApi.PluginBanCreatePayload payload) { string reason = payload.reason; - CourtApi.BanCreate(payload).Execute( - () => Log($"Player {steamId} banned for {reason}"), - (err) => Error($"Failed to ban {steamId}. Reason: {err}")); + CourtApi.BanCreate(payload).Execute(() => + { + Log($"Player {steamId} banned for {reason}"); + }, + (error) => + { + Error($"Failed to ban {steamId}. Reason: {error}"); + }); Pool.Free(ref payload); } @@ -4233,7 +4245,10 @@ private void BanDelete(string steamId) { Log($"Player {steamId} unbanned"); }, - (err) => Error($"Failed to unban {steamId}. Reason: {err}")); + (error) => + { + Error($"Failed to unban {steamId}. Reason: {error}"); + }); } private void CreatePlayerAlertsCustom(Plugin plugin, string message, object? data = null, object? meta = null) @@ -4272,9 +4287,10 @@ private void CreatePlayerAlertsCustom(Plugin plugin, string message, object? dat category: $"{plugin.Name} • {(name ?? "")}", customLinks: customLinks); - CourtApi.CreatePlayerAlertsCustom(payload).Execute( - (error) => Debug($"Failed to send custom alert: {error}") - ); + CourtApi.CreatePlayerAlertsCustom(payload).Execute(onException: (error) => + { + Debug($"Failed to send custom alert: {error}"); + }); Pool.Free(ref payload); } @@ -4283,16 +4299,157 @@ private void CreatePlayerAlertsCustom(Plugin plugin, string message, object? dat #region StableRequest + #region Custom Json Stuff + + public sealed class UlongConverter : JsonConverter + { + [ThreadStatic] + private static char[] _reusableBuffer; + public override bool CanRead => false; + public override bool CanConvert(Type objectType) => objectType == typeof(ulong); + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (writer is not CustomJsonTextWriter customWriter) + { + throw new JsonSerializationException("Cannot write ulong using non CustomJsonTextWriter"); + } + + if (value is not ulong number) + { + throw new JsonSerializationException($"Unsupported type: {value?.GetType()}"); + } + + _reusableBuffer ??= new char[256]; + var buffer = _reusableBuffer; + var pos = WriteUlong(buffer, number); + customWriter.WriteRawStringValue(buffer, pos); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotImplementedException("Unnecessary because CanRead is false."); + } + } + + public sealed class Vector3Converter : JsonConverter + { + [ThreadStatic] + private static char[] _reusableBuffer; + public override bool CanRead => false; + public override bool CanConvert(Type objectType) => objectType == typeof(Vector3); + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (writer is not CustomJsonTextWriter customWriter) + { + throw new JsonSerializationException("Cannot write Vector3 using non CustomJsonTextWriter"); + } + + if (value is not Vector3 vec3) + { + throw new JsonSerializationException($"Unsupported type: {value?.GetType()}"); + } + + var buffer = _reusableBuffer ??= new char[256]; + var pos = WriteTuple(buffer, vec3.x, vec3.y, vec3.z); + customWriter.WriteRawStringValue(buffer, pos); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotImplementedException("Unnecessary because CanRead is false."); + } + } + + public sealed class QuaternionConverter : JsonConverter + { + [ThreadStatic] + private static char[] _reusableBuffer; + public override bool CanRead => false; + public override bool CanConvert(Type objectType) => objectType == typeof(Quaternion); + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (writer is not CustomJsonTextWriter customWriter) + { + throw new JsonSerializationException("Cannot write Quaternion using non CustomJsonTextWriter"); + } + + if (value is not Quaternion quat) + { + throw new JsonSerializationException($"Unsupported type: {value?.GetType()}"); + } + + var buffer = _reusableBuffer ??= new char[256]; + var pos = WriteTuple(buffer, quat.x, quat.y, quat.z, quat.w); + customWriter.WriteRawStringValue(buffer, pos); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotImplementedException("Unnecessary because CanRead is false."); + } + } + + private static int WriteUlong(char[] buffer, ulong value) + { + if (!value.TryFormat(buffer.AsSpan(), out int len, default, CultureInfo.InvariantCulture)) + { + throw new JsonSerializationException("Failed to serialize ulong value"); + } + + return len; + } + + private static int WriteTuple(char[] buffer, float x, float y, float z) + { + int pos = 0; + buffer[pos++] = '('; + pos = WriteFloat(buffer, pos, x); + buffer[pos++] = ','; + pos = WriteFloat(buffer, pos, y); + buffer[pos++] = ','; + pos = WriteFloat(buffer, pos, z); + buffer[pos++] = ')'; + return pos; + } + + private static int WriteTuple(char[] buffer, float x, float y, float z, float w) + { + int pos = 0; + buffer[pos++] = '('; + pos = WriteFloat(buffer, pos, x); + buffer[pos++] = ','; + pos = WriteFloat(buffer, pos, y); + buffer[pos++] = ','; + pos = WriteFloat(buffer, pos, z); + buffer[pos++] = ','; + pos = WriteFloat(buffer, pos, w); + buffer[pos++] = ')'; + return pos; + } + + private static int WriteFloat(char[] buffer, int pos, float value) + { + if (!value.TryFormat(buffer.AsSpan(pos), out int len, "F3", CultureInfo.InvariantCulture)) + { + throw new JsonSerializationException("Failed to serialize float value"); + } + + return pos + len; + } + [ThreadStatic] private static char[] _reusableCharBuffer; private static readonly UTF8Encoding _encoding = new(false); - public sealed class PooledTextReaderUtf8 : TextReader + public sealed class CustomTextReader : TextReader { private readonly int _length; private int _pos; - public PooledTextReaderUtf8(ReadOnlySpan data) + public CustomTextReader(ReadOnlySpan data) { var requiredBufferLen = _encoding.GetMaxCharCount(data.Length); if (_reusableCharBuffer == null || _reusableCharBuffer.Length < requiredBufferLen) @@ -4300,41 +4457,57 @@ public PooledTextReaderUtf8(ReadOnlySpan data) var length = _reusableCharBuffer?.Length ?? 2048; _reusableCharBuffer = new char[Math.Max(length * 2, requiredBufferLen)]; } + _length = _encoding.GetChars(data, _reusableCharBuffer); _pos = 0; - } public override int Read(char[] buffer, int index, int count) { if (buffer == null) + { throw new ArgumentNullException(nameof(buffer), "Buffer cannot be null."); + } + if (index < 0) + { throw new ArgumentOutOfRangeException(nameof(index), "Non-negative number required."); + } + if (count < 0) + { throw new ArgumentOutOfRangeException(nameof(count), "Non-negative number required."); + } + if (buffer.Length - index < count) + { throw new ArgumentException("Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection."); + } + int readCount = _length - _pos; if (readCount > 0) { if (readCount > count) + { readCount = count; + } + _reusableCharBuffer.AsSpan(_pos, readCount).CopyTo(buffer.AsSpan(index)); _pos += readCount; } + return readCount; } } - public sealed class PooledTextWriterUtf8 : TextWriter + public sealed class CustomTextWriter : TextWriter { [ThreadStatic] private static byte[] _reusableByteBuffer; - private int _pos; + public override Encoding Encoding => _encoding; - public PooledTextWriterUtf8() : base(CultureInfo.InvariantCulture) + public CustomTextWriter() : base(CultureInfo.InvariantCulture) { _reusableCharBuffer ??= new char[4096]; _pos = 0; @@ -4348,12 +4521,11 @@ public ArraySegment AsArraySegment() var length = _reusableByteBuffer?.Length ?? 2048; _reusableByteBuffer = new byte[Math.Max(length * 2, requiredBufferLen)]; } + var len = _encoding.GetBytes(_reusableCharBuffer, 0, _pos, _reusableByteBuffer, 0); return new ArraySegment(_reusableByteBuffer, 0, len); } - public override Encoding Encoding => _encoding; - public override void Write(char value) { Grow(1); @@ -4377,7 +4549,10 @@ public override void Write(ReadOnlySpan buffer) public override void Write(string value) { if (value == null) + { return; + } + Grow(value.Length); value.CopyTo(0, _reusableCharBuffer, _pos, value.Length); _pos += value.Length; @@ -4388,22 +4563,42 @@ private void Grow(int requestedSize) var freeSize = _reusableCharBuffer.Length - _pos; if (freeSize < requestedSize) { - var newBuffer = new char[Math.Max(_reusableCharBuffer.Length * 2, _reusableCharBuffer.Length + requestedSize)]; - _reusableCharBuffer.AsSpan().CopyTo(newBuffer); + var newBuffer = new char[Math.Max(_reusableCharBuffer.Length * 2, _pos + requestedSize)]; + _reusableCharBuffer.AsSpan(0, _pos).CopyTo(newBuffer); _reusableCharBuffer = newBuffer; } } } + public class CustomJsonTextWriter : JsonTextWriter + { + private readonly TextWriter _textWriter; + + public CustomJsonTextWriter(TextWriter textWriter) : base(textWriter) + { + _textWriter = textWriter; + } + + public void WriteRawStringValue(char[] buffer, int count) + { + WriteRawValue(string.Empty); + _textWriter.Write(QuoteChar); + _textWriter.Write(buffer, 0, count); + _textWriter.Write(QuoteChar); + } + } + + #endregion + public class StableRequest where T : class { - private Uri url; - private string method; - private object? data; + private readonly Uri _url; + private readonly string _method; + private readonly object _data; - public StableRequest(Uri url, RequestMethod requestMethod, object? data) + public StableRequest(Uri url, RequestMethod requestMethod, object data) { - method = requestMethod switch + _method = requestMethod switch { RequestMethod.GET => UnityWebRequest.kHttpVerbGET, RequestMethod.PUT => UnityWebRequest.kHttpVerbPUT, @@ -4412,80 +4607,76 @@ public StableRequest(Uri url, RequestMethod requestMethod, object? data) _ => throw new ArgumentOutOfRangeException(nameof(requestMethod), requestMethod, null) }; - this.url = url; - this.data = data; + _url = url; + _data = data; } - public StableRequest(string url, RequestMethod requestMethod, object? data) - : this(new Uri(url), requestMethod, data) { } + public StableRequest(string url, RequestMethod requestMethod, object data) : this(new Uri(url), requestMethod, data) { } public void Execute() { - Rust.Global.Runner.StartCoroutine(SendWebRequestDeserialize(onComplete: null, onException: null)); + Rust.Global.Runner.StartCoroutine(SendRequest(null, null, null, null)); } - public void Execute(Action onException) + public void Execute(Action onComplete) { - Rust.Global.Runner.StartCoroutine(SendWebRequestDeserialize(onComplete: null, onException)); + Rust.Global.Runner.StartCoroutine(SendRequest(onComplete, null, null, null)); } - public void Execute(Action onComplete, Action onException) + public void Execute(Action onComplete) { - Rust.Global.Runner.StartCoroutine(SendWebRequestDeserialize(onComplete, onException)); + Rust.Global.Runner.StartCoroutine(SendRequest(null, onComplete, null, null)); } - // Overload for requests when we don't need to deserialize response - public void Execute(Action onComplete, Action onException) + public void Execute(Action onException) { - Rust.Global.Runner.StartCoroutine(SendWebRequest(onComplete, onException)); + Rust.Global.Runner.StartCoroutine(SendRequest(null, null, null, onException)); } - private IEnumerator SendWebRequest(Action onComplete, Action onException) + public void Execute(Action onComplete, Action onException) { - using var request = CreateWebRequest(); + Rust.Global.Runner.StartCoroutine(SendRequest(onComplete, null, onException, null)); + } - yield return request.SendWebRequest(); + public void Execute(Action onComplete, Action onException) + { + Rust.Global.Runner.StartCoroutine(SendRequest(onComplete, null, null, onException)); + } - if (TryGetError(request, out var error)) - { - onException?.Invoke(error); - yield break; - } + public void Execute(Action onComplete, Action onException) + { + Rust.Global.Runner.StartCoroutine(SendRequest(null, onComplete, onException, null)); + } - onComplete?.Invoke(); + public void Execute(Action onComplete, Action onException) + { + Rust.Global.Runner.StartCoroutine(SendRequest(null, onComplete, null, onException)); } - private IEnumerator SendWebRequestDeserialize(Action onComplete, Action onException) + private IEnumerator SendRequest(Action onComplete, Action onCompleteT, Action onException, Action onExceptionText) { using var request = CreateWebRequest(); - yield return request.SendWebRequest(); - if (TryGetError(request, out var error)) - { - onException?.Invoke(error); - yield break; - } - - if (onComplete == null) + if (request.result == UnityWebRequest.Result.Success) { - yield break; - } + onComplete?.Invoke(); - try - { - var obj = DeserializeWebResponse(request); - onComplete.Invoke(obj); + if (onCompleteT != null && TryDeserializeResponse(request, out var deserialized)) + { + onCompleteT.Invoke(deserialized); + } } - catch (Exception parseException) + else { - Error($"Failed to parse response ({request.method.ToUpper()} {request.url}): {parseException} (Response: {request.downloadHandler?.text})"); + onException?.Invoke(); + onExceptionText?.Invoke(GetError(request)); } } private UnityWebRequest CreateWebRequest() { - var request = new UnityWebRequest(url, method) + var request = new UnityWebRequest(_url, _method) { downloadHandler = new DownloadHandlerBuffer(), timeout = 10 @@ -4496,62 +4687,67 @@ private UnityWebRequest CreateWebRequest() request.SetRequestHeader(name, value); } - SetWebRequestPayload(request, data); - return request; - } - - private static void SetWebRequestPayload(UnityWebRequest request, object data) - { - if (data == null) + if (_data != null) { - return; - } + using var stringWriter = new CustomTextWriter(); + using var jsonWriter = new CustomJsonTextWriter(stringWriter); + _jsonSerializer.Serialize(jsonWriter, _data); + var dataArray = stringWriter.AsArraySegment(); + var dataNativeArray = new NativeArray(dataArray.Count, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + NativeArray.Copy(dataArray.Array, dataNativeArray, dataArray.Count); - using var stringWriter = new PooledTextWriterUtf8(); - _jsonSerializer.Serialize(stringWriter, data); - var dataArray = stringWriter.AsArraySegment(); - - var dataNativeArray = new NativeArray(dataArray.Count, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); - NativeArray.Copy(dataArray.Array, dataNativeArray, dataArray.Count); + request.uploadHandler = new UploadHandlerRaw(dataNativeArray, true) + { + contentType = "application/json" + }; + } - request.uploadHandler = new UploadHandlerRaw(dataNativeArray, transferOwnership: true) - { - contentType = "application/json" - }; + return request; } - private static bool TryGetError(UnityWebRequest request, out string error) + private static bool TryDeserializeResponse(UnityWebRequest request, out T deserialized) { - if (request.result == UnityWebRequest.Result.Success) + deserialized = default; + + try { - error = null; - return false; - } + var data = request.downloadHandler.nativeData; + if (data.Length == 0 || data.Length == 2 && data[0] == '[' && data[1] == ']') + { + return true; + } - error = $"Error: {request.result}. Message: {request.downloadHandler?.text.ToLower() ?? "possible network errors, contact @rustapp_help if you see > 5 minutes"}"; - if (error.Contains("502 bad gateway") || error.Contains("cloudflare")) + using var textReader = new CustomTextReader(data.AsReadOnlySpan()); + using var reader = new JsonTextReader(textReader); + deserialized = _jsonSerializer.Deserialize(reader); + return true; + } + catch (Exception ex) { - error = "rustapp is restarting, wait please"; + Error($"Failed to parse response ({request.method.ToUpper()} {request.url}): {ex} (Response: {request.downloadHandler?.text})"); + return false; } - return true; } - private static T DeserializeWebResponse(UnityWebRequest request) + private static string GetError(UnityWebRequest request) { - var data = request.downloadHandler.nativeData; - if (data.Length == 0) + string message; + string downloadHandlerText = request.downloadHandler?.text; + if (string.IsNullOrEmpty(downloadHandlerText)) { - return default; + message = "possible network errors, contact @rustapp_help if you see this for more than 5 minutes"; } - - if (data.Length == 2 && data[0] == '[' && data[1] == ']') + else { - return default; + if (downloadHandlerText.Contains("502 bad gateway", StringComparison.OrdinalIgnoreCase) || downloadHandlerText.Contains("cloudflare", StringComparison.OrdinalIgnoreCase)) + { + return "rustapp is restarting, please wait"; + } + + message = downloadHandlerText; } - using var textReader = new PooledTextReaderUtf8(data.AsReadOnlySpan()); - using var reader = new JsonTextReader(textReader); - return _jsonSerializer.Deserialize(reader); + return $"Error: {request.result}. Message: {message}"; } } @@ -4563,9 +4759,14 @@ private void RustApp_PlayerMuteCreate(string targetSteamId, string reason, strin { CourtApi.PlayerMuteCreateDto? payload = CourtApi.PlayerMuteCreateDto.Create(targetSteamId, reason, duration, broadcast, comment, referenceMessageText); - CourtApi.PlayerMuteCreate(payload).Execute( - () => Puts($"Player ({targetSteamId}) is muted"), - (err) => PrintError($"Failed to mute player: {err}")); + CourtApi.PlayerMuteCreate(payload).Execute(() => + { + Puts($"Player ({targetSteamId}) is muted"); + }, + (error) => + { + PrintError($"Failed to mute player: {error}"); + }); Pool.Free(ref payload); } @@ -4574,9 +4775,14 @@ private void RustApp_PlayerMuteDelete(string targetSteamId) { CourtApi.PlayerMuteDeleteDto? payload = CourtApi.PlayerMuteDeleteDto.Create(targetSteamId); - CourtApi.PlayerMuteDelete(payload).Execute( - () => Puts($"Player ({targetSteamId}) is unmuted"), - (err) => PrintError($"Failed to unmute player: {err}")); + CourtApi.PlayerMuteDelete(payload).Execute(() => + { + Puts($"Player ({targetSteamId}) is unmuted"); + }, + (error) => + { + PrintError($"Failed to unmute player: {error}"); + }); Pool.Free(ref payload); } From d408be6921203ff774e3ff3f78cb623698d1f522 Mon Sep 17 00:00:00 2001 From: Weird Phrog <23280161+WeirdPhrog@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:28:27 +0300 Subject: [PATCH 4/6] Move requests from coroutines to UniTask - Replace coroutine-based UnityWebRequest execution with UniTask and a local cancellation timeout. This manually aborts hanging requests before the underlying curl timeout, preventing the native crashes observed when many requests time out simultaneously, while keeping existing callbacks and error handling behavior --- plugins/rust/RustApp.cs | 46 +++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/plugins/rust/RustApp.cs b/plugins/rust/RustApp.cs index f4e3b18..a0bbdc1 100644 --- a/plugins/rust/RustApp.cs +++ b/plugins/rust/RustApp.cs @@ -1,7 +1,7 @@ // Reference: ZString +// Reference: UniTask using System; -using System.Collections; using System.Collections.Generic; using System.Drawing; using System.Drawing.Imaging; @@ -9,8 +9,10 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading; using ConVar; using Cysharp.Text; +using Cysharp.Threading.Tasks; using JetBrains.Annotations; using Network; using Newtonsoft.Json; @@ -4592,6 +4594,8 @@ public void WriteRawStringValue(char[] buffer, int count) public class StableRequest where T : class { + private const string NetworkError = "possible network errors, contact @rustapp_help if you see this for more than 5 minutes"; + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); private readonly Uri _url; private readonly string _method; private readonly object _data; @@ -4615,48 +4619,61 @@ public StableRequest(string url, RequestMethod requestMethod, object data) : thi public void Execute() { - Rust.Global.Runner.StartCoroutine(SendRequest(null, null, null, null)); + SendRequestAsync(null, null, null, null).Forget(); } public void Execute(Action onComplete) { - Rust.Global.Runner.StartCoroutine(SendRequest(onComplete, null, null, null)); + SendRequestAsync(onComplete, null, null, null).Forget(); } public void Execute(Action onComplete) { - Rust.Global.Runner.StartCoroutine(SendRequest(null, onComplete, null, null)); + SendRequestAsync(null, onComplete, null, null).Forget(); } public void Execute(Action onException) { - Rust.Global.Runner.StartCoroutine(SendRequest(null, null, null, onException)); + SendRequestAsync(null, null, null, onException).Forget(); } public void Execute(Action onComplete, Action onException) { - Rust.Global.Runner.StartCoroutine(SendRequest(onComplete, null, onException, null)); + SendRequestAsync(onComplete, null, onException, null).Forget(); } public void Execute(Action onComplete, Action onException) { - Rust.Global.Runner.StartCoroutine(SendRequest(onComplete, null, null, onException)); + SendRequestAsync(onComplete, null, null, onException).Forget(); } public void Execute(Action onComplete, Action onException) { - Rust.Global.Runner.StartCoroutine(SendRequest(null, onComplete, onException, null)); + SendRequestAsync(null, onComplete, onException, null).Forget(); } public void Execute(Action onComplete, Action onException) { - Rust.Global.Runner.StartCoroutine(SendRequest(null, onComplete, null, onException)); + SendRequestAsync(null, onComplete, null, onException).Forget(); } - private IEnumerator SendRequest(Action onComplete, Action onCompleteT, Action onException, Action onExceptionText) + private async UniTaskVoid SendRequestAsync(Action onComplete, Action onCompleteT, Action onException, Action onExceptionText) { - using var request = CreateWebRequest(); - yield return request.SendWebRequest(); + using UnityWebRequest request = CreateWebRequest(); + using CancellationTokenSource cts = new(); + using IDisposable timeoutCancellation = cts.CancelAfterSlim(_timeout); + + try + { + await request.SendWebRequest().WithCancellation(cts.Token); + } + catch (Exception) + { + var textError = cts.IsCancellationRequested ? $"Error: ConnectionError. Message: {NetworkError}" : GetError(request); + onException?.Invoke(); + onExceptionText?.Invoke(textError); + return; + } if (request.result == UnityWebRequest.Result.Success) { @@ -4678,8 +4695,7 @@ private UnityWebRequest CreateWebRequest() { var request = new UnityWebRequest(_url, _method) { - downloadHandler = new DownloadHandlerBuffer(), - timeout = 10 + downloadHandler = new DownloadHandlerBuffer() }; foreach (var (name, value) in _ApiHeaders) @@ -4735,7 +4751,7 @@ private static string GetError(UnityWebRequest request) string downloadHandlerText = request.downloadHandler?.text; if (string.IsNullOrEmpty(downloadHandlerText)) { - message = "possible network errors, contact @rustapp_help if you see this for more than 5 minutes"; + message = NetworkError; } else { From deb9beb568bbe2c8bb0d1fd2120d1ae5908bd38f Mon Sep 17 00:00:00 2001 From: Weird Phrog <23280161+WeirdPhrog@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:15:20 +0300 Subject: [PATCH 5/6] Use JSON converters for API IDs and positions - Replace SleepingBag position string with Vector3 + Vector3Converter - Send signage destroyed net IDs as ulong list with item converter - Serialize signage create net_id through UlongConverter - Remove extra ToString() calls for position/net ID payload fields - Keep API payload format consistent for large numeric IDs --- plugins/rust/RustApp.cs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/plugins/rust/RustApp.cs b/plugins/rust/RustApp.cs index a0bbdc1..73b37f1 100644 --- a/plugins/rust/RustApp.cs +++ b/plugins/rust/RustApp.cs @@ -33,7 +33,7 @@ namespace Oxide.Plugins; -[Info("RustApp", "RustApp.io", "3.0.0")] +[Info("RustApp", "RustApp.io", "3.0.1")] public class RustApp : RustPlugin { #region Variables @@ -482,10 +482,11 @@ public class PluginSleepingBagDto : Pool.IPooled { public string initiator_steam_id; public string target_steam_id; - public string position; + [JsonConverter(typeof(Vector3Converter))] + public Vector3 position; public bool are_friends; - public static PluginSleepingBagDto Create(string initiatorSteamId, string targetSteamId, string position, bool areFriends) + public static PluginSleepingBagDto Create(string initiatorSteamId, string targetSteamId, Vector3 position, bool areFriends) { PluginSleepingBagDto? dto = Pool.Get(); dto.initiator_steam_id = initiatorSteamId; @@ -501,7 +502,7 @@ public void EnterPool() { initiator_steam_id = null; target_steam_id = null; - position = null; + position = default; are_friends = false; } } @@ -725,6 +726,7 @@ public static StableRequest CreatePlayerAlertsCustom(PluginPlayerAlertCu public class PluginSignageCreateDto : Pool.IPooled { public string steam_id; + [JsonConverter(typeof(UlongConverter))] public ulong net_id; public byte[] base64_image; public string type; @@ -768,9 +770,10 @@ public static StableRequest SendSignage(PluginSignageCreateDto payload) public class SignageDestroyedDto : Pool.IPooled { - public List net_ids; + [JsonProperty(ItemConverterType = typeof(UlongConverter))] + public List net_ids; - public void LeavePool() => net_ids = Pool.Get>(); + public void LeavePool() => net_ids = Pool.Get>(); public void EnterPool() { @@ -2226,7 +2229,7 @@ private void CycleSendPlayerAlerts() private class SignageWorker : RustAppWorker { - private readonly List DestroyedSignagesQueue = new(); + private readonly List DestroyedSignagesQueue = new(); private new void Awake() { @@ -2235,7 +2238,7 @@ private class SignageWorker : RustAppWorker InvokeRepeating(nameof(CycleSendUpdate), 5f, 5f); } - public void AddSignageDestroy(string netId) + public void AddSignageDestroy(ulong netId) { DestroyedSignagesQueue.Add(netId); } @@ -2840,7 +2843,10 @@ protected override void LoadDefaultMessages() private void CanUserLogin(string name, string id, string ipAddress) { if (ulong.TryParse(id, out ulong userid)) + { _tempDisconnectReasons.Remove(userid); + _pendingKick.Remove(userid); + } _disconnectProcessed.Remove(id); @@ -3025,7 +3031,7 @@ private void CanAssignBed(BasePlayer player, SleepingBag bag, ulong targetPlayer worker.AddSleepingBag(CourtApi.PluginSleepingBagDto.Create( player.UserIDString, GetSteamIdString(targetPlayerId), - bag.transform.position.ToString(), + bag.transform.position, player.Team?.members?.Contains(targetPlayerId) ?? false)); } @@ -5406,7 +5412,7 @@ private void OnEntityKill(BaseNetworkable entity) return; } - _RustAppEngine?.SignageWorker?.AddSignageDestroy(entity.net.ID.Value.ToString()); + _RustAppEngine?.SignageWorker?.AddSignageDestroy(entity.net.ID.Value); } // ISignage can only be Signage, PhotoFrame, or CarvablePumpkin here From 83906bf123299522e42fc0f746c5f27400f51985 Mon Sep 17 00:00:00 2001 From: Weird Phrog <23280161+WeirdPhrog@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:39:53 +0300 Subject: [PATCH 6/6] Reduce Vector3/Quaternion converter precision - Reduce Vector3/Quaternion converter precision from F3 to F2 --- plugins/rust/RustApp.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/rust/RustApp.cs b/plugins/rust/RustApp.cs index 73b37f1..e897052 100644 --- a/plugins/rust/RustApp.cs +++ b/plugins/rust/RustApp.cs @@ -4440,7 +4440,7 @@ private static int WriteTuple(char[] buffer, float x, float y, float z, float w) private static int WriteFloat(char[] buffer, int pos, float value) { - if (!value.TryFormat(buffer.AsSpan(pos), out int len, "F3", CultureInfo.InvariantCulture)) + if (!value.TryFormat(buffer.AsSpan(pos), out int len, "F2", CultureInfo.InvariantCulture)) { throw new JsonSerializationException("Failed to serialize float value"); }