Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
/// <summary>
/// Single switch for the per-request chatter the patch used to print on every
/// login (the [Net], [Connection], [Auth], [HttpStatus] and per-connection [TLS]
/// lines). It is OFF by default: the log now carries patch status and real
/// problems only. Set RADICADE_DIAG=1 to get the full trace back when something
/// needs diagnosing.
/// </summary>
internal static class Diag
{
internal static readonly bool Verbose = ReadFlag("RADICADE_DIAG");
private static readonly object Gate = new object();
private static readonly HashSet<string> Seen = new HashSet<string>(StringComparer.Ordinal);
internal static bool ReadFlag(string name)
{
try
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw)) return false;
var value = raw.Trim().ToLowerInvariant();
return !(value == "0" || value == "off" || value == "false" || value == "no");
}
catch
{
return false;
}
}
/// <summary>Per-request tracing: printed only with RADICADE_DIAG=1.</summary>
internal static void Trace(string message)
{
if (!Verbose) return;
Info(message);
}
internal static void Info(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogInfo(message); } catch { }
}
internal static void Warn(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogWarning(message); } catch { }
}
internal static void Error(string message)
{
try { RadicadeEacBypassPlugin.LogSource.LogError(message); } catch { }
}
/// <summary>True the first time this key is seen in the session.</summary>
internal static bool FirstTime(string key)
{
try
{
lock (Gate) return Seen.Add(key);
}
catch
{
return false;
}
}
}
@@ -0,0 +1,137 @@
using System;
using System.Linq;
using HarmonyLib;
using TLS = Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication;
using TimeValid = Org.BouncyCastle.Crypto.Tls.TimeValidVerifyer;
[HarmonyPatch]
internal static class EmbeddedTlsPatches
{
internal static void Apply(Harmony harmony)
{
try
{
harmony.Patch(AccessTools.Method(typeof(TLS), "NotifyServerCertificate"),
prefix: new HarmonyMethod(typeof(EmbeddedTlsPatches), nameof(NotifyServerCertificatePrefix)));
RadicadeEacBypassPlugin.LogSource.LogInfo("[TLS] Patched LegacyTlsAuthentication.NotifyServerCertificate (host-scoped accept)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] NotifyServerCertificate patch failed: {ex.GetType().Name}: {ex.Message}");
}
try
{
harmony.Patch(AccessTools.Method(typeof(TimeValid), "IsValid"),
prefix: new HarmonyMethod(typeof(EmbeddedTlsPatches), nameof(IsValidPrefix)));
RadicadeEacBypassPlugin.LogSource.LogInfo("[TLS] Patched TimeValidVerifyer.IsValid (host-scoped accept)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] TimeValidVerifyer patch failed: {ex.GetType().Name}: {ex.Message}");
}
}
static bool NotifyServerCertificatePrefix(TLS __instance)
{
try
{
var uri = __instance.TargetUri;
var host = uri?.Host;
var verifyerType = __instance.verifyer != null ? __instance.verifyer.GetType().FullName : "<null>";
Diag.Trace(
$"[TLS] NotifyServerCertificate: host={host ?? "<null>"}, verifyer={verifyerType}");
if (host != null && IsHostAllowed(host))
{
Diag.Trace(
$"[TLS] Accepting server certificate for allowed Radicade host '{host}' (embedded-stack bypass)");
return false;
}
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] NotifyServerCertificate prefix error: {ex.GetType().Name}: {ex.Message}");
}
return true;
}
static bool IsValidPrefix(Il2CppSystem.Uri targetUri, ref bool __result)
{
try
{
var host = targetUri?.Host;
if (host != null && IsHostAllowed(host))
{
Diag.Trace(
$"[TLS] TimeValidVerifyer.IsValid forced TRUE for allowed Radicade host '{host}'");
__result = true;
return false;
}
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[TLS] IsValid prefix error: {ex.GetType().Name}: {ex.Message}");
}
return true;
}
public static bool IsHostAllowed(string host)
{
if (string.IsNullOrWhiteSpace(host)) return false;
var raw = RadicadeEacBypassPlugin.TlsAllowedHosts?.Value;
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Split(',')
.Select(h => h.Trim().TrimEnd('.'))
.Where(h => h.Length > 0)
.Any(h => host.TrimEnd('.').Equals(h, StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,107 @@
using System;
using System.IO;
using System.Text;
using System.Reflection;
using BestHTTP;
using HarmonyLib;
/// <summary>
/// Fabric-level repairs for two things the game cannot survive as-is:
/// a chunked response the server truncated, and two responses whose shape the
/// client's reader rejects.
/// </summary>
internal static class HttpTolerancePatches
{
private static long _healed;
private static int _lastHealReport;
internal static void Apply(Harmony harmony)
{
try
{
var target = TargetMethod();
if (target == null)
{
Diag.Warn("[Http] BestHTTP.HTTPResponse.ReadChunkLength(Stream) was not found: " +
"a truncated chunked body will keep failing its request.");
return;
}
harmony.Patch(target,
finalizer: new HarmonyMethod(AccessTools.Method(typeof(HttpTolerancePatches), nameof(Finalizer))));
Diag.Info("[Http] truncated chunked responses are completed instead of failing their request.");
}
catch (Exception ex)
{
Diag.Warn("[Http] could not arm the tolerant chunked reader (" + ex.GetType().Name + ": " + ex.Message + ").");
}
}
internal static MethodBase TargetMethod() =>
AccessTools.Method(typeof(HTTPResponse), "ReadChunkLength", new[] { typeof(Stream) });
/// <summary>
/// "Can't parse '' as a hex number" is BestHTTP reading a chunk-size line from a
/// connection the server had already closed - the pooled keep-alive connection
/// it picked was dead. Ending the body there (chunk length 0) delivers the
/// response that did arrive instead of failing the request, which is what the
/// heartbeats need: their ack body is empty anyway.
/// </summary>
private static Exception Finalizer(Exception __exception, ref int __result)
{
try
{
if (__exception == null) return null;
if (!ResponseRepair.IsTruncatedChunkFailure(__exception.Message)) return __exception;
ReportHealed();
__result = 0;
__exception = null;
return null;
}
catch
{
return __exception;
}
}
private static void ReportHealed()
{
var count = System.Threading.Interlocked.Increment(ref _healed);
var now = Environment.TickCount;
if (count != 1 && unchecked(now - _lastHealReport) < 60000) return;
_lastHealReport = now;
Diag.Warn("[Http] a chunked response ended early (dead pooled connection); the request was completed "
+ "with the bytes that arrived instead of failing. Occurrences so far: " + count + ".");
}
/// <summary>
/// Applies <see cref="ResponseRepair"/> to a live response before the client's own
/// callback reads it. Called from the request funnel, always on the transport
/// thread that owns the response.
/// </summary>
internal static void RepairResponse(HTTPResponse response, string path, string query)
{
try
{
if (response == null || string.IsNullOrEmpty(path)) return;
if (response.StatusCode != 200) return;
string body;
try { body = response.DataAsText; }
catch { return; }
var repaired = ResponseRepair.RepairBody(path, query, body, out var note);
if (repaired == null) return;
response.Data = Encoding.UTF8.GetBytes(repaired);
if (Diag.FirstTime("repair:" + path))
Diag.Warn("[Repair] " + path + ": " + note + ".");
}
catch (Exception ex)
{
Diag.Warn("[Repair] could not repair a " + path + " response: " + ex.GetType().Name + ": " + ex.Message);
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.Text.Json;
using BestHTTP;
using Il2CppInterop.Runtime;
internal static class HubSocketAuth
{
internal static string Token { get; private set; }
internal static string LastLoginRefusalCode { get; private set; }
private static readonly string[] AuthorizationHeaders = { "Authorization", "authorization", "AUTHORIZATION" };
private static readonly System.Collections.Generic.HashSet<string> Stated = new();
internal static bool IsServer(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var request) ||
!Uri.TryCreate(RadicadeEacBypassPlugin.ServerUrl.Value, UriKind.Absolute, out var server)) return false;
return request.Host.Equals(server.Host, StringComparison.OrdinalIgnoreCase) && request.Port == server.Port &&
(request.Scheme == server.Scheme || request.Scheme == (server.Scheme == "https" ? "wss" : "ws"));
}
internal static void Capture(HTTPRequest request)
{
if (request?.Uri == null || !IsServer(request.Uri.ToString())) return;
foreach (var name in AuthorizationHeaders)
{
var values = request.GetHeaderValues(name);
if (values == null || values.Count == 0) continue;
var value = values[0];
if (value != null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
Token = value.Substring(7).Trim();
}
}
internal static void CaptureFromLoginResponse(HTTPRequest request, string url)
{
if (!IsServer(url) || !new Uri(url).AbsolutePath.EndsWith("/connect/token", StringComparison.OrdinalIgnoreCase)) return;
Token = null;
var original = request.Callback;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
new Action<HTTPRequest, HTTPResponse>((req, response) =>
{
try
{
if (response != null && response.StatusCode == 200)
{
using var document = JsonDocument.Parse(response.DataAsText);
if (document.RootElement.TryGetProperty("access_token", out var token) &&
token.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(token.GetString()))
{
Token = token.GetString();
LastLoginRefusalCode = null;
Diag.Trace("[Realtime] Login token captured (value omitted)");
}
else
{
Token = null;
var error = document.RootElement.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
var description = document.RootElement.TryGetProperty("error_description", out var descriptionElement) ? descriptionElement.GetString() : null;
LastLoginRefusalCode = error;
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[Realtime] connect/token answered HTTP 200 without an access_token - that is a login REFUSAL, not a login. " +
"error='" + (error ?? "-") + "', error_description='" + (description ?? "-") + "'.");
}
}
}
catch (Exception ex) { RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] Token parse failed: " + ex.GetType().Name); }
finally { original?.Invoke(req, response); }
}));
}
internal static void AuthenticateHubRequest(HTTPRequest request, string url)
{
if (!IsServer(url) || !NameserverRedirect.IsHubUrl(url)) return;
if (!string.IsNullOrWhiteSpace(Token))
{
request.Uri = new Il2CppSystem.Uri(NameserverRedirect.EnsureHubToken(url, Token));
request.SetHeader("Authorization", "Bearer " + Token);
}
Diag.Trace("[Realtime] Hub request " + new Uri(url).AbsolutePath +
"; token=" + (NameserverRedirect.HasHubToken(request.Uri.ToString()) ? "present" : "missing"));
}
internal static void AuthenticateServerRequest(HTTPRequest request, string url)
{
if (request == null || !IsServer(url) || string.IsNullOrWhiteSpace(Token)) return;
try
{
if (IsAnonymousPath(new Uri(url).AbsolutePath)) return;
foreach (var name in AuthorizationHeaders)
{
var values = request.GetHeaderValues(name);
if (values != null && values.Count > 0) return;
}
request.SetHeader("Authorization", "Bearer " + Token);
var path = new Uri(url).AbsolutePath;
if (Stated.Add(path))
Diag.Trace("[Auth] stated the session token on " + path + " (the client sent none)");
}
catch (Exception ex)
{
Diag.Warn("[Auth] could not state the token on " + url + ": " + ex.GetType().Name);
}
}
private static bool IsAnonymousPath(string path) =>
path.StartsWith("/auth/connect/token", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/auth/cachedlogin", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/auth/eac/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,187 @@
#nullable enable
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
using System.Text;
public static class ImageSignatureRequest
{
const int HeaderScanLimit = 0x100;
const int MinimumRecords = 32;
const int PrefixProbe = 8;
const int PackingProbe = 1024;
const int MaxLiteralLength = 1 << 20;
static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true);
public static string? DetectKeyId(byte[]? metadata)
{
if (metadata == null || metadata.Length < 24 ||
BinaryPrimitives.ReadUInt32LittleEndian(metadata) != 0xFAB11BAF ||
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(4)) != 27)
return null;
int table = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(8));
int size = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(12));
int data = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(16));
int dataSize = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(20));
if (table < 24 || size < 0 || size % 8 != 0 || (long)table + size > metadata.Length ||
data < 24 || dataSize < 0 || (long)data + dataSize > metadata.Length)
return null;
return ScanKeyTails(metadata, table, size / 8, data, dataSize);
}
public static string? DetectKeyIdFromGameMetadata(byte[]? metadata)
{
if (metadata == null || metadata.Length < 24) return null;
if (BinaryPrimitives.ReadUInt32LittleEndian(metadata) == 0xFAB11BAF &&
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(4)) == 27)
return DetectKeyId(metadata);
return DetectKeyIdByHeaderScan(metadata);
}
static string? ScanKeyTails(byte[] metadata, int tableOffset, int recordCount, int dataOffset, int dataSize)
{
string? key = null;
for (int record = 0; record < recordCount; record++)
{
var (length, index) = Record(metadata, tableOffset, record);
if (length < 0 || index < 0 || (long)index + length > dataSize) return null;
if (length != 8 && length != 18) continue;
var literal = Encoding.UTF8.GetString(metadata, dataOffset + index, length);
if (literal != ".rec.net" && literal != ".radicade.internal") continue;
var candidate = "KEY:RSA:p1" + literal;
if (key != null && key != candidate) return null;
key = candidate;
}
return key;
}
static (int Length, int Index) Record(byte[] metadata, int tableOffset, int record)
{
int offset = tableOffset + 8 * record;
return (BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset)),
BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset + 4)));
}
public static string? DetectKeyIdByHeaderScan(byte[]? metadata)
{
if (metadata == null || metadata.Length < HeaderScanLimit + 8) return null;
var fields = new List<int>();
for (int offset = 0; offset + 4 <= HeaderScanLimit; offset += 4)
{
int value = BinaryPrimitives.ReadInt32LittleEndian(metadata.AsSpan(offset));
if (value > 0 && value <= metadata.Length && !fields.Contains(value)) fields.Add(value);
}
var tables = new List<(int Offset, int Count)>();
foreach (var tableOffset in fields)
foreach (var tableSize in fields)
{
if (tableSize % 8 != 0 || tableSize / 8 < MinimumRecords) continue;
if (tableOffset < HeaderScanLimit || (long)tableOffset + tableSize > metadata.Length) continue;
tables.Add((tableOffset, tableSize / 8));
}
var slices = new List<(int Offset, int Size)>();
foreach (var dataOffset in fields)
foreach (var dataSize in fields)
{
if (dataSize <= 0 || dataOffset < HeaderScanLimit) continue;
if ((long)dataOffset + dataSize > metadata.Length) continue;
slices.Add((dataOffset, dataSize));
}
tables.Sort((a, b) => b.Count.CompareTo(a.Count));
foreach (var (tableOffset, count) in tables)
foreach (var (dataOffset, dataSize) in slices)
{
if (!LooksLikeLiteralTable(metadata, tableOffset, count, dataOffset, dataSize, PrefixProbe)) continue;
if (!SampleDecodesAsText(metadata, tableOffset, count, dataOffset, dataSize)) continue;
if (!LooksLikeLiteralTable(metadata, tableOffset, count, dataOffset, dataSize, PackingProbe)) continue;
var key = ScanKeyTails(metadata, tableOffset, count, dataOffset, dataSize);
if (key != null) return key;
}
return null;
}
static bool LooksLikeLiteralTable(byte[] metadata, int tableOffset, int count, int dataOffset, int dataSize, int probe)
{
int examined = Math.Min(count, probe);
int contiguous = 0;
long running = 0;
for (int record = 0; record < examined; record++)
{
var (length, index) = Record(metadata, tableOffset, record);
if (length < 0 || length > MaxLiteralLength || index < 0 || (long)index + length > dataSize) return false;
if (index == running) contiguous++;
running = index + length;
}
return contiguous >= examined - Math.Max(1, examined / 100);
}
static bool SampleDecodesAsText(byte[] metadata, int tableOffset, int count, int dataOffset, int dataSize)
{
int step = Math.Max(1, count / 48);
for (int record = 0; record < count; record += step)
{
var (length, index) = Record(metadata, tableOffset, record);
if (length < 0 || index < 0 || (long)index + length > dataSize) return false;
if (length == 0) continue;
try
{
StrictUtf8.GetCharCount(metadata, dataOffset + index, length);
}
catch (ArgumentException)
{
return false;
}
}
return true;
}
public static string AddKeyId(string url, string serverUrl, string? keyId)
{
if (string.IsNullOrEmpty(keyId) ||
!Uri.TryCreate(url, UriKind.Absolute, out var request) ||
!Uri.TryCreate(serverUrl, UriKind.Absolute, out var server) ||
(server.Scheme != "http" && server.Scheme != "https") ||
request.Scheme != server.Scheme || request.Authority != server.Authority ||
!request.AbsolutePath.StartsWith(server.AbsolutePath.TrimEnd('/') + "/images/", StringComparison.OrdinalIgnoreCase))
return url;
var parameters = request.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)
.Where(p => !Uri.UnescapeDataString(p.Split('=')[0]).Equals("rcsig", StringComparison.OrdinalIgnoreCase))
.Append("rcsig=" + Uri.EscapeDataString(keyId));
return request.GetLeftPart(UriPartial.Path) + "?" + string.Join("&", parameters) + request.Fragment;
}
}
@@ -0,0 +1,510 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
internal static class HileGate
{
internal static bool Enabled;
internal static int DroppedCount;
public static bool DropEnabled { get; } = IsDropEnabled();
private static bool IsDropEnabled()
{
try
{
var raw = System.Environment.GetEnvironmentVariable("RADICADE_HILE_GATE");
if (string.IsNullOrWhiteSpace(raw)) return false;
return raw.Trim().ToLowerInvariant() == "drop";
}
catch { return false; }
}
private static bool _dumped;
public static bool ShouldDrop(HTTPRequest request)
{
if (!Enabled || request?.Uri == null) return false;
var path = request.Uri.AbsolutePath;
if (path == null ||
!path.EndsWith("/api/PlayerReporting/v1/hile", System.StringComparison.OrdinalIgnoreCase))
return false;
Dump(request);
return DropEnabled;
}
public static void Dump(HTTPRequest request)
{
if (_dumped) return;
_dumped = true;
try
{
var body = "<unreadable>";
try
{
var raw = request.RawData;
if (raw != null && raw.Length > 0)
{
body = System.Text.Encoding.UTF8.GetString(raw);
if (body.Length > 2000) body = body.Substring(0, 2000) + "...[truncated]";
}
else
{
var entity = request.GetEntityBody();
if (entity != null && entity.Length > 0)
{
var text = System.Text.Encoding.UTF8.GetString(entity);
body = text + " (" + entity.Length + " bytes in the entity body)";
}
else
{
body = "<empty body>";
}
}
}
catch (System.Exception bodyEx)
{
body = "<body read failed: " + bodyEx.Message + ">";
}
var caller = "<unavailable>";
try { caller = System.Environment.StackTrace; } catch { }
RadicadeEacBypassPlugin.LogSource.LogWarning(
"[HileGate] anti-cheat report captured (full wire form):\n " + request.MethodType
+ " " + request.Uri + "\n body: " + body
+ "\n managed caller stack:" + caller);
}
catch (System.Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[HileGate] capture failed: " + ex.Message);
}
}
}
[HarmonyPatch(typeof(HTTPManager), "SendRequest", new[] { typeof(HTTPRequest) })]
internal static class NameserverConnectionPatches
{
[HarmonyPriority(Priority.Last)]
private static bool Prefix(HTTPRequest request, ref HTTPRequest __result)
{
if (RequestFunnel.Handle(request)) return true;
__result = request;
return false;
}
}
internal static class RequestFunnel
{
private static readonly HashSet<long> Seen = new();
private static bool IsSocketScheme(string url) =>
url.StartsWith("ws:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("wss:", StringComparison.OrdinalIgnoreCase);
internal static bool Handle(HTTPRequest request)
{
if (HileGate.ShouldDrop(request))
{
HileGate.DroppedCount++;
RadicadeEacBypassPlugin.LogSource.LogWarning("[HileGate] dropped anti-cheat report POST (no response will arrive).");
return false;
}
try
{
var uri = request?.Uri?.ToString();
if (!string.IsNullOrEmpty(uri))
Diag.Trace("[Net] " + request.MethodType + " " + uri);
}
catch
{
}
if (request?.Uri == null) return true;
if (!Seen.Add(request.Pointer.ToInt64())) return true;
var original = request.Uri.ToString();
var rewritten = IsSocketScheme(original)
? NameserverRedirect.RewriteWebSocket(original, RadicadeEacBypassPlugin.ServerUrl.Value)
: NameserverRedirect.Rewrite(original, RadicadeEacBypassPlugin.ServerUrl.Value);
var withKeyId = ImageSignatureRequest.AddKeyId(rewritten, RadicadeEacBypassPlugin.ServerUrl.Value,
RadicadeEacBypassPlugin.ImageKeyId);
if (withKeyId != rewritten)
{
Diag.Trace("[ImageSig] stated key id on " + withKeyId);
}
rewritten = withKeyId;
if (rewritten != original)
{
request.Uri = new Il2CppSystem.Uri(rewritten);
AlignHostHeader(request, rewritten);
Diag.Trace("[Connection] Redirected nameserver request to the configured Radicade server.");
}
if (RealtimeDiagnostics.Verbose && IsSocketScheme(original))
{
try
{
var outgoing = new System.Text.StringBuilder("[Realtime] socket request: " + original);
var headers = request.Headers;
if (headers != null)
{
foreach (var header in headers)
{
outgoing.Append(" | ").Append(header.Key).Append("=");
var values = header.Value;
if (values != null)
foreach (var value in values) outgoing.Append(value).Append(",");
}
}
RadicadeEacBypassPlugin.LogSource.LogWarning(outgoing.ToString());
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] socket request dump failed: "
+ ex.GetType().Name + ": " + ex.Message);
}
}
HubSocketAuth.Capture(request);
HubSocketAuth.CaptureFromLoginResponse(request, request.Uri.ToString());
HubSocketAuth.AuthenticateHubRequest(request, request.Uri.ToString());
HubSocketAuth.AuthenticateServerRequest(request, request.Uri.ToString());
if (Diag.Verbose) AuthGrantTrace.Report(request);
HttpOutcomeTrace.Wrap(request);
return true;
}
private static void AlignHostHeader(HTTPRequest request, string url)
{
if (request == null || string.IsNullOrEmpty(url)) return;
Uri target;
if (!Uri.TryCreate(url, UriKind.Absolute, out target) || string.IsNullOrEmpty(target.Host)) return;
var authority = target.IsDefaultPort ? target.Host : target.Host + ":" + target.Port;
try
{
var stated = request.GetHeaderValues("Host");
if (stated != null && stated.Count > 0)
{
if (stated.Count == 1 && stated[0] == authority) return;
request.RemoveHeader("Host");
}
request.SetHeader("Host", authority);
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Connection] could not align the Host header on " + url +
": " + ex.GetType().Name + ": " + ex.Message);
}
}
}
internal static class AuthGrantTrace
{
private static readonly HashSet<long> Reported = new();
private static readonly string[] Fields =
{
"account_id", "username", "password", "birthday", "avatar", "avatarJson",
"email", "platform_auth", "device_id", "device_class", "client_id", "ver"
};
internal static void Report(HTTPRequest request)
{
try
{
if (request?.Uri == null) return;
if (!request.Uri.ToString().EndsWith("/auth/connect/token", StringComparison.OrdinalIgnoreCase)) return;
if (!Reported.Add(request.Pointer.ToInt64())) return;
var body = ReadBody(request);
if (string.IsNullOrEmpty(body))
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[AuthGrant] connect/token body was unreadable; the grant is unknown for this request.");
return;
}
string Value(string name)
{
foreach (var pair in body.Split('&'))
{
var separator = pair.IndexOf('=');
if (separator <= 0) continue;
if (!string.Equals(pair.Substring(0, separator), name, StringComparison.OrdinalIgnoreCase)) continue;
return pair.Substring(separator + 1);
}
return null;
}
bool Present(string name)
{
var value = Value(name);
return !string.IsNullOrWhiteSpace(value);
}
var grant = Value("grant_type") ?? "<absent>";
var present = new List<string>();
foreach (var field in Fields)
if (Present(field)) present.Add(field);
RadicadeEacBypassPlugin.LogSource.LogInfo("[AuthGrant] connect/token grant_type=" + grant
+ "; fields present: " + (present.Count == 0 ? "<none>" : string.Join(", ", present))
+ " (values omitted)");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[AuthGrant] could not read the grant: " + ex.GetType().Name + ": " + ex.Message);
}
}
private static string ReadBody(HTTPRequest request)
{
try
{
var raw = request.RawData;
if (raw != null && raw.Length > 0) return System.Text.Encoding.UTF8.GetString(raw);
}
catch { }
try
{
var entity = request.GetEntityBody();
if (entity != null && entity.Length > 0) return System.Text.Encoding.UTF8.GetString(entity);
}
catch { }
return null;
}
}
[HarmonyPatch]
internal static class SendRequestImplFunnelPatch
{
private static IEnumerable<MethodBase> TargetMethods()
{
var impl = AccessTools.Method(typeof(HTTPManager), "SendRequestImpl", new[] { typeof(HTTPRequest) });
return impl == null ? Enumerable.Empty<MethodBase>() : new MethodBase[] { impl };
}
private static void Prefix(object[] __args)
{
try
{
if (__args == null || __args.Length == 0) return;
RequestFunnel.Handle(__args[0] as HTTPRequest);
}
catch (Exception ex)
{
Diag.Warn("[Net] funnel trace failed: " + ex.GetType().Name + ": " + ex.Message);
}
}
}
internal static class HttpOutcomeTrace
{
private static readonly HashSet<long> Wrapped = new();
private static int _completed;
internal static void Wrap(HTTPRequest request)
{
if (request == null) return;
string url;
string method;
string path;
string query;
try
{
if (request.Uri == null) return;
url = Shorten(request.Uri.ToString());
method = request.MethodType.ToString();
path = request.Uri.AbsolutePath;
query = request.Uri.Query;
}
catch
{
return;
}
try
{
if (!Wrapped.Add(request.Pointer.ToInt64())) return;
var original = request.Callback;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
new Action<HTTPRequest, HTTPResponse>((req, response) =>
{
try
{
HttpTolerancePatches.RepairResponse(response, path, query);
var completed = System.Threading.Interlocked.Increment(ref _completed);
var status = -1;
var bytes = 0;
var reason = string.Empty;
if (response != null)
{
status = response.StatusCode;
try { var data = response.Data; if (data != null) bytes = data.Length; } catch { }
try { if (!response.IsSuccess && !string.IsNullOrEmpty(response.Message)) reason = " reason=" + response.Message; } catch { }
}
Diag.Trace("[HttpStatus] #" + completed + " " + method + " " + url + " -> "
+ (response == null ? "NO RESPONSE" : status.ToString())
+ " bytes=" + bytes + reason);
if (RealtimeDiagnostics.Verbose && url.IndexOf("/notify/hub/v1", StringComparison.OrdinalIgnoreCase) >= 0)
{
try
{
var answer = new System.Text.StringBuilder();
answer.Append("[Realtime] socket answer: status=").Append(response == null ? -1 : status)
.Append(" message=").Append(response == null ? "<none>" : response.Message);
var answerHeaders = response?.Headers;
if (answerHeaders != null)
{
foreach (var header in answerHeaders)
{
answer.Append(" | ").Append(header.Key).Append("=");
var values = header.Value;
if (values != null)
foreach (var value in values) answer.Append(value).Append(",");
}
}
string body = null;
try { body = response?.DataAsText; } catch { }
if (!string.IsNullOrEmpty(body))
answer.Append(" | body=").Append(body.Length > 200 ? body.Substring(0, 200) : body);
RadicadeEacBypassPlugin.LogSource.LogWarning(answer.ToString());
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[Realtime] socket answer dump failed: "
+ ex.GetType().Name + ": " + ex.Message);
}
}
}
catch
{
}
finally
{
try { original?.Invoke(req, response); } catch { }
}
}));
}
catch (Exception ex)
{
Diag.Warn("[HttpStatus] could not instrument " + method + " " + url + ": "
+ ex.GetType().Name + ": " + ex.Message);
}
}
private static string Shorten(string url)
{
try
{
var uri = new Uri(url);
var path = uri.AbsolutePath;
if (path.StartsWith("/auth/cachedlogin/forplatformid/", StringComparison.OrdinalIgnoreCase))
return "/auth/cachedlogin/forplatformid/{redacted}";
return path + (string.IsNullOrEmpty(uri.Query) ? string.Empty : "?" + uri.Query.Length + " chars");
}
catch
{
return "<unparsable>";
}
}
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
public static class NameserverRedirect
{
private static readonly Dictionary<string, string> ServicePrefixes = new(StringComparer.OrdinalIgnoreCase)
{
["api.rec.net"] = "",
["cdn.rec.net"] = "",
["images.rec.net"] = "/images",
["img.rec.net"] = "/images",
["data.rec.net"] = "/data",
["storage.rec.net"] = "/storage",
["notify.rec.net"] = "/notify",
["notifications.rec.net"] = "/notify",
["notify.radicade.internal"] = "/notify",
["notifications.radicade.internal"] = "/notify",
["auth.rec.net"] = "/auth",
["accounts.rec.net"] = "/accounts",
["rooms.rec.net"] = "/rooms",
["matchmaking.rec.net"] = "/matchmaking",
["chat.rec.net"] = "/chat",
["api.radicade.app"] = "",
["cdn.radicade.app"] = "/cdn",
["ai.radicade.app"] = "/ai",
["auth.radicade.app"] = "/auth",
["www.radicade.app"] = "/www",
["cards.radicade.app"] = "/cards",
["notifications.radicade.app"] = "/notify",
["images.radicade.app"] = "/images",
["commerce.radicade.app"] = "/commerce",
["data.radicade.app"] = "/data",
["datacollection.radicade.app"] = "/datacollection",
["discovery.radicade.app"] = "/discovery",
["matchmaking.radicade.app"] = "/matchmaking",
["storage.radicade.app"] = "/storage",
["chat.radicade.app"] = "/chat",
["geo.radicade.app"] = "/geo",
["leaderboard.radicade.app"] = "/leaderboard",
["accounts.radicade.app"] = "/accounts",
["link.radicade.app"] = "/link",
["locker.radicade.app"] = "/locker",
["moderation.radicade.app"] = "/moderation",
["roomcomments.radicade.app"] = "/roomcomments",
["roomieintegrations.radicade.app"] = "/roomieintegrations",
["clubs.radicade.app"] = "/clubs",
["rooms.radicade.app"] = "/rooms",
["econ.radicade.app"] = "/econ",
["bugreporting.radicade.app"] = "/bugreporting",
["cms.radicade.app"] = "/cms",
["gamelogs.radicade.app"] = "/gamelogs",
["platformnotifications.radicade.app"] = "/platformnotifications",
["stringscdn.radicade.app"] = "/stringscdn",
["strings.radicade.app"] = "/strings",
["lists.radicade.app"] = "/lists",
["playersettings.radicade.app"] = "/playersettings",
["studio.radicade.app"] = "/studio",
["studiocdn.radicade.app"] = "/studiocdn",
["thorn.radicade.app"] = "/thorn",
["videos.radicade.app"] = "/videos",
};
public static string Rewrite(string requestUrl, string serverUrl)
{
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var request) ||
(request.Scheme != "http" && request.Scheme != "https"))
return requestUrl;
bool nameserver = request.Host.Equals("ns.rec.net", StringComparison.OrdinalIgnoreCase) ||
request.Host.Equals("ns.radicade.internal", StringComparison.OrdinalIgnoreCase);
bool patchedServer = request.Host.Equals("ns.radicade.app", StringComparison.OrdinalIgnoreCase);
ServicePrefixes.TryGetValue(request.Host, out var servicePrefix);
if (!nameserver && !patchedServer && servicePrefix == null)
return requestUrl;
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var server) ||
(server.Scheme != "http" && server.Scheme != "https") ||
!string.IsNullOrEmpty(server.UserInfo) || !string.IsNullOrEmpty(server.Query) ||
!string.IsNullOrEmpty(server.Fragment))
throw new ArgumentException("Connection.ServerUrl must be an HTTP(S) base URL without credentials, query or fragment.", nameof(serverUrl));
var path = request.AbsolutePath;
if (nameserver)
{
if (path == "/") path = "/ns";
}
else if (servicePrefix is { Length: > 0 })
{
if (servicePrefix == "/notify" && path.StartsWith("/notify/", StringComparison.OrdinalIgnoreCase))
path = path.Substring("/notify".Length);
else if (path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase))
path = path.Substring("/images".Length);
else if (path.StartsWith("/img/", StringComparison.OrdinalIgnoreCase))
path = path.Substring("/img".Length);
path = servicePrefix + path;
}
return server.AbsoluteUri.TrimEnd('/') + path + request.Query;
}
public static string RewriteWebSocket(string requestUrl, string serverUrl)
{
if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var request))
return requestUrl;
var scheme = request.Scheme.ToLowerInvariant();
var secure = scheme == "wss" || scheme == "https";
if (scheme != "ws" && scheme != "wss" && scheme != "http" && scheme != "https")
return requestUrl;
var asHttp = new UriBuilder(request) { Scheme = secure ? "https" : "http" }.Uri.AbsoluteUri;
var rewritten = Rewrite(asHttp, serverUrl);
if (rewritten == asHttp) return requestUrl;
if (!Uri.TryCreate(rewritten, UriKind.Absolute, out var result)) return requestUrl;
return new UriBuilder(result) { Scheme = result.Scheme == "https" ? "wss" : "ws" }.Uri.AbsoluteUri;
}
public static bool IsHubUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
var lower = url.ToLowerInvariant();
var query = lower.IndexOf('?');
var path = query < 0 ? lower : lower.Substring(0, query);
return path.Contains("/hub/v1") || path.EndsWith("/hub")
|| path.Contains("/hub/")
|| path.Contains("notifyhub")
|| path.Contains("/notify/hub");
}
public static bool HasHubToken(string url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
var query = url.IndexOf('?');
if (query < 0) return false;
foreach (var pair in url.Substring(query + 1).Split('&'))
{
var equals = pair.IndexOf('=');
if (equals <= 0 || equals + 1 >= pair.Length) continue;
if (pair.Substring(0, equals).Equals("access_token", StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
public static string EnsureHubToken(string url, string token)
{
if (!IsHubUrl(url)) return url;
if (HasHubToken(url)) return url;
if (string.IsNullOrWhiteSpace(token)) return url;
var raw = token.Trim();
if (raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(7).Trim();
else if (raw.Equals("Bearer", StringComparison.OrdinalIgnoreCase)) raw = string.Empty;
if (raw.Length == 0) return url;
var query = url.IndexOf('?');
var basePart = query < 0 ? url : url.Substring(0, query);
var rest = query < 0 ? string.Empty : url.Substring(query + 1).TrimStart('&');
var accessToken = "access_token=" + Uri.EscapeDataString(raw);
return basePart + "?" + accessToken + (rest.Length == 0 ? string.Empty : "&" + rest);
}
}
@@ -0,0 +1,903 @@
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) + "...";
}
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using BestHTTP;
using HarmonyLib;
internal static class RequestRouteLog
{
private static readonly object Gate = new();
private static readonly HashSet<string> Seen = new();
private const int Limit = 300;
internal static void Apply(Harmony harmony)
{
try
{
var response = AccessTools.TypeByName("BestHTTP.HTTPResponse");
if (response == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[HttpLog] BestHTTP.HTTPResponse not found - REST routes stay unlogged.");
return;
}
System.Reflection.ConstructorInfo constructor = null;
foreach (var candidate in AccessTools.GetDeclaredConstructors(response))
{
var parameters = candidate.GetParameters();
if (parameters.Length == 4
&& parameters[0].ParameterType.Name.Contains("HTTPRequest")
&& parameters[2].ParameterType.Name == "Boolean"
&& parameters[3].ParameterType.Name == "Boolean")
{
constructor = candidate;
break;
}
}
if (constructor == null)
{
RadicadeEacBypassPlugin.LogSource.LogWarning("[HttpLog] HTTPResponse ctor not found - REST routes stay unlogged.");
return;
}
harmony.Patch(constructor, postfix: new HarmonyMethod(typeof(RequestRouteLog), nameof(ResponsePostfix)));
RadicadeEacBypassPlugin.LogSource.LogInfo("[HttpLog] hooked HTTPResponse(HTTPRequest, Stream) - every REST route and status is logged once");
}
catch (Exception ex)
{
RadicadeEacBypassPlugin.LogSource.LogWarning($"[HttpLog] could not hook HTTPResponse: {ex.GetType().Name}: {ex.Message}");
}
}
private static void ResponsePostfix(HTTPResponse __instance, HTTPRequest __0)
{
try
{
var uri = __0?.Uri?.ToString();
if (string.IsNullOrEmpty(uri)) return;
if (!HubSocketAuth.IsServer(uri)) return;
var path = new Uri(uri).AbsolutePath;
if (NameserverRedirect.IsHubUrl(uri)) return;
if (path.Contains("/images/", StringComparison.OrdinalIgnoreCase)) return;
if (path.Contains("/cdn/", StringComparison.OrdinalIgnoreCase)) return;
int status;
try { status = __instance.StatusCode; }
catch { return; }
var method = "REQ";
try { method = __0.MethodType.ToString().ToUpperInvariant(); }
catch { }
var line = $"{status} {method} {path}";
lock (Gate)
{
if (Seen.Count >= Limit || !Seen.Add(line)) return;
}
RadicadeEacBypassPlugin.LogSource.LogInfo($"[HttpLog] {line}");
}
catch
{
}
}
}
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
/// <summary>
/// Pure string surgery for two responses the client cannot cope with as the
/// server sends them. No engine or BestHTTP types live here so the rules can be
/// exercised outside the game.
/// </summary>
internal static class ResponseRepair
{
/// <summary>Loopback probe target: any transport fails (or answers) instantly, so a probe phase can never stall.</summary>
internal const string LoopbackProbeAddress = "127.0.0.1:50000";
private const string QosPathSuffix = "/matchmaking/player/qos";
private const string UserSegmentPathSuffix = "/data/commonquery/usersegment";
private const string AddressKey = "\"address\"";
/// <summary>
/// BestHTTP's chunked reader throws exactly this when the chunk-size line is
/// empty, which is what a keep-alive connection the server had already closed
/// looks like. It is not a bad request, so it must not fail one.
/// </summary>
internal static bool IsTruncatedChunkFailure(string message)
{
if (string.IsNullOrEmpty(message)) return false;
return message.IndexOf("as a hex number", StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Rewrites a response body when the client would otherwise mis-handle it.
/// Returns null when the body is left alone; <paramref name="note"/> explains
/// what changed when it did.
/// </summary>
internal static string RepairBody(string path, string query, string body, out string note)
{
note = null;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(body)) return null;
if (EndsWith(path, QosPathSuffix)) return RepairProbeList(body, ref note);
if (EndsWith(path, UserSegmentPathSuffix)) return RepairUserSegment(query, body, ref note);
return null;
}
/// <summary>
/// The probe list points at retired GCP addresses: five of its six entries are
/// unreachable, so the client's region-probe phase waits out a ping timeout on
/// each boot and login sits there. Pinning every entry at loopback keeps the
/// list's shape but makes the probes answer (or refuse) immediately.
/// </summary>
private static string RepairProbeList(string body, ref string note)
{
var repaired = new StringBuilder(body.Length);
var index = 0;
var replaced = 0;
while (true)
{
var key = body.IndexOf(AddressKey, index, StringComparison.Ordinal);
if (key < 0)
{
repaired.Append(body, index, body.Length - index);
break;
}
repaired.Append(body, index, key - index + AddressKey.Length);
var cursor = key + AddressKey.Length;
while (cursor < body.Length && char.IsWhiteSpace(body[cursor])) cursor++;
if (cursor >= body.Length || body[cursor] != ':')
{
repaired.Append(body, key + AddressKey.Length, cursor - key - AddressKey.Length);
index = cursor;
continue;
}
repaired.Append(body, key + AddressKey.Length, cursor - key - AddressKey.Length);
repaired.Append(':');
cursor++;
while (cursor < body.Length && char.IsWhiteSpace(body[cursor])) cursor++;
if (cursor >= body.Length || body[cursor] != '"')
{
index = cursor;
continue;
}
var close = body.IndexOf('"', cursor + 1);
if (close < 0)
{
repaired.Append(body, cursor, body.Length - cursor);
break;
}
repaired.Append('"').Append(LoopbackProbeAddress).Append('"');
replaced++;
index = close + 1;
}
if (replaced == 0) return null;
note = replaced.ToString(CultureInfo.InvariantCulture) + " region probe address(es) pinned to "
+ LoopbackProbeAddress + " (the server's list points at retired hosts)";
return repaired.ToString();
}
/// <summary>
/// The client's model for this route is a name -> row map
/// (Dictionary&lt;string, ...&gt;), so the flat member object the server answers
/// with makes its reader demand '{' where a string sits and throw
/// "expected:'{', actual:'\"none\"'" (a crash report per login). Wrapping the
/// flat object under the row key(s) the request named gives it the map it wants.
/// </summary>
private static string RepairUserSegment(string query, string body, ref string note)
{
var trimmed = body.TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{') return null;
var keys = new List<string>();
foreach (var value in QueryValues(query))
{
AddKey(keys, value);
AddKey(keys, "user_segment:" + value);
}
if (keys.Count == 0) AddKey(keys, "user_segment");
var repaired = new StringBuilder(body.Length * (keys.Count + 1) + 16);
repaired.Append('{');
for (var i = 0; i < keys.Count; i++)
{
if (i > 0) repaired.Append(',');
repaired.Append('"').Append(keys[i]).Append("\":").Append(body);
}
repaired.Append('}');
note = "userSegment answered as a name -> row map (" + string.Join(", ", keys) + ")";
return repaired.ToString();
}
private static void AddKey(List<string> keys, string key)
{
if (string.IsNullOrEmpty(key)) return;
foreach (var existing in keys)
if (existing == key) return;
keys.Add(key);
}
internal static IEnumerable<string> QueryValues(string query)
{
if (string.IsNullOrEmpty(query)) yield break;
foreach (var pair in query.TrimStart('?').Split('&'))
{
if (pair.Length == 0) continue;
var separator = pair.IndexOf('=');
var value = separator < 0 ? pair : pair.Substring(separator + 1);
value = Uri.UnescapeDataString(value).Trim();
if (value.Length == 0) continue;
yield return value;
}
}
private static bool EndsWith(string value, string suffix) =>
value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,32 @@
using System;
using HarmonyLib;
internal static class WebSocketRedirectPatches
{
internal static void Apply(Harmony harmony)
{
var type = AccessTools.TypeByName("BestHTTP.WebSocket.WebSocket");
int count = 0;
if (type != null)
foreach (var constructor in AccessTools.GetDeclaredConstructors(type))
{
var parameters = constructor.GetParameters();
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(Il2CppSystem.Uri)) continue;
harmony.Patch(constructor, prefix: new HarmonyMethod(typeof(WebSocketRedirectPatches), nameof(Prefix)));
count++;
}
RadicadeEacBypassPlugin.LogSource.LogInfo("[Realtime] WebSocket URI hooks installed: " + count);
}
private static void Prefix(ref Il2CppSystem.Uri __0)
{
if (__0 == null) return;
var url = NameserverRedirect.RewriteWebSocket(__0.ToString(), RadicadeEacBypassPlugin.ServerUrl.Value);
if (HubSocketAuth.IsServer(url) && NameserverRedirect.IsHubUrl(url))
{
url = NameserverRedirect.EnsureHubToken(url, HubSocketAuth.Token);
Diag.Trace("[Realtime] Hub socket constructed; token=" +
(NameserverRedirect.HasHubToken(url) ? "present" : "missing"));
}
__0 = new Il2CppSystem.Uri(url);
}
}