using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text.Json; using System.Threading; using HarmonyLib; internal static class RealtimeDiagnostics { private static readonly object Gate = new object(); private static readonly Dictionary Targets = new Dictionary(); private static readonly Dictionary NotificationIds = new Dictionary(); private static readonly HashSet KnownNotifications = new HashSet(); private static readonly HashSet HighFrequencyIds = new HashSet(StringComparer.Ordinal) { "PresenceUpdate", "AccountUpdate", "SelfAccountUpdate", "SubscriptionUpdatePresence", "SubscriptionUpdateProfile", "SubscriptionUpdateGameSession", "SubscriptionUpdateRoom", "SubscriptionUpdateRoomPlaylist" }; private static int Frames; private static int Failures; private const int VerboseFrames = 40; private const int PreviewChars = 400; internal static void StartHandlerKeyProbe(Harmony harmony) { if (Interlocked.Exchange(ref HandlerKeyProbeStarted, 1) != 0) return; var worker = new Thread(() => { Type dispatcher = null; bool hooksInstalled = false; bool tablesDumped = false; for (int attempt = 1; attempt <= HandlerKeyProbeAttempts; attempt++) { try { if (dispatcher == null) dispatcher = FindNotificationDispatcher(); if (!hooksInstalled && dispatcher != null) hooksInstalled = InstallRegistrationHooks(harmony, dispatcher); if (!tablesDumped && attempt <= TableDumpAttempts) tablesDumped = DumpDispatcherKeys(attempt, dispatcher); if (hooksInstalled && (tablesDumped || attempt >= TableDumpAttempts)) return; } catch (Exception ex) { if (attempt == 1) RadicadeEacBypassPlugin.LogSource.LogWarning( $"[Realtime] handler-key probe failed ({ex.GetType().Name}: {ex.Message}); retrying."); } Thread.Sleep(HandlerKeyProbeIntervalMs); } if (!hooksInstalled) RadicadeEacBypassPlugin.LogSource.LogWarning( $"[Realtime] no handler-registration method was located after {HandlerKeyProbeAttempts} attempts " + "- notification ids cannot be checked against the client's own registrations in this build."); }) { IsBackground = true, Name = "RadicadeNotifyKeyProbe" }; worker.Start(); } private const int HandlerKeyProbeAttempts = 60; private const int HandlerKeyProbeIntervalMs = 2000; private const int TableDumpAttempts = 3; private static int HandlerKeyProbeStarted; private static readonly HashSet RegisteredHandlerKeys = new HashSet(StringComparer.Ordinal); private static bool ChatKeyReported; private static bool InstallRegistrationHooks(Harmony harmony, Type dispatcher) { var hooked = 0; var callbacks = 0; var generics = 0; foreach (var method in AccessTools.GetDeclaredMethods(dispatcher)) { try { if (method.ReturnType != typeof(void)) continue; var parameters = method.GetParameters(); if (parameters.Length != 1 && parameters.Length != 2) continue; var keyType = parameters[0].ParameterType; if (keyType != typeof(string) && !keyType.IsEnum) continue; if (parameters.Length == 2) { if (!IsCallback(parameters[1].ParameterType)) continue; callbacks++; if (method.IsGenericMethodDefinition) { generics++; continue; } } harmony.Patch(method, prefix: new HarmonyMethod(AccessTools.Method(typeof(RealtimeDiagnostics), nameof(RegistrationPrefix)))); hooked++; } catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning( $"[Realtime] could not hook {dispatcher.Name}.{method.Name} for key logging ({ex.GetType().Name}: {ex.Message})."); } } if (hooked == 0) return false; RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] watching {hooked} dispatcher method(s) on {dispatcher.FullName} " + $"({callbacks} registration, {generics} generic overload(s) skipped but funnel through the core, rest channels): " + "every notification id this client accepts will be printed as it registers."); return true; } private static bool IsCallback(Type type) { try { if (typeof(Delegate).IsAssignableFrom(type)) return true; for (var current = type; current != null; current = current.BaseType) { var name = current.Name; if (name == "Delegate" || name == "MulticastDelegate") return true; } } catch { } return false; } private static void RegistrationPrefix(object[] __args, MethodBase __originalMethod) { try { if (__args == null || __args.Length == 0) return; var key = DescribeKey(__args[0]); if (key == null) return; if (__args.Length == 1) { RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] the client called {__originalMethod?.Name}({key}) - " + "a realtime channel/lookup name it expects the server to publish on."); return; } bool first; lock (Gate) first = RegisteredHandlerKeys.Add(key); if (!first) return; RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] the client registered a notification handler under {key} " + $"({__originalMethod?.DeclaringType?.Name}.{__originalMethod?.Name}) - " + $"a notification whose Id EQUALS {key} is handled, anything else is dropped."); if (key.IndexOf("Progression", StringComparison.Ordinal) >= 0 || key.IndexOf("AppVersion", StringComparison.Ordinal) >= 0 || key.IndexOf("Announcement", StringComparison.Ordinal) >= 0) RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] {key} binds a payload of type {DescribeCallback(__args.Length > 1 ? __args[1] : null)}"); ReportChatKeyMatch(); } catch { } } private static string DescribeCallback(object? callback) { if (callback == null) return "(no callback)"; try { var type = callback.GetType(); var candidates = new List(); if (type.IsGenericType) candidates.AddRange(type.GetGenericArguments()); for (var current = type; current != null; current = current.BaseType) { MethodInfo? invoke = null; try { invoke = current.GetMethod("Invoke", BindingFlags.Public | BindingFlags.Instance); } catch { } if (invoke == null) continue; foreach (var parameter in invoke.GetParameters()) candidates.Add(parameter.ParameterType); } var payloads = candidates .Where(t => t != null && t != typeof(void)) .Distinct() .ToList(); if (payloads.Count == 0) return $"{type.FullName} (no payload readable)"; return string.Join(" | ", payloads.Select(DescribePayloadType)); } catch (Exception ex) { return $"(unreadable: {ex.GetType().Name})"; } } private static string DescribePayloadType(Type type) { try { if (type.IsPrimitive || type == typeof(string)) return type.Name; var members = new List(); foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance)) members.Add(field.Name); foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) members.Add(property.Name); members = members.Distinct().OrderBy(m => m, StringComparer.Ordinal).ToList(); if (members.Count == 0) return type.FullName ?? type.Name; return $"{type.FullName} {{{string.Join(", ", members)}}}"; } catch { return type.FullName ?? type.Name; } } private static string DescribeKey(object value) { if (value is string text) return Quote(text); if (value == null) return null; try { var type = value.GetType(); return type.IsEnum ? Quote($"{type.Name}.{value}") : null; } catch { return null; } } private static void ReportChatKeyMatch() { lock (Gate) { if (ChatKeyReported) return; var numeric = RegisteredHandlerKeys.Contains("\"90\""); var named = RegisteredHandlerKeys.FirstOrDefault( key => key.IndexOf("ChatMessage", StringComparison.Ordinal) >= 0); if (!numeric && named == null) return; ChatKeyReported = true; RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] chat push key check: this client registered " + $"{(numeric ? "a handler for the chat enum's number (\"90\")" : "NO handler for the chat enum's number (\"90\")")}" + $"{(named == null ? string.Empty : $", and {named} by name")}. " + "Only an exact match is delivered, so the server's notification Id has to be the " + "spelling that was registered - for chat that is the name."); } } private const int MaxTables = 25; private const int MaxKeysPerTable = 60; private static bool DumpDispatcherKeys(int attempt, Type dispatcher) { var tables = new List(); if (dispatcher != null) CollectStringKeyedTables(dispatcher, tables); if (tables.Count == 0) { foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { var name = assembly.GetName().Name ?? string.Empty; if (!name.StartsWith("RecNet", StringComparison.Ordinal)) continue; Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray(); } catch { continue; } foreach (var type in types) { try { CollectStringKeyedTables(type, tables); } catch { } if (tables.Count >= MaxTables) break; } if (tables.Count >= MaxTables) break; } } if (tables.Count == 0) return false; RadicadeEacBypassPlugin.LogSource.LogInfo( dispatcher != null ? $"[Realtime] the client's notification dispatcher ({dispatcher.FullName}) holds these string keys (attempt {attempt}). " + "A notification Id must EQUAL one of them to be handled - this is the contract:" : $"[Realtime] could not pin the notification dispatcher, so every string-keyed static table in RecNet.* is listed " + $"instead (attempt {attempt}). The handler keys are among them; a notification Id must EQUAL one to be handled:"); foreach (var table in tables) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {table}"); var flat = " " + string.Join(" ", tables) + " "; RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] chat push key check: the client registered {Registered(flat, "\"90\"")} for \"90\" and " + $"{Registered(flat, "ChatMessageReceived")} for \"ChatMessageReceived\". " + "Whichever is registered is the string the server's chat notification Id must equal."); return true; } private static string Registered(string flatKeys, string key) => flatKeys.Contains(" " + key, StringComparison.Ordinal) ? "a handler" : "NO handler"; private static void CollectStringKeyedTables(Type type, List tables) { foreach (var field in type.GetFields( BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) { if (tables.Count >= MaxTables) return; Type fieldType; try { fieldType = field.FieldType; } catch { continue; } if (!IsStringKeyed(fieldType)) continue; object value; try { value = field.GetValue(null); } catch { continue; } AddTable(tables, $"{type.Name}.{field.Name}", value); } } private static bool IsStringKeyed(Type type) { try { return type.IsGenericType && type.GetGenericArguments()[0] == typeof(string); } catch { return false; } } private static void AddTable(List tables, string label, object value) { if (value == null) return; var keys = new List(); try { if (value is System.Collections.IDictionary map) { foreach (var key in map.Keys) keys.Add(Quote(key?.ToString())); } else if (value is System.Collections.IEnumerable items) { foreach (var item in items) keys.Add(KeyOf(item)); } else return; } catch { return; } if (keys.Count == 0) return; keys.Sort(StringComparer.Ordinal); var shown = keys.Count <= MaxKeysPerTable ? string.Join(", ", keys) : string.Join(", ", keys.Take(MaxKeysPerTable)) + $", … (+{keys.Count - MaxKeysPerTable} more)"; tables.Add($"{label}: {shown}"); } private static string KeyOf(object item) { if (item == null) return "(null)"; try { var type = item.GetType(); if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) { var key = type.GetProperty("Key"); if (key != null) return Quote(key.GetValue(item)?.ToString()); } } catch { } return Quote(item.ToString()); } private static string Quote(string text) => text == null ? "(null)" : $"\"{text}\""; private static Type FindNotificationDispatcher() { var byName = FindType("LMBGGGGNBOB"); if (byName != null && DeclaresNotificationEnum(byName)) return byName; foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t != null).ToArray(); } catch { continue; } foreach (var type in types) { try { if (!type.IsEnum || !IsNotificationEnum(type)) continue; RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] notification enum located: {type.FullName} " + $"(message = {Convert.ToInt64(Enum.Parse(type, "MessageReceived"), System.Globalization.CultureInfo.InvariantCulture)}, " + $"kick = {Convert.ToInt64(Enum.Parse(type, "ModerationKick"), System.Globalization.CultureInfo.InvariantCulture)})"); if (type.DeclaringType != null) return type.DeclaringType; continue; } catch { } } } return null; } private static bool DeclaresNotificationEnum(Type type) { try { foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)) if (nested.IsEnum && IsNotificationEnum(nested)) return true; } catch { } return false; } private static bool IsNotificationEnum(Type type) { var members = Enum.GetNames(type); return Array.IndexOf(members, "MessageReceived") >= 0 && Array.IndexOf(members, "ModerationKick") >= 0 && Array.IndexOf(members, "PresenceHeartbeatResponse") >= 0; } internal static System.DateTime? ServerRequestedExitUtc; private const int ExitRequestWindowSeconds = 120; internal static bool ServerRequestedExitRecently() => ServerRequestedExitUtc.HasValue && (System.DateTime.UtcNow - ServerRequestedExitUtc.Value).TotalSeconds < ExitRequestWindowSeconds; private static void NoteServerRequestedExit(string id) { if (id != "20" && id != "6") return; if (ServerRequestedExitRecently()) return; ServerRequestedExitUtc = System.DateTime.UtcNow; RadicadeEacBypassPlugin.LogSource.LogInfo( $"[Realtime] Id={id} arrived - the server asked this client to leave, so the AppExit guard will let the exit through."); } internal static void ApplyExitWatch(Harmony harmony) { try { var transport = FindType("BestHTTP.SignalRCore.Transports.WebSocketTransport"); var target = transport == null ? null : AccessTools.DeclaredMethod(transport, "OnMessage"); if (target == null) { RadicadeEacBypassPlugin.LogSource.LogWarning( "[Realtime] could not watch for the server's quit/logout frames - a moderator's Quit Game may be suppressed as a room-load exit."); return; } harmony.Patch(target, prefix: new HarmonyMethod(typeof(RealtimeDiagnostics), nameof(ExitWatchPrefix))); } catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] exit watch failed to install: {ex.GetType().Name}: {ex.Message}"); } } private static void ExitWatchPrefix(string __1) { var text = __1; if (string.IsNullOrEmpty(text)) return; if (text.IndexOf("\"Notification\"", StringComparison.Ordinal) < 0) return; if (text.IndexOf("\"Id\":\"20\"", StringComparison.Ordinal) >= 0 || text.IndexOf("\"Id\":20", StringComparison.Ordinal) >= 0) { NoteServerRequestedExit("20"); return; } if (text.IndexOf("\"Id\":\"6\"", StringComparison.Ordinal) >= 0 || text.IndexOf("\"Id\":6", StringComparison.Ordinal) >= 0) NoteServerRequestedExit("6"); } internal static bool Verbose = true; internal static void Apply(Harmony harmony, bool verbose) { Verbose = verbose; var transport = FindType("BestHTTP.SignalRCore.Transports.WebSocketTransport"); if (transport == null) { RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] BestHTTP.SignalRCore.Transports.WebSocketTransport not found - notify frame logging stays inactive."); } else { Hook(harmony, transport, "OnOpen", nameof(OnOpenPrefix), "socket open"); Hook(harmony, transport, "HandleHandshakeResponse", nameof(HandshakePrefix), "SignalR handshake answer"); Hook(harmony, transport, "OnMessage", nameof(OnMessagePrefix), "inbound frames"); Hook(harmony, transport, "OnError", nameof(OnErrorPrefix), "transport errors"); Hook(harmony, transport, "OnClosed", nameof(OnClosedPrefix), "socket close with code/reason"); } var hub = FindType("BestHTTP.SignalRCore.HubConnection"); if (hub == null) { RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] BestHTTP.SignalRCore.HubConnection not found - hub-pump guard stays inactive."); } else { HookFinalizer(harmony, hub, "OnMessages", nameof(OnMessagesFinalizer)); } RadicadeEacBypassPlugin.LogSource.LogInfo(Verbose ? "[Realtime] notify-hub tap armed: every inbound frame is read and logged." : "[Realtime] notify-hub tap armed for moderation only - a voice chat restriction is applied from the frame it arrives in; frames are not logged (Diagnostics=false)."); if (Verbose) StartHandlerKeyProbe(harmony); } private static Type FindType(string name) { var direct = AccessTools.TypeByName(name); if (direct != null) return direct; foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { try { var found = assembly.GetType(name, false); if (found != null) return found; } catch { } } return null; } private static void Hook(Harmony harmony, Type type, string method, string prefix, string what) { try { var target = AccessTools.DeclaredMethod(type, method); if (target == null) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] {type.Name}.{method} not found - {what} will not be logged."); return; } harmony.Patch(target, prefix: new HarmonyMethod(typeof(RealtimeDiagnostics), prefix)); if (Verbose) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] hooked {type.Name}.{method} ({what})"); } catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] could not hook {type.Name}.{method}: {ex.GetType().Name}: {ex.Message}"); } } private static void HookFinalizer(Harmony harmony, Type type, string method, string finalizer) { try { var target = AccessTools.DeclaredMethod(type, method); if (target == null) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] {type.Name}.{method} not found - the hub-pump guard stays inactive."); return; } harmony.Patch(target, finalizer: new HarmonyMethod(typeof(RealtimeDiagnostics), finalizer)); if (Verbose) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] guarded {type.Name}.{method} (a frame the client cannot parse is logged, not thrown)"); } catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] could not guard {type.Name}.{method}: {ex.GetType().Name}: {ex.Message}"); } } private static void OnOpenPrefix() { if (Verbose) RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] socket open - the notify hub WebSocket upgraded and is ready."); } private static void HandshakePrefix(string __0) { var text = __0 ?? string.Empty; if (text.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0) RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] hub handshake answered with an error: {Trim(text, 300)}"); else if (Verbose) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] hub handshake accepted: {Trim(text, 120)}"); } private static void OnMessagePrefix(string __1) => ObserveFrame(__1); private static void OnErrorPrefix(string __1) => RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] transport error: {Trim(__1, 300)}"); private static void OnClosedPrefix(ushort __1, string __2) => RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] socket closed code={__1} reason={Trim(__2, 200)}"); private static Exception OnMessagesFinalizer(Exception __exception) { if (__exception == null) return null; lock (Gate) Failures++; RadicadeEacBypassPlugin.LogSource.LogWarning( $"[Realtime] the client failed to handle a hub frame ({__exception.GetType().Name}: {__exception.Message}) - " + $"frame swallowed so the notify socket and the rest of the batch survive (failures={Failures}). " + "A parse error here means a notification payload the client's own parser rejects; the IdToken/summary lines " + "above name which notification it was."); return null; } private static void ObserveFrame(string data) { if (string.IsNullOrEmpty(data)) { if (Verbose) RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] empty frame received (keep-alive)."); return; } foreach (var record in data.Split('\u001e')) { if (record.Length == 0) continue; if (!Verbose && record.IndexOf(VoiceChatRestrictionPatches.NotificationId, StringComparison.Ordinal) < 0) continue; int index; lock (Gate) index = ++Frames; DescribeRecord(index, record); } } private static void DescribeRecord(int index, string record) { try { using var document = JsonDocument.Parse(record); var root = document.RootElement; var type = root.TryGetProperty("type", out var typeElement) ? typeElement.GetRawText() : "?"; var target = root.TryGetProperty("target", out var targetElement) ? targetElement.GetString() : null; if (string.Equals(target, "Notification", StringComparison.Ordinal)) { DescribeNotification(index, root, record); return; } Count(Targets, target ?? ("type " + type)); if (!Verbose) return; if (index <= VerboseFrames) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] frame #{index} target={target ?? "-"} type={type} len={record.Length}: {Trim(record, PreviewChars)}"); else Summarize(index); } catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] frame #{index} is not readable as a hub record ({ex.GetType().Name}: {ex.Message}): {Trim(record, PreviewChars)}"); } } private static void DescribeNotification(int index, JsonElement root, string record) { string envelope = null; if (root.TryGetProperty("arguments", out var arguments) && arguments.ValueKind == JsonValueKind.Array && arguments.GetArrayLength() > 0) { var first = arguments[0]; envelope = first.ValueKind == JsonValueKind.String ? first.GetString() : first.GetRawText(); } string id = null; string idToken = "(no Id)"; string msg = "(no Msg)"; if (!string.IsNullOrEmpty(envelope)) { try { using var inner = JsonDocument.Parse(envelope); var payload = inner.RootElement; if (TryGetProperty(payload, "Id", out var idElement)) { idToken = idElement.ValueKind.ToString(); id = idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : idElement.GetRawText(); } if (TryGetProperty(payload, "Msg", out var msgElement)) msg = Trim(msgElement.GetRawText(), 200); } catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning($"[Realtime] Notification #{index} envelope did not parse at all ({ex.GetType().Name}: {ex.Message}): {Trim(envelope, PreviewChars)}"); envelope = null; } } if (!string.IsNullOrEmpty(id)) NoteServerRequestedExit(id); if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(envelope)) VoiceChatRestrictionPatches.Observe(id, envelope); Count(Targets, "Notification"); Count(NotificationIds, id ?? "(no Id)"); bool firstOfItsKind = false; if (!string.IsNullOrEmpty(id)) lock (Gate) firstOfItsKind = KnownNotifications.Add(id); if (!Verbose) return; bool worthFullDetail = string.IsNullOrEmpty(id) || !HighFrequencyIds.Contains(id); if (worthFullDetail || firstOfItsKind || index <= VerboseFrames) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] Notification #{index} Id={id ?? "(none)"} IdToken={idToken} Msg={msg}"); else Summarize(index); if (worthFullDetail || firstOfItsKind) RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {id ?? "envelope"} envelope: {Trim(envelope ?? record, PreviewChars)}"); } private static void Summarize(int index) { if (index % 25 != 0) return; string targets; string ids; int failures; lock (Gate) { targets = Join(Targets); ids = Join(NotificationIds); failures = Failures; } RadicadeEacBypassPlugin.LogSource.LogInfo($"[Realtime] {index} frames so far - targets: {targets}; notifications: {ids}; handler failures: {failures}"); } private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) { if (element.ValueKind == JsonValueKind.Object) { if (element.TryGetProperty(name, out value)) return true; var lower = char.ToLowerInvariant(name[0]) + name.Substring(1); if (element.TryGetProperty(lower, out value)) return true; } value = default; return false; } private static void Count(Dictionary counts, string key) { lock (Gate) { counts.TryGetValue(key, out var current); counts[key] = current + 1; } } private static string Join(Dictionary counts) { var parts = new List(); foreach (var pair in counts) parts.Add($"{pair.Key} x{pair.Value}"); return parts.Count == 0 ? "none" : string.Join(", ", parts); } private static string Trim(string text, int limit) { if (string.IsNullOrEmpty(text)) return string.Empty; var flat = text.Replace("\r", " ").Replace("\n", " "); return flat.Length <= limit ? flat : flat.Substring(0, limit) + "..."; } }