mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
904 lines
31 KiB
C#
904 lines
31 KiB
C#
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<string, int> Targets = new Dictionary<string, int>();
|
|
private static readonly Dictionary<string, int> NotificationIds = new Dictionary<string, int>();
|
|
private static readonly HashSet<string> KnownNotifications = new HashSet<string>();
|
|
|
|
private static readonly HashSet<string> HighFrequencyIds = new HashSet<string>(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<string> RegisteredHandlerKeys = new HashSet<string>(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<Type>();
|
|
|
|
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<string>();
|
|
|
|
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<string>();
|
|
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<string> 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<string> tables, string label, object value)
|
|
{
|
|
if (value == null) return;
|
|
|
|
var keys = new List<string>();
|
|
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<string, int> counts, string key)
|
|
{
|
|
lock (Gate)
|
|
{
|
|
counts.TryGetValue(key, out var current);
|
|
counts[key] = current + 1;
|
|
}
|
|
}
|
|
|
|
private static string Join(Dictionary<string, int> counts)
|
|
{
|
|
var parts = new List<string>();
|
|
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) + "...";
|
|
}
|
|
}
|