mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static class CrashAddressMap
|
||||
{
|
||||
private const long DefaultTarget = 0x29a79ec;
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
internal static long TargetRva => _target;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static readonly long _target = ReadTarget();
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_MAP");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static long ReadTarget()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_TARGET");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return DefaultTarget;
|
||||
raw = raw.Trim();
|
||||
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(2);
|
||||
return long.TryParse(raw, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : DefaultTarget;
|
||||
}
|
||||
catch { return DefaultTarget; }
|
||||
}
|
||||
|
||||
internal static void Start()
|
||||
{
|
||||
if (!Enabled) return;
|
||||
var thread = new System.Threading.Thread(Run) { IsBackground = true, Name = "RadicadeAddrMap" };
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
|
||||
|
||||
try { System.Threading.Thread.Sleep(5000); } catch { }
|
||||
|
||||
var log = RadicadeEacBypassPlugin.LogSource;
|
||||
var started = Environment.TickCount;
|
||||
|
||||
IntPtr baseAddress = IntPtr.Zero;
|
||||
var moduleSize = 0L;
|
||||
try
|
||||
{
|
||||
using (var self = System.Diagnostics.Process.GetCurrentProcess())
|
||||
{
|
||||
foreach (System.Diagnostics.ProcessModule module in self.Modules)
|
||||
{
|
||||
if (!string.Equals(module.ModuleName, "GameAssembly.dll", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
baseAddress = module.BaseAddress;
|
||||
moduleSize = module.ModuleMemorySize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError("[AddrMap] could not locate GameAssembly.dll: " + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (baseAddress == IntPtr.Zero || moduleSize <= 0)
|
||||
{
|
||||
log.LogError("[AddrMap] GameAssembly.dll is not loaded - nothing to map");
|
||||
return;
|
||||
}
|
||||
|
||||
var gameRoot = Path.GetDirectoryName(UnityEngine.Application.dataPath) ?? ".";
|
||||
var path = Path.Combine(gameRoot, "BepInEx", "radicade-addr-map.txt");
|
||||
|
||||
var baseValue = baseAddress.ToInt64();
|
||||
var mapped = 0;
|
||||
var images = 0;
|
||||
var unmappable = 0;
|
||||
var bestBelow = -1L;
|
||||
string bestBelowName = null;
|
||||
long bestAbove = -1;
|
||||
string bestAboveName = null;
|
||||
var failures = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using (var writer = new StreamWriter(path, false))
|
||||
{
|
||||
writer.WriteLine("# il2cpp RVA -> method map, written by RadicadeAugustClient (run 24)");
|
||||
writer.WriteLine("# GameAssembly.dll base 0x" + baseValue.ToString("x") + ", mapped size 0x" + moduleSize.ToString("x"));
|
||||
writer.WriteLine("# rva<TAB>image<TAB>Namespace.Class::Method - rva is relative to the base above");
|
||||
|
||||
unsafe
|
||||
{
|
||||
var domain = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get();
|
||||
if (domain == IntPtr.Zero)
|
||||
{
|
||||
log.LogError("[AddrMap] no il2cpp domain; nothing to map");
|
||||
return;
|
||||
}
|
||||
|
||||
uint assemblyCount = 0;
|
||||
var assemblies = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get_assemblies(domain, ref assemblyCount);
|
||||
for (uint a = 0; a < assemblyCount; a++)
|
||||
{
|
||||
var image = Il2CppInterop.Runtime.IL2CPP.il2cpp_assembly_get_image(assemblies[a]);
|
||||
if (image == IntPtr.Zero) continue;
|
||||
images++;
|
||||
|
||||
string imageName;
|
||||
try { imageName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_name(image)) ?? "?"; }
|
||||
catch { imageName = "?"; }
|
||||
|
||||
var classCount = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class_count(image);
|
||||
for (uint c = 0; c < classCount; c++)
|
||||
{
|
||||
var klass = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class(image, c);
|
||||
if (klass == IntPtr.Zero) continue;
|
||||
|
||||
string className;
|
||||
try
|
||||
{
|
||||
var namespaze = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_namespace(klass));
|
||||
var name = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_name(klass));
|
||||
className = string.IsNullOrEmpty(namespaze) ? name : namespaze + "." + name;
|
||||
}
|
||||
catch { className = "?"; failures++; }
|
||||
|
||||
var iterator = IntPtr.Zero;
|
||||
IntPtr methodInfo;
|
||||
while ((methodInfo = Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_methods(klass, ref iterator)) != IntPtr.Zero)
|
||||
{
|
||||
long entry;
|
||||
try { entry = Marshal.ReadIntPtr(methodInfo).ToInt64(); }
|
||||
catch { entry = 0; }
|
||||
|
||||
if (entry <= baseValue || entry >= baseValue + moduleSize)
|
||||
{
|
||||
|
||||
|
||||
unmappable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var rva = entry - baseValue;
|
||||
string methodName;
|
||||
try { methodName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_method_get_name(methodInfo)) ?? "?"; }
|
||||
catch { methodName = "?"; failures++; }
|
||||
|
||||
writer.WriteLine("0x" + rva.ToString("x") + "\t" + imageName + "\t" + className + "::" + methodName);
|
||||
mapped++;
|
||||
|
||||
var full = className + "::" + methodName;
|
||||
if (rva <= _target && rva > bestBelow) { bestBelow = rva; bestBelowName = full; }
|
||||
if (rva > _target && (bestAbove < 0 || rva < bestAbove)) { bestAbove = rva; bestAboveName = full; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError("[AddrMap] pass failed after " + mapped + " methods: " + ex);
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsed = Environment.TickCount - started;
|
||||
log.LogInfo($"[AddrMap] mapped {mapped} methods from {images} images in {elapsed} ms -> {path} ({unmappable} without a mappable entry point)");
|
||||
|
||||
if (bestBelow < 0)
|
||||
{
|
||||
log.LogWarning("[AddrMap] no method starts below the target 0x" + _target.ToString("x")
|
||||
+ " - the crash is in code no managed method owns (il2cpp runtime or native plugin)."
|
||||
+ (bestAboveName != null ? " Nearest above: 0x" + bestAbove.ToString("x") + " " + bestAboveName : string.Empty));
|
||||
return;
|
||||
}
|
||||
|
||||
var span = bestAbove > bestBelow ? bestAbove - bestBelow : -1;
|
||||
log.LogWarning("[AddrMap] crash target 0x" + _target.ToString("x") + " -> containing method 0x" + bestBelow.ToString("x")
|
||||
+ " " + bestBelowName + " (+0x" + (_target - bestBelow).ToString("x") + " into it)"
|
||||
+ (span > 0 ? "; next method 0x" + bestAbove.ToString("x") + " " + bestAboveName + " (span 0x" + span.ToString("x") + ")" : string.Empty)
|
||||
+ (failures > 0 ? "; " + failures + " name reads failed" : string.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1430 @@
|
||||
using BepInEx;
|
||||
using BepInEx.Configuration;
|
||||
using BepInEx.Logging;
|
||||
using BepInEx.Unity.IL2CPP;
|
||||
using HarmonyLib;
|
||||
using RecRoom.AntiCheat;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
|
||||
|
||||
[BepInPlugin("com.radicade.eacbypass", "Radicade August Client", "2.0.0")]
|
||||
public sealed class RadicadeEacBypassPlugin : BasePlugin
|
||||
{
|
||||
internal static ManualLogSource LogSource;
|
||||
internal static ConfigEntry<string> ServerUrl, TlsAllowedHosts;
|
||||
internal static Il2CppSystem.Threading.SynchronizationContext MainThreadContext;
|
||||
internal static string ImageKeyId;
|
||||
|
||||
public override void Load()
|
||||
{
|
||||
LogSource = Log;
|
||||
if (UnityEngine.Application.version != "20230825")
|
||||
throw new NotSupportedException("This plugin requires Rec Room 20230825.");
|
||||
MainThreadContext = Il2CppSystem.Threading.SynchronizationContext.Current;
|
||||
Log.LogInfo($"[August] main-thread sync context: {(MainThreadContext != null ? MainThreadContext.GetType().Name : "NULL")}");
|
||||
ServerUrl = Config.Bind("Connection", "ServerUrl", "https://localhost:3030", "Private server URL.");
|
||||
if (!Uri.TryCreate(ServerUrl.Value, UriKind.Absolute, out var server) || server.Scheme != "https")
|
||||
throw new InvalidOperationException("The August client requires an HTTPS server URL.");
|
||||
TlsAllowedHosts = Config.Bind("TLS", "AllowedHosts", server.Host, "Exact private server hosts allowed by the embedded TLS integration.");
|
||||
if (!EmbeddedTlsPatches.IsHostAllowed(server.Host))
|
||||
throw new InvalidOperationException("TLS AllowedHosts must include the configured private server host.");
|
||||
|
||||
var metadata = Path.Combine(UnityEngine.Application.dataPath, "il2cpp_data", "Metadata", "global-metadata.interop.dat");
|
||||
ImageKeyId = ImageSignatureRequest.DetectKeyId(File.ReadAllBytes(metadata));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (ModuleHidingPolicy.Enabled)
|
||||
{
|
||||
var hidden = PebModuleHider.HideModule("winhttp.dll");
|
||||
Log.LogInfo(hidden
|
||||
? "[ModuleHide] winhttp.dll unlinked from PEB module lists (scanner-invisible)"
|
||||
: "[ModuleHide] WARNING: winhttp.dll not found in PEB lists - scanner will still see it");
|
||||
|
||||
|
||||
var wiped = PeHeaderWiper.WipeSignatures("winhttp.dll");
|
||||
Log.LogInfo(wiped
|
||||
? "[ModuleHide] winhttp.dll PE signatures wiped (MZ+PE) - region scans cannot classify it"
|
||||
: "[ModuleHide] WARNING: PE signature wipe did not apply (signatures missing or VirtualProtect failed)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.LogInfo("[ModuleHide] disabled by default (RADICADE_MODULE_HIDE=1 re-arms runs 14/15): winhttp.dll keeps its loader-list entry and its PE headers, so a module scan has nothing to call obscured.");
|
||||
}
|
||||
|
||||
Log.LogInfo(ModuleHidingPolicy.DescribeModuleList());
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
HileGate.Enabled = true;
|
||||
Log.LogInfo("[HileGate] armed - anti-cheat report POSTs are " + (HileGate.DropEnabled
|
||||
? "dropped at the HTTP layer (RADICADE_HILE_GATE=drop)"
|
||||
: "captured in full and then allowed through, so the report body is on the record (RADICADE_HILE_GATE=drop restores the drop)") + ".");
|
||||
|
||||
var harmony = new Harmony("com.radicade.eacbypass");
|
||||
|
||||
EmbeddedTlsPatches.Apply(harmony);
|
||||
harmony.PatchAll(typeof(NameserverConnectionPatches));
|
||||
WebSocketRedirectPatches.Apply(harmony);
|
||||
harmony.PatchAll(typeof(AugustChallengePatches));
|
||||
harmony.PatchAll(typeof(AugustLaunchDiagnostics));
|
||||
harmony.PatchAll(typeof(AugustLaunchValidatorPatch));
|
||||
RsaVerifyNeutralizerPatches.Apply(harmony);
|
||||
harmony.PatchAll(typeof(FileCheckEntryPatch));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
harmony.PatchAll(typeof(ExitInitiatorTracerPatch));
|
||||
Log.LogInfo("[ExitInit] armed - managed exit initiators are blocked once a hile report has been dropped, and traced.");
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
harmony.PatchAll(typeof(ExitStateVetoPatch));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Log.LogInfo("[ExitVeto] patches applied; blockExitState enabled=" + ExitStateVetoPatch.Enabled
|
||||
+ " (opt-in fallback only; a normal quit and server logouts work either way. RADICADE_EXIT_VETO=1 arms it)");
|
||||
}
|
||||
catch (Exception vetoEx)
|
||||
{
|
||||
|
||||
|
||||
Log.LogError("[ExitVeto] failed to apply - continuing without it: " + vetoEx);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
harmony.PatchAll(typeof(ExitSequenceTracePatch));
|
||||
harmony.PatchAll(typeof(UnityQuitVetoPatch));
|
||||
|
||||
|
||||
harmony.PatchAll(typeof(EacNativeClientStubPatch));
|
||||
harmony.PatchAll(typeof(EacHealthQueryLiePatch));
|
||||
harmony.PatchAll(typeof(CheatManagerTracePatch));
|
||||
harmony.PatchAll(typeof(CheatScanSuppressPatch));
|
||||
Log.LogInfo("[CheatScan] verdict suppression enabled=" + CheatScanSuppressPatch.Enabled);
|
||||
Log.LogInfo("[CheatType] cheat-detection suppression enabled=" + CheatDetectionSuppressPatch.Enabled
|
||||
+ " (RADICADE_CHEAT_TYPE=0 restores the detection and the dorm drop that follows it)");
|
||||
Log.LogInfo("[CheatDrop] dorm-drop guard enabled=" + CheatDormDropGuardPatch.Enabled
|
||||
+ " (RADICADE_CHEAT_DROP=0 lets every drop run)");
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
harmony.PatchAll(typeof(CheatDetectionSuppressPatch));
|
||||
Log.LogInfo("[CheatType] verdict handlers guarded=" + CheatDetectionSuppressPatch.Guarded
|
||||
+ (CheatDetectionSuppressPatch.Unguarded == null
|
||||
? " (all three)"
|
||||
: " - UNGUARDED, not found on CheatManager: " + CheatDetectionSuppressPatch.Unguarded));
|
||||
}
|
||||
catch (Exception cheatTypeEx)
|
||||
{
|
||||
Log.LogError("[CheatType] verdict suppression NOT armed: " + cheatTypeEx.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
harmony.PatchAll(typeof(CheatDormDropGuardPatch));
|
||||
Log.LogInfo("[CheatDrop] dorm drop (EFLCHFDDPJP) held back for the anti-cheat reason; "
|
||||
+ "every other reason still drops the player.");
|
||||
}
|
||||
catch (Exception cheatDropEx)
|
||||
{
|
||||
Log.LogError("[CheatDrop] dorm-drop guard NOT armed: " + cheatDropEx.Message);
|
||||
}
|
||||
|
||||
Log.LogInfo("[EacStub] EasyAntiCheat Hydra runtime neutralisation enabled=" + EacGate.Enabled
|
||||
+ " (RADICADE_EAC_NEUTRALIZE=0 before launch leaves EAC alone)");
|
||||
Log.LogInfo("[ExitTrace] shutdown-request stack dump armed; [QuitVeto] wantsToQuit veto enabled="
|
||||
+ UnityQuitVetoPatch.Enabled);
|
||||
}
|
||||
catch (Exception quitEx)
|
||||
{
|
||||
Log.LogError("[ExitTrace] failed to apply - continuing without them: " + quitEx);
|
||||
}
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
CrashAddressMap.Start();
|
||||
Log.LogInfo("[AddrMap] enabled=" + CrashAddressMap.Enabled
|
||||
+ " target=0x" + CrashAddressMap.TargetRva.ToString("x")
|
||||
+ " (RADICADE_ADDR_MAP=0 disables; RADICADE_ADDR_TARGET=0x... re-aims it)");
|
||||
}
|
||||
catch (Exception mapEx)
|
||||
{
|
||||
|
||||
Log.LogError("[AddrMap] failed to start: " + mapEx);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
harmony.PatchAll(typeof(ToolCollisionResetGuardPatch));
|
||||
Log.LogInfo("[ToolGuard] Tool.ResetToDefaultCollisionLayer skipped=" + ToolCollisionResetGuardPatch.Enabled
|
||||
+ " (RADICADE_TOOL_RESET=0 before launch restores the original reset)");
|
||||
}
|
||||
catch (Exception toolEx)
|
||||
{
|
||||
Log.LogError("[ToolGuard] failed to apply - the room crash is NOT guarded: " + toolEx);
|
||||
}
|
||||
Log.LogInfo($"[August] Connection patches loaded for {ServerUrl.Value}; image key {ImageKeyId ?? "unknown"}.");
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class AugustLaunchDiagnostics
|
||||
{
|
||||
[HarmonyPrefix, HarmonyPatch(typeof(NDIIPBKGGIG), "FIBOEONALKB")]
|
||||
[HarmonyPatch(typeof(NDIIPBKGGIG), "IKHPLCCBENN")]
|
||||
private static void Trace(JKBPKDAGEHN __0)
|
||||
{
|
||||
if (__0 == JKBPKDAGEHN.LAUNCH_VALIDATION_FAILED)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[August launch diagnostic] " + Il2CppSystem.Environment.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class AugustChallengePatches
|
||||
{
|
||||
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), nameof(EACManager.GenerateChallengeResponse))]
|
||||
private static bool Challenge(ref string __result)
|
||||
{
|
||||
__result = "radicade-eac-bypass";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), "MGEAHKJIIMO")]
|
||||
private static bool Ready(ref bool __result)
|
||||
{
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch(typeof(ADJOODNBCKG.NFPDGJNDFOF), "GBAMNAKGEGD")]
|
||||
internal static class AugustLaunchValidatorPatch
|
||||
{
|
||||
static readonly System.Threading.CancellationTokenSource Pump = new();
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool Bypass(ADJOODNBCKG.NFPDGJNDFOF __instance)
|
||||
{
|
||||
var promise = __instance.promise;
|
||||
if (promise == null)
|
||||
return true;
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[August] Bypassing native launch validator (hash check cannot pass in a patched install).");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var context = RadicadeEacBypassPlugin.MainThreadContext;
|
||||
try
|
||||
{
|
||||
var before = Il2CppSystem.Threading.SynchronizationContext.Current;
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[August] validator thread context: {(before != null ? before.GetType().Name : "null")}; installing main-thread context");
|
||||
if (context != null)
|
||||
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(context);
|
||||
try
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[August] resolving: done={SafeState(promise, "EPCPPDDJCMO")} handlers={SafeHandlerCount(promise)}");
|
||||
promise.OLKGFACPIPC();
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[August] Launch-validation promise resolved; boot continues.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Resolve failed: {e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(before);
|
||||
}
|
||||
}
|
||||
catch (Exception outer)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Context install failed: {outer}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int SafeHandlerCount(PFDOLDLEIBA promise)
|
||||
{
|
||||
try { return promise.HFPMLNDOGLF?.Count ?? 0; }
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
static bool SafeState(PFDOLDLEIBA promise, string prop)
|
||||
{
|
||||
try
|
||||
{
|
||||
return prop switch
|
||||
{
|
||||
"EPCPPDDJCMO" => promise.EPCPPDDJCMO,
|
||||
"MPJBGFFEEOP" => promise.MPJBGFFEEOP,
|
||||
"NEIINCCHEPF" => promise.NEIINCCHEPF,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static class RsaVerifyNeutralizerPatches
|
||||
{
|
||||
static readonly byte[] P1KeyModulus = System.Convert.FromBase64String(
|
||||
"X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIPMaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJAijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ==");
|
||||
|
||||
static int p1Calls, totalCalls;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
|
||||
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
|
||||
new[]
|
||||
{
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(Il2CppSystem.Security.Cryptography.HashAlgorithmName),
|
||||
typeof(Il2CppSystem.Security.Cryptography.RSASignaturePadding)
|
||||
});
|
||||
|
||||
|
||||
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
|
||||
new[]
|
||||
{
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(int),
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
|
||||
});
|
||||
|
||||
|
||||
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyData",
|
||||
new[]
|
||||
{
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(Il2CppSystem.Object),
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
|
||||
});
|
||||
}
|
||||
|
||||
static void PatchVerify(Harmony harmony, Type target, string methodName, Type[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var m = AccessTools.Method(target, methodName, args);
|
||||
if (m == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[RsaVerify] {target.Name}.{methodName}({args.Length} args) not found - skipped");
|
||||
return;
|
||||
}
|
||||
harmony.Patch(m, prefix: new HarmonyMethod(typeof(RsaVerifyNeutralizerPatches).GetMethod(nameof(VerifyPrefix))));
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
$"[RsaVerify] patched {target.Name}.{methodName}({string.Join(", ", Array.ConvertAll(args, t => t.Name))})");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[RsaVerify] failed to patch {target.Name}.{methodName}: {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
} public static bool VerifyPrefix(
|
||||
Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider __instance, ref bool __result)
|
||||
{
|
||||
totalCalls++;
|
||||
var isP1 = false;
|
||||
try
|
||||
{
|
||||
var modulus = __instance.ExportParameters(false).Modulus;
|
||||
isP1 = modulus != null && modulus.Length == P1KeyModulus.Length && modulus.SequenceEqual(P1KeyModulus);
|
||||
if (isP1)
|
||||
p1Calls++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
__result = isP1;
|
||||
if (totalCalls <= 8)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[ImageSig] RSA verify #{totalCalls} ({(isP1 ? "p1 key" : "game key")}) forced {(isP1 ? "true" : "false")}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class FileCheckEntryPatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
yield return AccessTools.Method(typeof(KKHDHILFOGO), "GGIFLDPHCIP");
|
||||
yield return AccessTools.Method(typeof(KKHDHILFOGO), "GOCCLPJDJIC");
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool Bypass(ref MCFFIFIPNKE<KKHDHILFOGO.BJGODLEIDGE> __result)
|
||||
{
|
||||
var result = KKHDHILFOGO.BJGODLEIDGE.NOMPJGOIAEB("radicade: file check bypassed");
|
||||
__result = PFDOLDLEIBA.PLJINLOCHGM(result);
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo("[FileCheck] entry bypassed - resolved success promise returned");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static unsafe class PebModuleHider
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_BASIC_INFORMATION
|
||||
{
|
||||
public IntPtr Reserved1;
|
||||
public IntPtr PebBaseAddress;
|
||||
public IntPtr Reserved2_0;
|
||||
public IntPtr Reserved2_1;
|
||||
public IntPtr UniqueProcessId;
|
||||
public IntPtr Reserved3;
|
||||
}
|
||||
|
||||
[DllImport("ntdll.dll")]
|
||||
private static extern int NtQueryInformationProcess(
|
||||
IntPtr hProcess, int processInfoClass, ref PROCESS_BASIC_INFORMATION pbi,
|
||||
int cb, out int returnLength);
|
||||
|
||||
private struct LIST_ENTRY { public IntPtr Flink, Blink; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private const int OffInMemoryOrder = 0x10;
|
||||
private const int OffInInitOrder = 0x20;
|
||||
private const int OffBaseDllName = 0x58;
|
||||
|
||||
public static bool HideModule(string dllName)
|
||||
{
|
||||
var pbi = default(PROCESS_BASIC_INFORMATION);
|
||||
int status = NtQueryInformationProcess(new IntPtr(-1), 0, ref pbi,
|
||||
Marshal.SizeOf<PROCESS_BASIC_INFORMATION>(), out _);
|
||||
if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
byte* peb = (byte*)pbi.PebBaseAddress;
|
||||
byte* ldr = *(byte**)(peb + 0x18);
|
||||
if (ldr == null)
|
||||
return false;
|
||||
|
||||
LIST_ENTRY* head = (LIST_ENTRY*)(ldr + 0x10);
|
||||
byte* target = null;
|
||||
for (LIST_ENTRY* n = (LIST_ENTRY*)head->Flink; n != head && target == null; n = (LIST_ENTRY*)n->Flink)
|
||||
{
|
||||
byte* entry = (byte*)n;
|
||||
ushort* us = (ushort*)(entry + OffBaseDllName);
|
||||
ushort lenBytes = *us;
|
||||
char* buf = *(char**)(entry + OffBaseDllName + 8);
|
||||
if (buf == null || lenBytes == 0)
|
||||
continue;
|
||||
string name = new string(buf, 0, lenBytes / 2);
|
||||
if (name.Equals(dllName, StringComparison.OrdinalIgnoreCase))
|
||||
target = entry;
|
||||
}
|
||||
|
||||
if (target == null)
|
||||
return false;
|
||||
|
||||
Unlink((LIST_ENTRY*)target);
|
||||
Unlink((LIST_ENTRY*)(target + OffInMemoryOrder));
|
||||
Unlink((LIST_ENTRY*)(target + OffInInitOrder));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Unlink(LIST_ENTRY* e)
|
||||
{
|
||||
var flink = (LIST_ENTRY*)e->Flink;
|
||||
var blink = (LIST_ENTRY*)e->Blink;
|
||||
if (flink == e || blink == e)
|
||||
return;
|
||||
blink->Flink = e->Flink;
|
||||
flink->Blink = e->Blink;
|
||||
e->Flink = (IntPtr)e;
|
||||
e->Blink = (IntPtr)e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static unsafe class PeHeaderWiper
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr GetModuleHandleW(string moduleName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
|
||||
|
||||
public static bool WipeSignatures(string dllName)
|
||||
{
|
||||
IntPtr baseAddr = GetModuleHandleW(dllName);
|
||||
if (baseAddr == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
byte* p = (byte*)baseAddr;
|
||||
int eLfanew = *(int*)(p + 0x3C);
|
||||
if (eLfanew <= 0 || eLfanew > 0x1000)
|
||||
return false;
|
||||
|
||||
if (!VirtualProtect(baseAddr, (UIntPtr)0x1000, 0x04 , out uint old))
|
||||
return false;
|
||||
|
||||
bool wasMz = p[0] == (byte)'M' && p[1] == (byte)'Z';
|
||||
bool wasPe = *(uint*)(p + eLfanew) == 0x00004550;
|
||||
|
||||
*(ushort*)p = 0;
|
||||
*(uint*)(p + eLfanew) = 0;
|
||||
|
||||
VirtualProtect(baseAddr, (UIntPtr)0x1000, old, out _);
|
||||
return wasMz && wasPe;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class ExitInitiatorTracerPatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
yield return AccessTools.Method(typeof(CCKNJCHHBLL), "BEFDPLAELIH");
|
||||
yield return AccessTools.Method(typeof(CCKNJCHHBLL), "GIEEBIOOFML");
|
||||
yield return AccessTools.Method(typeof(CCKNJCHHBLL), "FNOEHNDLHHG");
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool TraceAndBlock()
|
||||
{
|
||||
var stack = "<unavailable>";
|
||||
try { stack = Environment.StackTrace; } catch { }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var armed = HileGate.DroppedCount > 0;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(armed
|
||||
? "[ExitInit] blocked managed exit initiator - caller trace:\n" + stack
|
||||
: "[ExitInit] allowed managed exit initiator (no report filed yet) - caller trace:\n" + stack);
|
||||
return !armed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch(typeof(BestHTTP.HTTPManager), "SendRequest", new[] { typeof(BestHTTP.HTTPRequest) })]
|
||||
internal static class ExitSequenceTracePatch
|
||||
{
|
||||
private static int _dumped;
|
||||
|
||||
[HarmonyPriority(Priority.First)]
|
||||
private static void Prefix(BestHTTP.HTTPRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = request?.Uri?.AbsolutePath;
|
||||
if (path == null) return;
|
||||
if (path.IndexOf("/matchmaking/player/logout", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
path.IndexOf("/playersettings/playersettings", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
return;
|
||||
if (++_dumped > 4) return;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[ExitTrace] shutdown request " + path + " - managed caller stack:" + ExitTraceHelper.SafeStack());
|
||||
}
|
||||
catch (Exception traceEx)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ExitTrace] dump failed: " + traceEx.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class UnityQuitVetoPatch
|
||||
{
|
||||
internal static bool Enabled => _enabled;
|
||||
|
||||
private static readonly bool _enabled = IsEnabled();
|
||||
|
||||
private static int _blocks;
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_QUIT_VETO");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var noArg = AccessTools.Method(typeof(CCKNJCHHBLL), "GBGMBMGDGGD", Type.EmptyTypes);
|
||||
if (noArg != null) yield return noArg;
|
||||
|
||||
var flag = AccessTools.Method(typeof(CCKNJCHHBLL), "GBGMBMGDGGD", new[] { typeof(bool) });
|
||||
if (flag != null) yield return flag;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Veto(ref bool __result)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
__result = false;
|
||||
|
||||
var count = ++_blocks;
|
||||
if (count <= 3 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[QuitVeto] wantsToQuit answered false (block #" + count + ") - Unity's quit is cancelled. " +
|
||||
"This is the anti-cheat shutdown, not a user quit; the veto is armed by RADICADE_QUIT_VETO=1, set it to 0 to disable." +
|
||||
ExitTraceHelper.SafeStack());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
internal static class ModuleHidingPolicy
|
||||
{
|
||||
internal static bool Enabled { get; } = ReadFlag("RADICADE_MODULE_HIDE");
|
||||
|
||||
private static bool ReadFlag(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
internal static string DescribeModuleList()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var self = System.Diagnostics.Process.GetCurrentProcess())
|
||||
{
|
||||
var seen = false;
|
||||
var path = string.Empty;
|
||||
|
||||
foreach (System.Diagnostics.ProcessModule module in self.Modules)
|
||||
{
|
||||
if (!string.Equals(module.ModuleName, "winhttp.dll", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
seen = true;
|
||||
path = module.FileName ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
|
||||
return seen
|
||||
? "[ModuleList] winhttp.dll is enumerated by a Toolhelp module walk: " + path
|
||||
: "[ModuleList] winhttp.dll is NOT enumerated by a Toolhelp module walk - a loaded image the loader lists do not know about is the most literal definition of obscured.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return "[ModuleList] enumeration failed: " + ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatScanSuppressPatch
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_SCAN");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Reflection.MethodBase TargetMethod()
|
||||
{
|
||||
var type = AccessTools.TypeByName("CheatManager");
|
||||
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
|
||||
|
||||
var method = AccessTools.Method(type, "MJLAGDJLOBC", System.Type.EmptyTypes);
|
||||
if (method == null) throw new System.MissingMethodException("CheatManager.MJLAGDJLOBC() is gone - the scan moved or was renamed.");
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip()
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
var count = ++_skips;
|
||||
if (count <= 3 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatScan] skipped CheatManager.MJLAGDJLOBC() (#" + count
|
||||
+ ") - this is the pass behind the Obscured/winhttp.dll verdict, so no report and no shutdown follow from it.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatDetectionSuppressPatch
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_TYPE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var type = AccessTools.TypeByName("CheatManager");
|
||||
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var found = new System.Collections.Generic.List<string>();
|
||||
var missing = new System.Collections.Generic.List<string>();
|
||||
foreach (var name in new[] { "OnObscuredTypeCheatDetected", "OnTimeCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
{
|
||||
var method = AccessTools.Method(type, name, System.Type.EmptyTypes);
|
||||
if (method == null)
|
||||
{
|
||||
missing.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
found.Add(name);
|
||||
yield return method;
|
||||
}
|
||||
|
||||
_guarded = found.Count;
|
||||
_unguarded = missing.Count == 0 ? null : string.Join(", ", missing);
|
||||
}
|
||||
|
||||
internal static int Guarded => _guarded;
|
||||
|
||||
internal static string Unguarded => _unguarded;
|
||||
|
||||
private static int _guarded;
|
||||
|
||||
private static string _unguarded;
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
var count = ++_skips;
|
||||
if (count <= 5 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatType] skipped CheatManager." + __originalMethod.Name + "() (#" + count
|
||||
+ ") - this is a verdict handler that ends in the dorm drop with EFLCHFDDPJP and the shutdown behind it, so the room survives the detection.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatDormDropGuardPatch
|
||||
{
|
||||
private const string AntiCheatReason = "385120";
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_DROP");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Reflection.MethodBase TargetMethod()
|
||||
{
|
||||
var type = AccessTools.TypeByName("CheatManager");
|
||||
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
|
||||
|
||||
var method = AccessTools.Method(type, "EFLCHFDDPJP", new[] { typeof(string) });
|
||||
if (method == null)
|
||||
throw new System.MissingMethodException("CheatManager.EFLCHFDDPJP(string) is gone - the dorm drop moved or was renamed, so the guard is NOT armed.");
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(string JELLDPBCBLG)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(JELLDPBCBLG) || JELLDPBCBLG.IndexOf(AntiCheatReason, StringComparison.Ordinal) < 0)
|
||||
return true;
|
||||
|
||||
var count = ++_skips;
|
||||
if (count <= 5 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatDrop] skipped CheatManager.EFLCHFDDPJP('" + JELLDPBCBLG + "') (#" + count
|
||||
+ ") - the anti-cheat's own drop, so the player stays where they are instead of being sent to the dormroom mid-session.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatManagerTracePatch
|
||||
{
|
||||
private const int PerMethodLimit = 4;
|
||||
private const int LineLimit = 90;
|
||||
|
||||
private static readonly System.Diagnostics.Stopwatch Clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
private static readonly System.Collections.Generic.Dictionary<string, int> Seen = new System.Collections.Generic.Dictionary<string, int>();
|
||||
private static int _lines;
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var outer = AccessTools.TypeByName("CheatManager");
|
||||
if (outer == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] CheatManager not resolvable - nothing traced.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var types = new System.Collections.Generic.List<System.Type> { outer };
|
||||
foreach (var nested in outer.GetNestedTypes(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
|
||||
types.Add(nested);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
yield return method;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static void Trace(System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_lines >= LineLimit) return;
|
||||
|
||||
var key = __originalMethod.DeclaringType.Name + "." + __originalMethod.Name;
|
||||
|
||||
Seen.TryGetValue(key, out var count);
|
||||
if (count >= PerMethodLimit) return;
|
||||
Seen[key] = count + 1;
|
||||
_lines++;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] +" + Clock.ElapsedMilliseconds + "ms " + key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ExitTraceHelper
|
||||
{
|
||||
internal static string SafeStack()
|
||||
{
|
||||
try { return "\n" + Environment.StackTrace; } catch { return "\n<stack unavailable>"; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
internal static class EacGate
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _logCount;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_EAC_NEUTRALIZE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
internal static System.Type Find(string fullName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var byName = System.Type.GetType(fullName + ", EasyAntiCheat.Client");
|
||||
if (byName != null) return byName;
|
||||
}
|
||||
catch { }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var forced = typeof(EasyAntiCheat.Client.Hydra.Runtime).Assembly;
|
||||
var direct = forced.GetType(fullName);
|
||||
if (direct != null) return direct;
|
||||
}
|
||||
catch { }
|
||||
|
||||
try { return AccessTools.TypeByName(fullName); } catch { return null; }
|
||||
}
|
||||
|
||||
private const int LogLimit = 8;
|
||||
|
||||
internal static void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (++_logCount > LogLimit) return;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class EacNativeClientStubPatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var type = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
|
||||
if (type == null)
|
||||
{
|
||||
EacGate.Log("[EacStub] Hydra.NativeClient not resolvable - EAC runtime left untouched.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
if (method.Name == "Initialize")
|
||||
yield return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!EacGate.Enabled) return true;
|
||||
|
||||
EacGate.Log("[EacStub] skipped " + __originalMethod.DeclaringType + "." + __originalMethod.Name
|
||||
+ " - EasyAntiCheat's native client never starts, so its module scanner never runs.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class EacHealthQueryLiePatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var native = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
|
||||
if (native != null)
|
||||
foreach (var method in native.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
if (method.Name == "IsStartedWithEasyAntiCheat" && method.ReturnType == typeof(bool))
|
||||
yield return method;
|
||||
|
||||
var runtime = EacGate.Find("EasyAntiCheat.Client.Hydra.Runtime");
|
||||
if (runtime != null)
|
||||
foreach (var method in runtime.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
if ((method.Name == "IsActive" || method.Name == "get_Initialized") && method.ReturnType == typeof(bool))
|
||||
yield return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Lie(ref bool __result, System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!EacGate.Enabled) return true;
|
||||
|
||||
__result = true;
|
||||
EacGate.Log("[EacStub] " + __originalMethod.DeclaringType + "." + __originalMethod.Name + " answered true.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class ToolCollisionResetGuardPatch
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_TOOL_RESET");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Reflection.MethodBase TargetMethod()
|
||||
{
|
||||
var method = AccessTools.Method(typeof(Tool), "ResetToDefaultCollisionLayer", Type.EmptyTypes);
|
||||
if (method == null)
|
||||
throw new MissingMethodException(
|
||||
"Tool.ResetToDefaultCollisionLayer() not found - the room crash guard is NOT armed.");
|
||||
return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(Tool __instance)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
var count = ++_skips;
|
||||
|
||||
|
||||
if (count <= 5 || count % 50 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[ToolGuard] skipped Tool.ResetToDefaultCollisionLayer on " + Describe(__instance)
|
||||
+ " (#" + count + ") - this method is the frame directly above the access violation "
|
||||
+ "at GameAssembly.dll+0x470728d, which walks the tool's ECS collision-layer components. "
|
||||
+ "Tool resets still apply; only the collision layer is left as it was.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Describe(Tool tool)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tool == null) return "<null tool>";
|
||||
var owner = tool.gameObject;
|
||||
return (owner == null) ? "<no gameObject>" : "'" + owner.name + "'";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return "<describe failed: " + ex.GetType().Name + ">";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ExitStateVetoPatch
|
||||
{
|
||||
internal static bool Enabled => _enabled;
|
||||
|
||||
private static readonly bool _enabled = IsEnabled();
|
||||
|
||||
private static int _blocks;
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_EXIT_VETO");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
raw = raw.Trim().ToLowerInvariant();
|
||||
return raw is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
|
||||
var property = AccessTools.PropertySetter(typeof(CCKNJCHHBLL), "GLNIKJNLCIB");
|
||||
if (property != null) yield return property;
|
||||
|
||||
|
||||
|
||||
var raw = AccessTools.Method(typeof(CCKNJCHHBLL), "KALEFEDAHCC",
|
||||
new[] { typeof(CCKNJCHHBLL.CCFIKLBABNB) });
|
||||
if (raw != null) yield return raw;
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Block(CCKNJCHHBLL.CCFIKLBABNB __0)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
if (__0 == CCKNJCHHBLL.CCFIKLBABNB.Running) return true;
|
||||
|
||||
var count = ++_blocks;
|
||||
|
||||
var caller = count == 1 ? ExitTraceHelper.SafeStack() : string.Empty;
|
||||
if (count <= 3 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[ExitVeto] blocked AppExitState -> {__0} (block #{count}); the session keeps running. " +
|
||||
"This is the anti-cheat-driven exit, not a user quit - the veto is armed by RADICADE_EXIT_VETO=1, set it to 0 to disable." + caller);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Radicade August Client
|
||||
|
||||
Startup integration for Rec Room **20230825**, compiled against that build's
|
||||
generated BepInEx interop assemblies. Requires the metadata-reader repair in
|
||||
`tools/AugustInterop` first. It does not use the incompatible March UI patches.
|
||||
|
||||
Build from the repository root:
|
||||
|
||||
```powershell
|
||||
dotnet build 2023Patch/RadicadeAugustClient/RadicadeAugustClient.csproj -c Release
|
||||
```
|
||||
|
||||
Override `-p:BepInExRoot=<game>/BepInEx` for a different installation. No automatic
|
||||
deployment occurs. Install `bin/Release/net6.0/RadicadeAugustClient.dll` into
|
||||
`BepInEx/plugins`, with the old RadicadeEacBypass.dll disabled/backed up. The two
|
||||
plugins share a config identity and must not be installed together.
|
||||
|
||||
Local config (`BepInEx/config/com.radicade.eacbypass.cfg`):
|
||||
|
||||
```ini
|
||||
[Connection]
|
||||
ServerUrl = https://localhost:3030
|
||||
[TLS]
|
||||
AllowedHosts = localhost
|
||||
```
|
||||
|
||||
TLS exceptions are limited to the configured host allowlist. The plugin checks
|
||||
the game version and requires HTTPS. It reads the normalized metadata copy only
|
||||
to identify the image signature key. Native game files are not modified.
|
||||
|
||||
Verified: build succeeds, plugin loads, TLS succeeds, and server configuration
|
||||
downloads. **Not yet playable:** the next client error is launch validation;
|
||||
login, Photon multiplayer, watch UI and room loading are not verified or fully
|
||||
ported. See `docs/2023-AUGUST-BUILD.md` for the current status and backups.
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<BepInExRoot Condition="'$(BepInExRoot)' == ''">C:\RecRoom\2023-08-29T06-53-33Z\BepInEx</BepInExRoot>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="$(BepInExRoot)\core\BepInEx.Core.dll;$(BepInExRoot)\core\BepInEx.Unity.IL2CPP.dll;$(BepInExRoot)\core\0Harmony.dll;$(BepInExRoot)\core\Il2CppInterop.Runtime.dll" Private="false" />
|
||||
<Reference Include="$(BepInExRoot)\interop\*.dll" Private="false" />
|
||||
<Compile Include="..\RadicadeEacBypass\EmbeddedTlsPatches.cs" Link="Shared\EmbeddedTlsPatches.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\NameserverConnectionPatches.cs" Link="Shared\NameserverConnectionPatches.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\NameserverRedirect.cs" Link="Shared\NameserverRedirect.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\ImageSignatureRequest.cs" Link="Shared\ImageSignatureRequest.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\HubSocketAuth.cs" Link="Shared\HubSocketAuth.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\WebSocketRedirectPatches.cs" Link="Shared\WebSocketRedirectPatches.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\Diag.cs" Link="Shared\Diag.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\ResponseRepair.cs" Link="Shared\ResponseRepair.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\HttpTolerancePatches.cs" Link="Shared\HttpTolerancePatches.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<string, ...>), 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using TMPro;
|
||||
using UnityEngine.UI;
|
||||
|
||||
internal static class ClientTextRewrites
|
||||
{
|
||||
private static readonly (string From, string To)[] Rules =
|
||||
{
|
||||
("https://recroom.zendesk.com/home", "https://support.radicade.app/"),
|
||||
("recroom.zendesk.com", "support.radicade.app"),
|
||||
("https://www.recroom.com/privacy-policy/", "https://radicade.app/privacy-policy/"),
|
||||
("https://recroom.com/terms-of-service", "https://radicade.app/terms-of-service/"),
|
||||
("https://www.recroom.com/community/", "https://radicade.app/community/"),
|
||||
};
|
||||
|
||||
private static readonly (string From, string To)[] ExactRules =
|
||||
{
|
||||
("This account has been locked for security reasons. Contact us at recroom.zendesk.com.",
|
||||
LockedAccountSentence),
|
||||
("This account has been locked for security reasons. Contact us at support.radicade.app.",
|
||||
LockedAccountSentence),
|
||||
};
|
||||
|
||||
private const string GenericLoginFailure = "Login failed. Please try again.";
|
||||
private const string LockedAccountErrorCode = "ACCOUNT_PERMALOCKED";
|
||||
|
||||
internal const string LockedAccountSentence =
|
||||
"This account has been locked for security reasons. Contact us at https://support.radicade.app.";
|
||||
|
||||
private static bool _rewriting;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
var mode = AsciiLower((Environment.GetEnvironmentVariable("RADICADE_CLIENT_TEXT") ?? string.Empty).Trim());
|
||||
if (mode == "0" || mode == "off")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool onEnable = mode.Length == 0 || mode == "1" || mode == "all" || mode == "onenable";
|
||||
bool onSet = mode.Length == 0 || mode == "1" || mode == "all" || mode == "settext";
|
||||
|
||||
var seen = new HashSet<MethodBase>();
|
||||
var names = new List<string>();
|
||||
|
||||
if (onEnable)
|
||||
{
|
||||
Hook(harmony, seen, names, AccessTools.Method(typeof(TextMeshProUGUI), "OnEnable"), nameof(GraphicEvent), "TextMeshProUGUI.OnEnable");
|
||||
Hook(harmony, seen, names, AccessTools.Method(typeof(Text), "OnEnable"), nameof(GraphicEvent), "UI.Text.OnEnable");
|
||||
}
|
||||
if (onSet)
|
||||
{
|
||||
Hook(harmony, seen, names, AccessTools.Method(typeof(TMP_Text), "set_text"), nameof(GraphicEvent), "TMP_Text.set_text");
|
||||
Hook(harmony, seen, names, AccessTools.Method(typeof(TMP_Text), "SetTextInternal"), nameof(GraphicEvent), "TMP_Text.SetTextInternal");
|
||||
Hook(harmony, seen, names, AccessTools.Method(typeof(Text), "set_text"), nameof(GraphicEvent), "UI.Text.set_text");
|
||||
}
|
||||
|
||||
if (names.Count == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ClientText] no text hooks could be installed (mode=" + (mode.Length == 0 ? "all" : mode) + "); the watch UI keeps its original text.");
|
||||
}
|
||||
|
||||
private static void Hook(Harmony harmony, HashSet<MethodBase> seen, List<string> names,
|
||||
MethodBase target, string handler, string label)
|
||||
{
|
||||
if (target == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ClientText] hook target missing: " + label);
|
||||
return;
|
||||
}
|
||||
if (!seen.Add(target)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var method = new HarmonyMethod(typeof(ClientTextRewrites)
|
||||
.GetMethod(handler, BindingFlags.Static | BindingFlags.NonPublic));
|
||||
harmony.Patch(target, postfix: method);
|
||||
names.Add(label);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ClientText] could not hook " + label + ": "
|
||||
+ ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void GraphicEvent(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__instance is not Il2CppObjectBase obj) return;
|
||||
if (obj.TryCast<TMP_Text>() is { } tmp) { Fix(tmp); return; }
|
||||
if (obj.TryCast<Text>() is { } txt) { Fix(txt); }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void Fix(TMP_Text instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (instance == null || _rewriting) return;
|
||||
var current = instance.text;
|
||||
var rewritten = Rewrite(current);
|
||||
if (ReferenceEquals(current, rewritten) || string.Equals(rewritten, current, StringComparison.Ordinal)) return;
|
||||
_rewriting = true;
|
||||
try { instance.text = rewritten; } finally { _rewriting = false; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void Fix(Text instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (instance == null || _rewriting) return;
|
||||
var current = instance.text;
|
||||
var rewritten = Rewrite(current);
|
||||
if (ReferenceEquals(current, rewritten) || string.Equals(rewritten, current, StringComparison.Ordinal)) return;
|
||||
_rewriting = true;
|
||||
try { instance.text = rewritten; } finally { _rewriting = false; }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string Rewrite(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
|
||||
if (HubSocketAuth.LastLoginRefusalCode == LockedAccountErrorCode &&
|
||||
IndexOfAsciiIgnoreCase(value, GenericLoginFailure, 0) >= 0)
|
||||
{
|
||||
var locked = Replace(value, GenericLoginFailure, LockedAccountSentence);
|
||||
return locked;
|
||||
}
|
||||
|
||||
if (IndexOfAsciiIgnoreCase(value, "recroom", 0) < 0 &&
|
||||
IndexOfAsciiIgnoreCase(value, "radicade", 0) < 0) return value;
|
||||
|
||||
var result = value;
|
||||
foreach (var (from, to) in ExactRules)
|
||||
if (IndexOfAsciiIgnoreCase(result, from, 0) >= 0)
|
||||
result = Replace(result, from, to);
|
||||
|
||||
foreach (var (from, to) in Rules)
|
||||
if (IndexOfAsciiIgnoreCase(result, from, 0) >= 0)
|
||||
result = Replace(result, from, to);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Replace(string source, string from, string to)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(source.Length);
|
||||
var i = 0;
|
||||
while (i < source.Length)
|
||||
{
|
||||
var hit = IndexOfAsciiIgnoreCase(source, from, i);
|
||||
if (hit < 0) { sb.Append(source, i, source.Length - i); break; }
|
||||
sb.Append(source, i, hit - i).Append(to);
|
||||
i = hit + from.Length;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static int IndexOfAsciiIgnoreCase(string haystack, string needle, int start)
|
||||
{
|
||||
if (haystack == null || needle == null) return -1;
|
||||
var n = needle.Length;
|
||||
var h = haystack.Length;
|
||||
if (n == 0) return start <= h ? start : -1;
|
||||
if (n > h) return -1;
|
||||
var last = h - n;
|
||||
for (var i = (start < 0 ? 0 : start); i <= last; i++)
|
||||
{
|
||||
var j = 0;
|
||||
while (j < n && LowerAscii(haystack[i + j]) == LowerAscii(needle[j])) j++;
|
||||
if (j == n) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static char LowerAscii(char c) => c >= 'A' && c <= 'Z' ? (char)(c + 32) : c;
|
||||
|
||||
private static string AsciiLower(string s)
|
||||
{
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
if (s[i] >= 'A' && s[i] <= 'Z')
|
||||
return string.Create(s.Length, s, (span, state) =>
|
||||
{
|
||||
for (var k = 0; k < state.Length; k++) span[k] = LowerAscii(state[k]);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
internal static class ColliderCastGuardPatch
|
||||
{
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
int installed = 0;
|
||||
foreach (var method in TargetMethods())
|
||||
{
|
||||
try
|
||||
{
|
||||
Install(harmony, method);
|
||||
installed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogError("[ColliderGuard] failed on " + Describe(method) + ": " + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Install(Harmony harmony, MethodBase method)
|
||||
{
|
||||
var prefix = IsColliderFrame(method)
|
||||
? new HarmonyMethod(typeof(ColliderCastGuardPatch), nameof(NoteCollider)) : null;
|
||||
harmony.Patch(method, prefix: prefix,
|
||||
finalizer: new HarmonyMethod(typeof(ColliderCastGuardPatch), nameof(Swallow)));
|
||||
}
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _swallows;
|
||||
|
||||
private static readonly Dictionary<MethodBase, string> LastCollider = new Dictionary<MethodBase, string>();
|
||||
|
||||
|
||||
private static readonly HashSet<MethodBase> ColliderFrames = new HashSet<MethodBase>();
|
||||
|
||||
private static readonly string[] ColliderParameterTypes = new string[2] { "AOCLEIAJHKP", "AKCGGCOFMHJ" };
|
||||
|
||||
private static long _inspections;
|
||||
|
||||
private static readonly string[] CastTypes = new string[3] { "BPJMFIFDPJO", "CBKENBPBBPK", "AOCLEIAJHKP" };
|
||||
|
||||
private const string RbexAssembly = "RecRoom.Rbex.Runtime";
|
||||
|
||||
private const string GameAssembly = "Assembly-CSharp";
|
||||
|
||||
private static readonly (string Assembly, string Type, string Method, int Args, string Role)[] Specs = new(string, string, string, int, string)[5]
|
||||
{
|
||||
("RecRoom.Rbex.Runtime", "NFMLBBHIOHJ", "JAMGDJKMPGM", 2, "locomotion: attach/detach a collider child"),
|
||||
("RecRoom.Rbex.Runtime", "NPDJILHGCBI", "JAMGDJKMPGM", 2, "locomotion: collider-child update (twin implementation)"),
|
||||
("RecRoom.Rbex.Runtime", "MOBAMIJAHPJ", "JDAFCHGGCBG", 1, "teardown: unregister a collider from its body"),
|
||||
("RecRoom.Rbex.Runtime", "CBKENBPBBPK", "JDIBOGNGEFK", 0, "teardown: body Dispose -> collider walk"),
|
||||
("Assembly-CSharp", "RecRoom.Core.Locomotion.PlayerMovement", "AuthorityUpdateMovement", 2, "locomotion: authority tick (backstop for every caller of the cast)")
|
||||
};
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static void NoteCollider(object __0, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__0 == null || __originalMethod == null || !ColliderFrames.Contains(__originalMethod))
|
||||
{
|
||||
return;
|
||||
}
|
||||
long num = ++_inspections;
|
||||
if (num > 200 && num % 50 != 0L)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string text = DescribeCollider(__0);
|
||||
if (text != null)
|
||||
{
|
||||
if (__originalMethod != null)
|
||||
{
|
||||
LastCollider[__originalMethod] = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeCollider(object collider)
|
||||
{
|
||||
string text = null;
|
||||
try
|
||||
{
|
||||
CBKENBPBBPK val = (CBKENBPBBPK)((collider is CBKENBPBBPK) ? collider : null);
|
||||
if (val != null)
|
||||
{
|
||||
text = NameOf(val.JPBNIBGFKJO);
|
||||
}
|
||||
else
|
||||
{
|
||||
BPJMFIFDPJO val2 = (BPJMFIFDPJO)((collider is BPJMFIFDPJO) ? collider : null);
|
||||
if (val2 != null)
|
||||
{
|
||||
text = NameOf(val2.JPBNIBGFKJO);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
string name;
|
||||
try
|
||||
{
|
||||
name = collider.GetType().Name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (text != null)
|
||||
{
|
||||
return name + " on '" + text + "'";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string NameOf(GameObject gameObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
return gameObject == null ? null : gameObject.name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
string environmentVariable = Environment.GetEnvironmentVariable("RADICADE_COLLIDER_GUARD");
|
||||
if (string.IsNullOrWhiteSpace(environmentVariable))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool result;
|
||||
switch (environmentVariable.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "0":
|
||||
case "off":
|
||||
case "false":
|
||||
case "no":
|
||||
result = true;
|
||||
break;
|
||||
default:
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<MethodBase> TargetMethods()
|
||||
{
|
||||
List<MethodBase> list = new List<MethodBase>();
|
||||
(string, string, string, int, string)[] specs = Specs;
|
||||
for (int i = 0; i < specs.Length; i++)
|
||||
{
|
||||
(string, string, string, int, string) tuple = specs[i];
|
||||
foreach (MethodBase item in Resolve(tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4))
|
||||
{
|
||||
list.Add(item);
|
||||
if (IsColliderFrame(item))
|
||||
{
|
||||
ColliderFrames.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (list.Count == 0)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[ColliderGuard] none of the " + Specs.Length + " known collider frames resolved - the physics cast failure is NOT guarded."));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool IsColliderFrame(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
if (parameters.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string name = parameters[0].ParameterType.Name;
|
||||
string[] colliderParameterTypes = ColliderParameterTypes;
|
||||
foreach (string b in colliderParameterTypes)
|
||||
{
|
||||
if (string.Equals(name, b, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Type FindType(string assemblyName, string typeName)
|
||||
{
|
||||
try
|
||||
{
|
||||
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
|
||||
foreach (Assembly assembly in assemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(assembly.GetName().Name, assemblyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Type type = assembly.GetType(typeName, throwOnError: false);
|
||||
if (type != null)
|
||||
{
|
||||
return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
try
|
||||
{
|
||||
return Type.GetType(typeName + ", " + assemblyName, throwOnError: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
try
|
||||
{
|
||||
return Assembly.Load(new AssemblyName(assemblyName)).GetType(typeName, throwOnError: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[ColliderGuard] " + assemblyName + " could not be loaded: " + ex.Message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<MethodBase> Resolve(string assemblyName, string typeName, string methodName, int args)
|
||||
{
|
||||
var type = FindType(assemblyName, typeName);
|
||||
if (type == null) yield break;
|
||||
foreach (var method in type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
|
||||
if (method.Name == methodName && method.GetParameters().Length == args)
|
||||
yield return method;
|
||||
}
|
||||
[HarmonyFinalizer]
|
||||
private static Exception Swallow(Exception __exception, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception == null || !Enabled)
|
||||
{
|
||||
return __exception;
|
||||
}
|
||||
if (!IsPhysicsCastFailure(__exception))
|
||||
{
|
||||
return __exception;
|
||||
}
|
||||
int num = ++_swallows;
|
||||
if (num <= 5 || num % 100 == 0)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[ColliderGuard] caught a collider cast failure in " + Describe(__originalMethod) + " (#" + num + ")" + DescribeArgument(__originalMethod) + ": " + __exception.GetType().Name + ": " + __exception.Message + " - the failed operation was aborted; remaining work in that method did not run."));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeArgument(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (method != null && LastCollider.TryGetValue(method, out var value))
|
||||
{
|
||||
return " collider=" + value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string Describe(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (method == null) ? "<unknown frame>" : (method.DeclaringType?.ToString() + "." + method.Name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<unknown frame>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPhysicsCastFailure(Exception exception)
|
||||
{
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = exception.Message ?? string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (text.IndexOf("Unable to cast object of type", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string[] castTypes = CastTypes;
|
||||
foreach (string value in castTypes)
|
||||
{
|
||||
if (text.IndexOf(value, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class ConnectionGate
|
||||
{
|
||||
private const int WaitMilliseconds = 15000;
|
||||
|
||||
private static readonly ManualResetEventSlim Opened = new ManualResetEventSlim(false);
|
||||
|
||||
private static int _holds;
|
||||
|
||||
internal static void Install(Harmony harmony)
|
||||
{
|
||||
harmony.PatchAll(typeof(ConnectionGatePatch));
|
||||
}
|
||||
|
||||
internal static void Open()
|
||||
{
|
||||
Opened.Set();
|
||||
}
|
||||
|
||||
internal static void Hold()
|
||||
{
|
||||
if (Opened.IsSet) return;
|
||||
|
||||
var count = Interlocked.Increment(ref _holds);
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Patch] a server request arrived before the patch list finished; holding it until it does (hold #"
|
||||
+ count + ").");
|
||||
|
||||
if (Opened.Wait(WaitMilliseconds)) return;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogError("[Patch] the patch list did not finish within "
|
||||
+ (WaitMilliseconds / 1000) + "s; releasing this request and every later one so the client is not held forever.");
|
||||
Opened.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BestHTTP.HTTPManager), "SendRequest", new[] { typeof(BestHTTP.HTTPRequest) })]
|
||||
internal static class ConnectionGatePatch
|
||||
{
|
||||
[HarmonyPriority(Priority.First)]
|
||||
internal static void Prefix()
|
||||
{
|
||||
ConnectionGate.Hold();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
internal static class CrashAddressMap
|
||||
{
|
||||
private const long DefaultTarget = 0x29a79ec;
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
internal static long TargetRva => _target;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static readonly long _target = ReadTarget();
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_MAP");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static long ReadTarget()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_ADDR_TARGET");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return DefaultTarget;
|
||||
raw = raw.Trim();
|
||||
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) raw = raw.Substring(2);
|
||||
return long.TryParse(raw, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : DefaultTarget;
|
||||
}
|
||||
catch { return DefaultTarget; }
|
||||
}
|
||||
|
||||
internal static void Start()
|
||||
{
|
||||
if (!Enabled) return;
|
||||
var thread = new System.Threading.Thread(Run) { IsBackground = true, Name = "RadicadeAddrMap" };
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
try { System.Threading.Thread.Sleep(5000); } catch { }
|
||||
|
||||
var log = RadicadeEacBypassPlugin.LogSource;
|
||||
|
||||
IntPtr baseAddress = IntPtr.Zero;
|
||||
var moduleSize = 0L;
|
||||
try
|
||||
{
|
||||
using (var self = System.Diagnostics.Process.GetCurrentProcess())
|
||||
{
|
||||
foreach (System.Diagnostics.ProcessModule module in self.Modules)
|
||||
{
|
||||
if (!string.Equals(module.ModuleName, "GameAssembly.dll", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
baseAddress = module.BaseAddress;
|
||||
moduleSize = module.ModuleMemorySize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError("[AddrMap] could not locate GameAssembly.dll: " + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (baseAddress == IntPtr.Zero || moduleSize <= 0)
|
||||
{
|
||||
log.LogError("[AddrMap] GameAssembly.dll is not loaded - nothing to map");
|
||||
return;
|
||||
}
|
||||
|
||||
var gameRoot = Path.GetDirectoryName(UnityEngine.Application.dataPath) ?? ".";
|
||||
var path = Path.Combine(gameRoot, "BepInEx", "radicade-addr-map.txt");
|
||||
|
||||
var baseValue = baseAddress.ToInt64();
|
||||
var mapped = 0;
|
||||
var images = 0;
|
||||
var unmappable = 0;
|
||||
var bestBelow = -1L;
|
||||
string bestBelowName = null;
|
||||
long bestAbove = -1;
|
||||
string bestAboveName = null;
|
||||
var failures = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using (var writer = new StreamWriter(path, false))
|
||||
{
|
||||
writer.WriteLine("# il2cpp RVA -> method map, written by RadicadeAugustClient (run 24)");
|
||||
writer.WriteLine("# GameAssembly.dll base 0x" + baseValue.ToString("x") + ", mapped size 0x" + moduleSize.ToString("x"));
|
||||
writer.WriteLine("# rva<TAB>image<TAB>Namespace.Class::Method - rva is relative to the base above");
|
||||
|
||||
unsafe
|
||||
{
|
||||
var domain = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get();
|
||||
if (domain == IntPtr.Zero)
|
||||
{
|
||||
log.LogError("[AddrMap] no il2cpp domain; nothing to map");
|
||||
return;
|
||||
}
|
||||
|
||||
uint assemblyCount = 0;
|
||||
var assemblies = Il2CppInterop.Runtime.IL2CPP.il2cpp_domain_get_assemblies(domain, ref assemblyCount);
|
||||
for (uint a = 0; a < assemblyCount; a++)
|
||||
{
|
||||
var image = Il2CppInterop.Runtime.IL2CPP.il2cpp_assembly_get_image(assemblies[a]);
|
||||
if (image == IntPtr.Zero) continue;
|
||||
images++;
|
||||
|
||||
string imageName;
|
||||
try { imageName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_name(image)) ?? "?"; }
|
||||
catch { imageName = "?"; }
|
||||
|
||||
var classCount = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class_count(image);
|
||||
for (uint c = 0; c < classCount; c++)
|
||||
{
|
||||
var klass = Il2CppInterop.Runtime.IL2CPP.il2cpp_image_get_class(image, c);
|
||||
if (klass == IntPtr.Zero) continue;
|
||||
|
||||
string className;
|
||||
try
|
||||
{
|
||||
var namespaze = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_namespace(klass));
|
||||
var name = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_name(klass));
|
||||
className = string.IsNullOrEmpty(namespaze) ? name : namespaze + "." + name;
|
||||
}
|
||||
catch { className = "?"; failures++; }
|
||||
|
||||
var iterator = IntPtr.Zero;
|
||||
IntPtr methodInfo;
|
||||
while ((methodInfo = Il2CppInterop.Runtime.IL2CPP.il2cpp_class_get_methods(klass, ref iterator)) != IntPtr.Zero)
|
||||
{
|
||||
long entry;
|
||||
try { entry = Marshal.ReadIntPtr(methodInfo).ToInt64(); }
|
||||
catch { entry = 0; }
|
||||
|
||||
if (entry <= baseValue || entry >= baseValue + moduleSize)
|
||||
{
|
||||
unmappable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var rva = entry - baseValue;
|
||||
string methodName;
|
||||
try { methodName = Marshal.PtrToStringAnsi(Il2CppInterop.Runtime.IL2CPP.il2cpp_method_get_name(methodInfo)) ?? "?"; }
|
||||
catch { methodName = "?"; failures++; }
|
||||
|
||||
writer.WriteLine("0x" + rva.ToString("x") + "\t" + imageName + "\t" + className + "::" + methodName);
|
||||
mapped++;
|
||||
|
||||
var full = className + "::" + methodName;
|
||||
if (rva <= _target && rva > bestBelow) { bestBelow = rva; bestBelowName = full; }
|
||||
if (rva > _target && (bestAbove < 0 || rva < bestAbove)) { bestAbove = rva; bestAboveName = full; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError("[AddrMap] pass failed after " + mapped + " methods: " + ex);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (bestBelow < 0)
|
||||
{
|
||||
log.LogWarning("[AddrMap] no method starts below the target 0x" + _target.ToString("x")
|
||||
+ " - the crash is in code no managed method owns (il2cpp runtime or native plugin)."
|
||||
+ (bestAboveName != null ? " Nearest above: 0x" + bestAbove.ToString("x") + " " + bestAboveName : string.Empty));
|
||||
return;
|
||||
}
|
||||
|
||||
var span = bestAbove > bestBelow ? bestAbove - bestBelow : -1;
|
||||
log.LogWarning("[AddrMap] crash target 0x" + _target.ToString("x") + " -> containing method 0x" + bestBelow.ToString("x")
|
||||
+ " " + bestBelowName + " (+0x" + (_target - bestBelow).ToString("x") + " into it)"
|
||||
+ (span > 0 ? "; next method 0x" + bestAbove.ToString("x") + " " + bestAboveName + " (span 0x" + span.ToString("x") + ")" : string.Empty)
|
||||
+ (failures > 0 ? "; " + failures + " name reads failed" : string.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using RRUI;
|
||||
using RRUI.Data;
|
||||
using UnityEngine;
|
||||
|
||||
internal static class DynamicUiPrefabRepair
|
||||
{
|
||||
private static bool reportedMissing;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
harmony.Patch(AccessTools.PropertyGetter(typeof(DynamicUIConfig), "Instance"),
|
||||
postfix: new HarmonyMethod(typeof(DynamicUiPrefabRepair), nameof(Repair)));
|
||||
}
|
||||
|
||||
private static void Repair(DynamicUIConfig __result)
|
||||
{
|
||||
if (__result == null) return;
|
||||
var standard = __result.DynamicFloatPrefab_MultiSelectConfig;
|
||||
var pro = __result.DynamicProUXFloatPrefab_MultiSelectConfig;
|
||||
var fallback = Compatible(standard) ? standard : Compatible(pro) ? pro : null;
|
||||
if (fallback == null)
|
||||
{
|
||||
if (!reportedMissing)
|
||||
{
|
||||
reportedMissing = true;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[DynamicUI] No compatible multi-select float prefab is available; leaving the missing mapping visible.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_ = __result.LandscapePrefabsByType;
|
||||
_ = __result.PortraitPrefabsByType;
|
||||
_ = __result.ProUXPrefabsByType;
|
||||
RepairMap(__result.landscapePrefabsByType, fallback, "landscape");
|
||||
RepairMap(__result.portraitPrefabsByType, fallback, "portrait");
|
||||
RepairMap(__result.proUXPrefabsByType, fallback, "ProUX");
|
||||
}
|
||||
|
||||
private static bool Compatible(GameObject prefab) => prefab != null &&
|
||||
prefab.GetComponentInChildren<DynamicFloatInputFieldModelController_MultiSelectConfig>(true) != null;
|
||||
|
||||
private static void RepairMap(Il2CppSystem.Collections.Generic.Dictionary<Il2CppSystem.Type, GameObject> map,
|
||||
GameObject fallback, string layout)
|
||||
{
|
||||
if (map == null) return;
|
||||
var key = Il2CppType.Of<JNHHEBGEAMP>();
|
||||
if (map.ContainsKey(key) && map[key] != null) return;
|
||||
map[key] = fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class InviteOnlineFriendsPatch
|
||||
{
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
harmony.Patch(AccessTools.PropertyGetter(typeof(ECFHJOKNJIJ), "OHKPFEMEPAE"),
|
||||
prefix: new HarmonyMethod(typeof(InviteOnlineFriendsPatch), nameof(EnableDialog)));
|
||||
harmony.Patch(AccessTools.PropertyGetter(typeof(ECFHJOKNJIJ), "EBCNNMAJGBN"),
|
||||
prefix: new HarmonyMethod(typeof(InviteOnlineFriendsPatch), nameof(IncomingDialog)));
|
||||
}
|
||||
|
||||
private static bool EnableDialog(ECFHJOKNJIJ __instance, ref bool __result)
|
||||
{
|
||||
return ReadServerOverride(__instance, "Radicade.Invites.ShowOnlineFriendsBeforeJoining", ref __result);
|
||||
}
|
||||
|
||||
private static bool IncomingDialog(ECFHJOKNJIJ __instance, ref bool __result)
|
||||
{
|
||||
return ReadServerOverride(__instance, "Radicade.Invites.UseRRUIDialog", ref __result);
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Generic.HashSet<string> logged = new();
|
||||
|
||||
private static bool ReadServerOverride(ECFHJOKNJIJ instance, string key, ref bool result)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configs = instance.CCNKOPKDIJI;
|
||||
if (configs == null || !configs.FNBNNDMHFCK) return true;
|
||||
if (!bool.TryParse(configs.KFNCGHMLMAI(key), out var value)) return true;
|
||||
result = value;
|
||||
return false;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
if (logged.Add(key + ":error"))
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[InviteUI] config lookup failed: " + ex.Message);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class KeepsakeToolPatch
|
||||
{
|
||||
private static readonly Type Palette =
|
||||
typeof(RecRoom.AGUI.ToolUIs.MakerPenPaletteMainScreen);
|
||||
|
||||
private static readonly Type Registry = typeof(PMJEHEPKBJL);
|
||||
|
||||
private static readonly Type ToolTypeEnum = typeof(AOCJJLANBMH);
|
||||
|
||||
private static readonly Type ToolConfig = typeof(RuntimeSpawnableToolConfig);
|
||||
|
||||
|
||||
private static readonly Dictionary<string, int> RepeatedLines = new Dictionary<string, int>();
|
||||
|
||||
private static string Mode
|
||||
=> (Environment.GetEnvironmentVariable("RADICADE_KEEPSAKE_TOOL")
|
||||
?? (RadicadeEacBypassPlugin.KeepsakeToolEnabled?.Value == true ? "on" : "skip")).Trim();
|
||||
|
||||
private static bool Requested => Mode.ToLowerInvariant() switch
|
||||
{
|
||||
"on" or "true" or "1" or "yes" or "enable" or "enabled" => true,
|
||||
"off" or "false" or "0" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static bool Forcing => Mode.ToLowerInvariant() switch
|
||||
{
|
||||
"on" or "true" or "1" or "yes" or "enable" or "enabled" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
if (!Requested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Palette == null || Registry == null || ToolTypeEnum == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[Keepsake] palette/registry/tool-type type not found (" +
|
||||
$"palette={Palette?.FullName ?? "null"}, registry={Registry?.FullName ?? "null"}, " +
|
||||
$"toolType={ToolTypeEnum?.FullName ?? "null"}) - keepsake patch skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Patch(harmony, Registry, "JBAINDGLMBO", new[] { ToolTypeEnum }, null, nameof(ConfigPostfix));
|
||||
|
||||
PatchByName(harmony, Registry, "LPBDCDIMBEN", nameof(PredicatePostfix));
|
||||
|
||||
|
||||
PatchByNameOnFamily(harmony, Palette, "LKGMIHJOIID", nameof(PredicatePostfix));
|
||||
PatchByNameOnFamily(harmony, Palette, "OLKPEGJNJGG", nameof(ConfigFilterPostfix));
|
||||
PatchByNameOnFamily(harmony, Palette, "IBHJBFJMBFB", nameof(ConfigFilterPostfix));
|
||||
|
||||
Patch(harmony, Palette, "OnUsedPaletteItem", null, nameof(UsedPalettePrefix), null);
|
||||
|
||||
Audit(harmony);
|
||||
}
|
||||
|
||||
private static void Audit(Harmony harmony)
|
||||
{
|
||||
var service = AccessTools.TypeByName("AMAOGLDGNLL");
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"MJDEEABFHPJ", "NCAKOLMAAEA", "CAFCLGFGKAO", "KHPJGNADGEE", "NGJMABOMHHB",
|
||||
"FCOMKMPDEIO", "AEHHFFIKLG"
|
||||
})
|
||||
PatchByName(harmony, service, name, nameof(CallPostfix), 2);
|
||||
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("RADICADE_KEEPSAKE_AUDIT"), "1",
|
||||
StringComparison.Ordinal)) return;
|
||||
|
||||
Log("[Keepsake] RADICADE_KEEPSAKE_AUDIT=1: instrumenting the room/progression models too");
|
||||
foreach (var typeName in new[] { "RRUI.Data.RoomModel", "RRUI.Data.RoomListModel", "RRUI.Data.ProgressionEventsModel" })
|
||||
{
|
||||
var model = AccessTools.TypeByName(typeName);
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"IsKeepsakeRoom", "GetCachedAreAllKeepsakesCollected",
|
||||
"GetTotalKeepsakeCountPerCategoryForRoom", "GetUncollectedKeepsakeCountPerCategoryForRoom",
|
||||
"GetTotalKeepsakeCountForRoom", "GetUncollectedKeepsakeCountForRoom",
|
||||
"IsKeepsakeRoomListUpsellItemOwned", "AHACLFABDEO", "CJPFGOBHGBB"
|
||||
})
|
||||
PatchByName(harmony, model, name, nameof(ModelPostfix), 4);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal static object KeepsakeMember()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ToolTypeEnum == null ? null : Enum.Parse(ToolTypeEnum, "KEEPSAKE");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[Keepsake] KEEPSAKE enum member missing: " + e.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsKeepsake(object toolType)
|
||||
=> toolType != null && KeepsakeMember()?.Equals(toolType) == true;
|
||||
|
||||
internal static void Log(string message)
|
||||
=> RadicadeEacBypassPlugin.LogSource.LogInfo(message);
|
||||
|
||||
private static void LogOnce(string message, int limit = 3)
|
||||
{
|
||||
var seen = RepeatedLines.TryGetValue(message, out var count) ? count + 1 : 1;
|
||||
RepeatedLines[message] = seen;
|
||||
if (seen <= limit) Log(message);
|
||||
else if (seen == limit + 1) Log($"[Keepsake] (suppressing further copies of: {message})");
|
||||
}
|
||||
|
||||
|
||||
private static void ConfigPostfix(object[] __args, RuntimeSpawnableToolConfig __result)
|
||||
{
|
||||
var toolType = __args.Length > 0 ? __args[0] : null;
|
||||
var keepsake = IsKeepsake(toolType);
|
||||
if (!keepsake && !IsRestricted(__result)) return;
|
||||
|
||||
|
||||
if (!keepsake || !Forcing) return;
|
||||
|
||||
var allowed = Field(__result, "spawnableToolTypesAllowedInRooms2");
|
||||
if (Contains(allowed, KeepsakeMember())) return;
|
||||
AddToCollection(allowed, KeepsakeMember());
|
||||
}
|
||||
|
||||
private static void PredicatePostfix(MethodBase __originalMethod, object[] __args, ref bool __result)
|
||||
{
|
||||
var toolType = FirstToolType(__args);
|
||||
var keepsake = IsKeepsake(toolType);
|
||||
|
||||
if (keepsake && Forcing && !__result)
|
||||
{
|
||||
__result = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CallPostfix(MethodBase __originalMethod, object[] __args, object __result)
|
||||
{
|
||||
}
|
||||
|
||||
private static void ModelPostfix(MethodBase __originalMethod, object[] __args, object __result)
|
||||
{
|
||||
}
|
||||
|
||||
private static string DescribeResult(object result)
|
||||
{
|
||||
if (result == null) return "(null)";
|
||||
var type = result.GetType();
|
||||
try
|
||||
{
|
||||
if (type == typeof(bool) || type == typeof(int) || type == typeof(long))
|
||||
return result.ToString();
|
||||
|
||||
var status = AccessTools.Property(type, "IsCompleted")?.GetValue(result) as bool?;
|
||||
if (status == true)
|
||||
{
|
||||
var value = AccessTools.Property(type, "Result")?.GetValue(result);
|
||||
return $"{type.Name}(completed) = {value}";
|
||||
}
|
||||
|
||||
if (status == false) return $"{type.Name}(pending)";
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return type.Name;
|
||||
}
|
||||
|
||||
private static void ConfigFilterPostfix(MethodBase __originalMethod, object[] __args, ref bool __result)
|
||||
{
|
||||
var config = __args.Length > 0 ? __args[0] : null;
|
||||
var name = Field(config, "UniqueName")?.ToString() ?? "(unnamed tool)";
|
||||
var keepsake = string.Equals(name, "KEEPSAKE", StringComparison.OrdinalIgnoreCase);
|
||||
if (!keepsake) return;
|
||||
|
||||
if (!Forcing || __result) return;
|
||||
|
||||
__result = true;
|
||||
}
|
||||
|
||||
private static void UsedPalettePrefix(object[] __args)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
private static object FirstToolType(object[] args)
|
||||
{
|
||||
if (args == null) return null;
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg == null) continue;
|
||||
if (ToolTypeEnum.IsInstanceOfType(arg) || arg.GetType() == ToolTypeEnum) return arg;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRestricted(object config)
|
||||
=> Field(config, "AvailableInMakerPen") is bool makerPen && !makerPen;
|
||||
|
||||
private static string Frames(int take = 10)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frames = new StackTrace(true).GetFrames();
|
||||
var sb = new StringBuilder();
|
||||
var taken = 0;
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
var method = frame.GetMethod();
|
||||
var owner = method?.DeclaringType?.Name ?? "?";
|
||||
if (owner.Contains("KeepsakeToolPatch") || owner.Contains("Harmony")) continue;
|
||||
sb.Append(owner).Append('.').Append(method?.Name ?? "?").Append(" <- ");
|
||||
if (++taken >= take) break;
|
||||
}
|
||||
return sb.Length == 0 ? "(no frames)" : sb.ToString().TrimEnd(' ', '<', '-');
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "(stack failed: " + e.GetType().Name + ")";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeList(object collection)
|
||||
{
|
||||
if (collection == null) return "(null)";
|
||||
var type = collection.GetType();
|
||||
|
||||
var count = 0;
|
||||
var keepsake = false;
|
||||
var names = new StringBuilder();
|
||||
try
|
||||
{
|
||||
var countValue = AccessTools.Property(type, "Count")?.GetValue(collection);
|
||||
count = countValue is int n ? n : 0;
|
||||
if (count == 0) return "0 item(s) []";
|
||||
|
||||
var indexer = type.GetProperty("Item");
|
||||
for (var i = 0; i < count && i < 12; i++)
|
||||
{
|
||||
var item = indexer?.GetValue(collection, new object[] { i });
|
||||
if (IsKeepsake(item)) keepsake = true;
|
||||
names.Append(item).Append(',');
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return $"(list read failed: {e.GetType().Name}: {e.Message})";
|
||||
}
|
||||
|
||||
return $"{count} item(s) [{names.ToString().TrimEnd(',')}]" + (keepsake ? " KEEPSAKE present" : "");
|
||||
}
|
||||
|
||||
private static bool Contains(object collection, object item)
|
||||
{
|
||||
if (collection == null || item == null) return false;
|
||||
try
|
||||
{
|
||||
var contains = AccessTools.Method(collection.GetType(), "Contains");
|
||||
return contains?.Invoke(collection, new[] { item }) is true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AddToCollection(object collection, object item)
|
||||
{
|
||||
if (collection == null || item == null) return false;
|
||||
try
|
||||
{
|
||||
var type = collection.GetType();
|
||||
MethodInfo add = null;
|
||||
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (candidate.Name != "Add" || candidate.GetParameters().Length != 1) continue;
|
||||
add = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
if (add == null) return false;
|
||||
add.Invoke(collection, new[] { item });
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Keepsake] could not add to {collection.GetType().Name}: {e.GetType().Name}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(object config)
|
||||
{
|
||||
if (config == null) return "(null config)";
|
||||
|
||||
var sb = new StringBuilder(config.GetType().Name).Append('{');
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"UniqueName", "AvailableInMakerPen", "AvailableToDevsOnly", "IsBetaItem",
|
||||
"AvailableInSpawner", "CanSaveIntoInventions", "LimitInventionSaveForJuniors"
|
||||
})
|
||||
{
|
||||
var value = Field(config, name);
|
||||
if (value != null) sb.Append(name).Append('=').Append(value).Append(' ');
|
||||
}
|
||||
|
||||
var allowed = Field(config, "spawnableToolTypesAllowedInRooms2");
|
||||
if (allowed != null)
|
||||
sb.Append("allowedInRooms=").Append(DescribeList(allowed)).Append(' ');
|
||||
|
||||
return sb.Append('}').ToString();
|
||||
}
|
||||
|
||||
private static object Field(object instance, string name)
|
||||
{
|
||||
if (instance == null) return null;
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var property = AccessTools.Property(type, name);
|
||||
if (property != null) return property.GetValue(instance);
|
||||
return AccessTools.Field(type, name)?.GetValue(instance);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void PatchByNameOnFamily(Harmony harmony, Type root, string method, string postfix)
|
||||
{
|
||||
var patched = 0;
|
||||
foreach (var type in Family(root))
|
||||
{
|
||||
foreach (var found in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
|
||||
| BindingFlags.Static | BindingFlags.Instance))
|
||||
{
|
||||
if (found.Name != method) continue;
|
||||
if (Bind(harmony, found, null, postfix)) patched++;
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Keepsake] no method named {method} on {root.Name} or its nested types - not instrumented");
|
||||
}
|
||||
|
||||
private static void PatchByName(Harmony harmony, Type target, string method, string postfix,
|
||||
int maxArguments = 1)
|
||||
{
|
||||
if (target == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Keepsake] type for {method} not found - not instrumented");
|
||||
return;
|
||||
}
|
||||
|
||||
var patched = 0;
|
||||
foreach (var found in target.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
|
||||
| BindingFlags.Static | BindingFlags.Instance))
|
||||
{
|
||||
if (found.Name != method) continue;
|
||||
var parameters = found.GetParameters();
|
||||
if (parameters.Length > maxArguments) continue;
|
||||
if (parameters.Any(p => p.ParameterType.IsByRef || p.IsOut || p.ParameterType.IsPointer)) continue;
|
||||
if (maxArguments == 1 && (parameters.Length != 1 ||
|
||||
(!ToolTypeEnum.IsAssignableFrom(parameters[0].ParameterType)
|
||||
&& parameters[0].ParameterType != ToolTypeEnum))) continue;
|
||||
if (Bind(harmony, found, null, postfix)) patched++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> Family(Type root)
|
||||
{
|
||||
yield return root;
|
||||
Type[] nested;
|
||||
try
|
||||
{
|
||||
nested = root.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic);
|
||||
}
|
||||
catch
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var type in nested)
|
||||
foreach (var deeper in Family(type))
|
||||
yield return deeper;
|
||||
}
|
||||
|
||||
private static void Patch(Harmony harmony, Type target, string method, Type[] args,
|
||||
string prefix, string postfix)
|
||||
{
|
||||
if (target == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning($"[Keepsake] type for {method} not found - not instrumented");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var found = args == null
|
||||
? AccessTools.Method(target, method)
|
||||
: AccessTools.Method(target, method, args);
|
||||
if (found == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Keepsake] {target.Name}.{method} not found - not instrumented");
|
||||
return;
|
||||
}
|
||||
|
||||
Bind(harmony, found, prefix, postfix);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Keepsake] FAILED to instrument {target.Name}.{method}: {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Bind(Harmony harmony, MethodBase found, string prefix, string postfix)
|
||||
{
|
||||
try
|
||||
{
|
||||
var self = typeof(KeepsakeToolPatch);
|
||||
const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic;
|
||||
harmony.Patch(found,
|
||||
prefix: prefix == null ? null : new HarmonyMethod(self.GetMethod(prefix, flags)),
|
||||
postfix: postfix == null ? null : new HarmonyMethod(self.GetMethod(postfix, flags)));
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[Keepsake] FAILED to instrument {found.DeclaringType?.Name}.{found.Name}: " +
|
||||
$"{e.GetType().Name}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using HarmonyLib;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
internal static class LogFloodGate
|
||||
{
|
||||
internal const double WindowSeconds = 30.0;
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
private static readonly Dictionary<string, Entry> Seen = new Dictionary<string, Entry>();
|
||||
private static long _totalSuppressed;
|
||||
|
||||
private static bool _selfTestPending;
|
||||
private static int _selfTestFired;
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public double WindowStart;
|
||||
public int Suppressed;
|
||||
public int ReportedAt;
|
||||
}
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_LOG_GATE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = AccessTools.Method(typeof(ODGGOALOLEG), "LogException",
|
||||
new[] { typeof(Il2CppSystem.Exception), typeof(UnityEngine.Object) });
|
||||
if (target == null)
|
||||
{
|
||||
Warn("[LogGate] ODGGOALOLEG.LogException(Il2CppSystem.Exception, UnityEngine.Object) not found - " +
|
||||
"not armed; the per-frame exception flood stays as-is.");
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = AccessTools.Method(typeof(LogFloodGate), nameof(Prefix));
|
||||
if (prefix == null)
|
||||
{
|
||||
Warn("[LogGate] patch method Prefix not found - not armed.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(prefix));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Warn($"[LogGate] failed to arm ({e.GetType().Name}: {e.Message}); the exception flood stays as-is.");
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool Prefix(Il2CppSystem.Exception __0)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
try
|
||||
{
|
||||
if (_selfTestPending && System.Threading.Interlocked.Exchange(ref _selfTestFired, 1) == 0)
|
||||
{
|
||||
_selfTestPending = false;
|
||||
RunSelfTest();
|
||||
}
|
||||
|
||||
var signature = Signature(__0);
|
||||
if (signature == null) return true;
|
||||
|
||||
var now = Time.realtimeSinceStartup;
|
||||
lock (Seen)
|
||||
{
|
||||
if (Seen.Count > 512) Evict(now);
|
||||
|
||||
if (!Seen.TryGetValue(signature, out var entry))
|
||||
{
|
||||
Seen[signature] = new Entry { WindowStart = now };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (now - entry.WindowStart < WindowSeconds)
|
||||
{
|
||||
entry.Suppressed++;
|
||||
_totalSuppressed++;
|
||||
if (entry.Suppressed == 1)
|
||||
Warn($"[LogGate] repeats of this exception are now suppressed for {WindowSeconds:0}s at a time: {signature}");
|
||||
else if (entry.Suppressed - entry.ReportedAt >= 500)
|
||||
{
|
||||
entry.ReportedAt = entry.Suppressed;
|
||||
Warn($"[LogGate] {entry.Suppressed} identical repeats of this exception suppressed so far " +
|
||||
$"(session total {_totalSuppressed}): {signature}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var dropped = entry.Suppressed;
|
||||
entry.WindowStart = now;
|
||||
entry.Suppressed = 0;
|
||||
entry.ReportedAt = 0;
|
||||
if (dropped > 0)
|
||||
Warn($"[LogGate] {dropped} identical repeats were collapsed in the previous {WindowSeconds:0}s window " +
|
||||
$"(session total {_totalSuppressed}); logging this occurrence in full: {signature}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Evict(double now)
|
||||
{
|
||||
var stale = new List<string>();
|
||||
foreach (var pair in Seen)
|
||||
if (now - pair.Value.WindowStart > 600.0)
|
||||
stale.Add(pair.Key);
|
||||
foreach (var key in stale)
|
||||
Seen.Remove(key);
|
||||
}
|
||||
|
||||
private static string Signature(Il2CppSystem.Exception exception)
|
||||
{
|
||||
string type, message, stack;
|
||||
try { type = exception.GetType().Name; } catch { type = "Exception"; }
|
||||
try { message = exception.Message; } catch { message = "<message unreadable>"; }
|
||||
try { stack = exception.StackTrace; } catch { stack = null; }
|
||||
if (string.IsNullOrEmpty(message) && string.IsNullOrEmpty(stack)) return null;
|
||||
return Trim(type + ": " + message, 150) + " @ " + FirstFrame(stack);
|
||||
}
|
||||
|
||||
private static string FirstFrame(string stack)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stack)) return "<no stack>";
|
||||
var start = stack.IndexOf(" at ", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
var newline = stack.IndexOf('\n');
|
||||
return Trim(newline < 0 ? stack : stack.Substring(0, newline), 120);
|
||||
}
|
||||
var end = stack.IndexOf('\n', start);
|
||||
return Trim(end < 0 ? stack.Substring(start) : stack.Substring(start, end - start), 120);
|
||||
}
|
||||
|
||||
private static string Trim(string value, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
value = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
return value.Length <= max ? value : value.Substring(0, max) + "...";
|
||||
}
|
||||
|
||||
private static void Warn(string message)
|
||||
{
|
||||
try { RadicadeEacBypassPlugin.LogSource.LogWarning(message); } catch { }
|
||||
}
|
||||
|
||||
internal static void RequestSelfTestIfAsked()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_LOG_GATE_SELFTEST");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||
if (raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no") return;
|
||||
|
||||
_selfTestPending = true;
|
||||
RadicadeEacBypassPlugin.LogSource.LogInfo(
|
||||
"[LogGate] self-test armed: the next exception through the game handler fires five identical ones to prove the gate (RADICADE_LOG_GATE_SELFTEST).");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Warn($"[LogGate] self-test could not be armed: {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunSelfTest()
|
||||
{
|
||||
try
|
||||
{
|
||||
var before = _totalSuppressed;
|
||||
var exception = new Il2CppSystem.Exception("radicade log gate self-test");
|
||||
const int shots = 5;
|
||||
for (var i = 0; i < shots; i++)
|
||||
UnityEngine.Debug.LogException(exception);
|
||||
|
||||
var collapsed = _totalSuppressed - before;
|
||||
Warn($"[LogGate] self-test: fired {shots} identical exceptions through the live game handler; {collapsed} were collapsed, " +
|
||||
$"{shots - collapsed} reached the log " +
|
||||
$"({(collapsed == shots - 1 ? "PASS" : "FAIL")} - the first of a burst always logs in full, the rest are suppressed).");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Warn($"[LogGate] self-test failed to run: {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Drops known-noise log lines inside Unity's own logger, so they never reach the
|
||||
/// console or BepInEx's LogOutput.log. The game funnels both Debug.LogError and
|
||||
/// Debug.LogWarning through Engine.Logger.Log(LogType, object) - that is the
|
||||
/// single place where a message can be refused before anyone formats it.
|
||||
/// </summary>
|
||||
internal static class LogNoiseFilter
|
||||
{
|
||||
private const string SelfTestProbe =
|
||||
"The character used for Underline is not available in font asset [radicade-filter-self-test].";
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
var prefix = AccessTools.Method(typeof(LogNoiseFilter), nameof(Prefix));
|
||||
if (prefix == null)
|
||||
{
|
||||
Diag.Warn("[LogFilter] the filter method was not found; known-noise log spam stays as-is.");
|
||||
return;
|
||||
}
|
||||
|
||||
var patched = 0;
|
||||
foreach (var target in Targets())
|
||||
{
|
||||
try
|
||||
{
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(prefix));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[LogFilter] could not patch " + target.DeclaringType?.Name + "." + target.Name +
|
||||
" (" + ex.GetType().Name + ": " + ex.Message + ").");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
Diag.Warn("[LogFilter] no Unity log funnel could be patched; the RigidbodyEx and font-underline spam stays as-is.");
|
||||
return;
|
||||
}
|
||||
|
||||
Diag.Info("[LogFilter] armed on " + patched + " Unity log funnel(s): " + LogNoiseRules.PatternCount +
|
||||
" known-noise pattern(s) are dropped before they are printed.");
|
||||
SelfTest();
|
||||
}
|
||||
|
||||
private static IEnumerable<MethodBase> Targets()
|
||||
{
|
||||
var log = AccessTools.Method(typeof(Logger), "Log",
|
||||
new[] { typeof(LogType), typeof(Il2CppSystem.Object) });
|
||||
if (log != null) yield return log;
|
||||
|
||||
var logWithContext = AccessTools.Method(typeof(Logger), "Log",
|
||||
new[] { typeof(LogType), typeof(Il2CppSystem.Object), typeof(UnityEngine.Object) });
|
||||
if (logWithContext != null) yield return logWithContext;
|
||||
}
|
||||
|
||||
private static bool Prefix(object[] __args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__args == null || __args.Length < 2) return true;
|
||||
var message = __args[1];
|
||||
if (message == null) return true;
|
||||
return !LogNoiseRules.ShouldDrop(message.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires one probe line through Debug.LogWarning and checks that the rules saw
|
||||
/// it. If they did not, the filter is not wired to this build's logger and the
|
||||
/// log says so instead of pretending the spam is gone.
|
||||
/// </summary>
|
||||
private static void SelfTest()
|
||||
{
|
||||
try
|
||||
{
|
||||
var before = LogNoiseRules.DroppedCount;
|
||||
UnityEngine.Debug.LogWarning(SelfTestProbe);
|
||||
if (LogNoiseRules.DroppedCount > before)
|
||||
Diag.Info("[LogFilter] self-test PASS: a probe log line was intercepted inside Unity's logger.");
|
||||
else
|
||||
Diag.Warn("[LogFilter] self-test FAIL: a probe log line reached the log, so this build's spam is not being filtered.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Diag.Warn("[LogFilter] self-test could not run (" + ex.GetType().Name + ": " + ex.Message + ").");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
/// <summary>
|
||||
/// Log lines the client prints on every room load that say nothing a player can
|
||||
/// act on and bury the lines that matter. Pure rules only, so they can be
|
||||
/// exercised outside the game.
|
||||
/// </summary>
|
||||
internal static class LogNoiseRules
|
||||
{
|
||||
private static readonly string[] Noise =
|
||||
{
|
||||
// Every chair and couch the dorm/Rec Center spawns complains that its
|
||||
// RigidbodyEx has no entity component - dozens of lines per room load,
|
||||
// all identical in substance.
|
||||
"has an RigidbodyEx but no",
|
||||
|
||||
// TMP asks for an underline glyph the legacy Diggity font does not carry;
|
||||
// the text renders fine without it.
|
||||
"The character used for Underline is not available in font asset",
|
||||
};
|
||||
|
||||
private static int _dropped;
|
||||
|
||||
internal static int PatternCount => Noise.Length;
|
||||
|
||||
internal static int DroppedCount => Volatile.Read(ref _dropped);
|
||||
|
||||
internal static bool ShouldDrop(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return false;
|
||||
|
||||
foreach (var pattern in Noise)
|
||||
{
|
||||
if (message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
Interlocked.Increment(ref _dropped);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1038 @@
|
||||
using BepInEx;
|
||||
using BepInEx.Logging;
|
||||
using BepInEx.Unity.IL2CPP;
|
||||
using HarmonyLib;
|
||||
using RecRoom.AntiCheat;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
internal sealed class FixedSetting<T>
|
||||
{
|
||||
internal T Value { get; }
|
||||
|
||||
internal FixedSetting(T value) => Value = value;
|
||||
}
|
||||
|
||||
[BepInPlugin("com.radicade.patch", "RadicadePatch", "3.0.0")]
|
||||
public sealed class RadicadeEacBypassPlugin : BasePlugin
|
||||
{
|
||||
internal static ManualLogSource LogSource;
|
||||
|
||||
internal const string ServerAddress = "https://ns.radicade.app/";
|
||||
internal const string ServerHost = "ns.radicade.app";
|
||||
|
||||
internal static FixedSetting<string> ServerUrl = new(ServerAddress);
|
||||
internal static FixedSetting<string> TlsAllowedHosts = new(ServerHost);
|
||||
internal static FixedSetting<bool> RealtimeLog = new(false);
|
||||
internal static FixedSetting<bool> KeepsakeToolEnabled = new(true);
|
||||
internal static Il2CppSystem.Threading.SynchronizationContext MainThreadContext;
|
||||
internal static string ImageKeyId;
|
||||
|
||||
static string ReadMetadataKey(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path)
|
||||
? ImageSignatureRequest.DetectKeyIdFromGameMetadata(File.ReadAllBytes(path))
|
||||
: null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogSource.LogWarning($"[ImageSig] key id detection failed for {Path.GetFileName(path)}: {e.GetType().Name}: {e.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void LogFixedConnection()
|
||||
{
|
||||
Log.LogInfo($"[TLS] fixed server {ServerUrl.Value}; AllowedHosts = '{TlsAllowedHosts.Value}'");
|
||||
if (!EmbeddedTlsPatches.IsHostAllowed(ServerHost))
|
||||
Log.LogError($"[TLS] built-in AllowedHosts '{TlsAllowedHosts.Value}' does not cover '{ServerHost}' - the embedded TLS stack will reject the server certificate.");
|
||||
}
|
||||
|
||||
void WarnIfMetadataPatchedForAnotherServer(Uri server)
|
||||
{
|
||||
try
|
||||
{
|
||||
var marker = Path.Combine(UnityEngine.Application.dataPath, "radicade-patch.json");
|
||||
if (!File.Exists(marker))
|
||||
{
|
||||
Log.LogWarning($"[Meta] no {marker}: this install was never metadata-patched, so the client still resolves the official hosts.");
|
||||
return;
|
||||
}
|
||||
|
||||
var text = File.ReadAllText(marker);
|
||||
var key = text.IndexOf("\"serverUrl\"", StringComparison.Ordinal);
|
||||
var colon = key < 0 ? -1 : text.IndexOf(':', key);
|
||||
var open = colon < 0 ? -1 : text.IndexOf('"', colon + 1);
|
||||
var close = open < 0 ? -1 : text.IndexOf('"', open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
Log.LogWarning($"[Meta] {marker} has no readable serverUrl field; cannot confirm the patched server matches {server}.");
|
||||
return;
|
||||
}
|
||||
|
||||
var patched = text[(open + 1)..close].TrimEnd('/');
|
||||
var wanted = server.GetLeftPart(UriPartial.Authority).TrimEnd('/');
|
||||
if (patched.Equals(wanted, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.LogInfo($"[Meta] metadata patched for {patched}; matches Connection:ServerUrl.");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.LogWarning($"[Meta] this install's metadata was patched for '{patched}' but Connection:ServerUrl is '{wanted}'. "
|
||||
+ "The client's own literals still point at the patched address, so requests will not reach the configured server. "
|
||||
+ $"Re-patch from the pristine copy: python tools/patch-0831-client-metadata.py <game dir> --server {wanted} --apply");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.LogWarning($"[Meta] metadata marker check failed: {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public override void Load()
|
||||
{
|
||||
LogSource = Log;
|
||||
if (UnityEngine.Application.version != "20230831")
|
||||
throw new NotSupportedException("This plugin requires Rec Room 20230831.");
|
||||
|
||||
MainThreadContext = Il2CppSystem.Threading.SynchronizationContext.Current;
|
||||
if (MainThreadContext == null)
|
||||
Log.LogWarning("[Patch] no main-thread synchronization context was captured; promise resolution may stall.");
|
||||
|
||||
if (!Uri.TryCreate(ServerUrl.Value, UriKind.Absolute, out var server) || server.Scheme != "https")
|
||||
throw new InvalidOperationException("The RadicadePatch client requires an HTTPS server URL.");
|
||||
|
||||
LogFixedConnection();
|
||||
WarnIfMetadataPatchedForAnotherServer(server);
|
||||
|
||||
var metadataRoot = Path.Combine(UnityEngine.Application.dataPath, "il2cpp_data", "Metadata");
|
||||
var gameKeyId = ReadMetadataKey(Path.Combine(metadataRoot, "global-metadata.dat"));
|
||||
var interopKeyId = gameKeyId == null ? ReadMetadataKey(Path.Combine(metadataRoot, "global-metadata.interop.dat")) : null;
|
||||
ImageKeyId = gameKeyId ?? interopKeyId;
|
||||
if (ImageKeyId == null)
|
||||
Log.LogWarning("[ImageSig] no image key id was found in the game metadata; rewritten URLs will not state one.");
|
||||
|
||||
var harmony = new Harmony("com.radicade.patch");
|
||||
|
||||
var steps = new (string Label, Action Apply)[]
|
||||
{
|
||||
("module hiding", ApplyModuleHiding),
|
||||
("hile gate", ArmHileGate),
|
||||
("connection gate", () => ConnectionGate.Install(harmony)),
|
||||
("august challenge", () => harmony.PatchAll(typeof(AugustChallengePatches))),
|
||||
("august launch diagnostics", () => harmony.PatchAll(typeof(AugustLaunchDiagnostics))),
|
||||
("august launch validator", () => harmony.PatchAll(typeof(AugustLaunchValidatorPatch))),
|
||||
("rsa verify neutraliser", () => RsaVerifyNeutralizerPatches.Apply(harmony)),
|
||||
("file check", () => harmony.PatchAll(typeof(FileCheckEntryPatch))),
|
||||
("eac native client stub", () => harmony.PatchAll(typeof(EacNativeClientStubPatch))),
|
||||
("eac health query", () => harmony.PatchAll(typeof(EacHealthQueryLiePatch))),
|
||||
("cheat scan suppression", () => harmony.PatchAll(typeof(CheatScanSuppressPatch))),
|
||||
("cheat detection suppression", () => harmony.PatchAll(typeof(CheatDetectionSuppressPatch))),
|
||||
("cheat dorm drop guard", () => harmony.PatchAll(typeof(CheatDormDropGuardPatch))),
|
||||
("cheat manager trace", () => harmony.PatchAll(typeof(CheatManagerTracePatch))),
|
||||
("exit initiator tracer", () => harmony.PatchAll(typeof(ExitInitiatorTracerPatch))),
|
||||
("exit sequence trace", () => harmony.PatchAll(typeof(ExitSequenceTracePatch))),
|
||||
("unity quit veto", () => harmony.PatchAll(typeof(UnityQuitVetoPatch))),
|
||||
("exit state veto", () => harmony.PatchAll(typeof(ExitStateVetoPatch))),
|
||||
("crash address map", CrashAddressMap.Start),
|
||||
("tool collision reset guard", () => harmony.PatchAll(typeof(ToolCollisionResetGuardPatch))),
|
||||
("collider cast guard", () => ColliderCastGuardPatch.Apply(harmony)),
|
||||
("unbound prop freeze", () => harmony.PatchAll(typeof(UnboundPropFreezePatch))),
|
||||
("keepsake tool", () => KeepsakeToolPatch.Apply(harmony)),
|
||||
("dynamic ui prefab repair", () => DynamicUiPrefabRepair.Apply(harmony)),
|
||||
("client text rewrites", () => ClientTextRewrites.Apply(harmony)),
|
||||
("voice chat restriction capture", () => VoiceChatRestrictionPatches.ArmInstanceCapture(harmony)),
|
||||
("voice chat ban flags", () => VoiceChatBanFlagPatches.Apply(harmony)),
|
||||
("invite online friends", () => InviteOnlineFriendsPatch.Apply(harmony)),
|
||||
("log flood gate", () => ApplyLogFloodGate(harmony)),
|
||||
("realtime exit watch", () => RealtimeDiagnostics.ApplyExitWatch(harmony)),
|
||||
("realtime diagnostics", () => RealtimeDiagnostics.Apply(harmony, RealtimeLog?.Value ?? true)),
|
||||
("embedded tls", () => EmbeddedTlsPatches.Apply(harmony)),
|
||||
("http response tolerance", () => HttpTolerancePatches.Apply(harmony)),
|
||||
("log noise filter", () => LogNoiseFilter.Apply(harmony)),
|
||||
("nameserver connection", () => harmony.PatchAll(typeof(NameserverConnectionPatches))),
|
||||
("websocket redirect", () => WebSocketRedirectPatches.Apply(harmony)),
|
||||
};
|
||||
|
||||
var failed = 0;
|
||||
for (var i = 0; i < steps.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
steps[i].Apply();
|
||||
Log.LogInfo($"[Patch] {i + 1}/{steps.Length} {steps[i].Label}");
|
||||
}
|
||||
catch (Exception stepEx)
|
||||
{
|
||||
failed++;
|
||||
Log.LogError($"[Patch] {i + 1}/{steps.Length} {steps[i].Label} failed: {stepEx}");
|
||||
}
|
||||
}
|
||||
|
||||
Log.LogInfo("PATCH LIST END");
|
||||
if (failed > 0)
|
||||
Log.LogWarning($"[Patch] {failed} of {steps.Length} steps failed, so the client may not behave as configured.");
|
||||
|
||||
ConnectionGate.Open();
|
||||
}
|
||||
|
||||
void ApplyModuleHiding()
|
||||
{
|
||||
if (!ModuleHidingPolicy.Enabled)
|
||||
return;
|
||||
|
||||
var hidden = PebModuleHider.HideModule("winhttp.dll");
|
||||
if (!hidden)
|
||||
Log.LogWarning("[ModuleHide] winhttp.dll was not found in the PEB module lists; a module scan will still see it.");
|
||||
|
||||
var wiped = PeHeaderWiper.WipeSignatures("winhttp.dll");
|
||||
if (!wiped)
|
||||
Log.LogWarning("[ModuleHide] the winhttp.dll PE signature wipe did not apply (signatures missing or VirtualProtect failed).");
|
||||
|
||||
if (hidden && wiped)
|
||||
Log.LogInfo("[ModuleHide] winhttp.dll is unlinked from the PEB module lists and its PE signatures are wiped.");
|
||||
}
|
||||
|
||||
void ArmHileGate()
|
||||
{
|
||||
HileGate.Enabled = true;
|
||||
Log.LogInfo(HileGate.DropEnabled
|
||||
? "[HileGate] armed: anti-cheat report POSTs are dropped at the HTTP layer."
|
||||
: "[HileGate] armed: anti-cheat report POSTs are captured in full and then allowed through (RADICADE_HILE_GATE=drop drops them instead).");
|
||||
}
|
||||
|
||||
void ApplyLogFloodGate(Harmony harmony)
|
||||
{
|
||||
LogFloodGate.Apply(harmony);
|
||||
LogFloodGate.RequestSelfTestIfAsked();
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class AugustLaunchDiagnostics
|
||||
{
|
||||
[HarmonyPrefix, HarmonyPatch(typeof(MILEHJBNIKI), "LMMMGKJCPOF")]
|
||||
[HarmonyPatch(typeof(MILEHJBNIKI), "MBNHJLNEIKL")]
|
||||
private static void Trace(FDJFJOBADEI __0)
|
||||
{
|
||||
if (__0 == FDJFJOBADEI.LAUNCH_VALIDATION_FAILED)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[August launch diagnostic] " + Il2CppSystem.Environment.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class AugustChallengePatches
|
||||
{
|
||||
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), nameof(EACManager.GenerateChallengeResponse))]
|
||||
private static bool Challenge(ref string __result)
|
||||
{
|
||||
__result = "radicade-eac-bypass";
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPrefix, HarmonyPatch(typeof(EACManager), "JGFKOKIDHIN")]
|
||||
private static bool Ready(ref bool __result)
|
||||
{
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(IDLGLOLKLEB.MKGADIPJDLK), "IDDOELEGIFB")]
|
||||
internal static class AugustLaunchValidatorPatch
|
||||
{
|
||||
static readonly System.Threading.CancellationTokenSource Pump = new();
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool Bypass(IDLGLOLKLEB.MKGADIPJDLK __instance)
|
||||
{
|
||||
var promise = __instance.promise;
|
||||
if (promise == null)
|
||||
return true;
|
||||
|
||||
var context = RadicadeEacBypassPlugin.MainThreadContext;
|
||||
try
|
||||
{
|
||||
var before = Il2CppSystem.Threading.SynchronizationContext.Current;
|
||||
if (context != null)
|
||||
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(context);
|
||||
try
|
||||
{
|
||||
promise.BEJNCICKNOM();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Resolve failed: {e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Il2CppSystem.Threading.SynchronizationContext.SetSynchronizationContext(before);
|
||||
}
|
||||
}
|
||||
catch (Exception outer)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogError($"[August] Context install failed: {outer}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal static class RsaVerifyNeutralizerPatches
|
||||
{
|
||||
static readonly byte[] P1KeyModulus = System.Convert.FromBase64String(
|
||||
"X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIPMaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJAijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ==");
|
||||
|
||||
static int p1Calls, totalCalls;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
|
||||
new[]
|
||||
{
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(Il2CppSystem.Security.Cryptography.HashAlgorithmName),
|
||||
typeof(Il2CppSystem.Security.Cryptography.RSASignaturePadding)
|
||||
});
|
||||
|
||||
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyHash",
|
||||
new[]
|
||||
{
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(int),
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
|
||||
});
|
||||
|
||||
PatchVerify(harmony, typeof(Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider), "VerifyData",
|
||||
new[]
|
||||
{
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>),
|
||||
typeof(Il2CppSystem.Object),
|
||||
typeof(Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStructArray<byte>)
|
||||
});
|
||||
}
|
||||
|
||||
static void PatchVerify(Harmony harmony, Type target, string methodName, Type[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var m = AccessTools.Method(target, methodName, args);
|
||||
if (m == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[RsaVerify] {target.Name}.{methodName}({args.Length} args) not found - skipped");
|
||||
return;
|
||||
}
|
||||
harmony.Patch(m, prefix: new HarmonyMethod(typeof(RsaVerifyNeutralizerPatches).GetMethod(nameof(VerifyPrefix))));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[RsaVerify] failed to patch {target.Name}.{methodName}: {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
} public static bool VerifyPrefix(
|
||||
Il2CppSystem.Security.Cryptography.RSACryptoServiceProvider __instance, ref bool __result)
|
||||
{
|
||||
totalCalls++;
|
||||
var isP1 = false;
|
||||
try
|
||||
{
|
||||
var modulus = __instance.ExportParameters(false).Modulus;
|
||||
isP1 = modulus != null && modulus.Length == P1KeyModulus.Length && modulus.SequenceEqual(P1KeyModulus);
|
||||
if (isP1)
|
||||
p1Calls++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
__result = isP1;
|
||||
if (totalCalls <= 8)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[ImageSig] RSA verify #{totalCalls} ({(isP1 ? "p1 key" : "game key")}) forced {(isP1 ? "true" : "false")}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class FileCheckEntryPatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
yield return AccessTools.Method(typeof(GBOAMDFCNJA), "PEEADMOKNLI");
|
||||
yield return AccessTools.Method(typeof(GBOAMDFCNJA), "IBPPCNEAGDP");
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool Bypass(ref MFBKDKPGMNP<GBOAMDFCNJA.CMIGBOACMAG> __result)
|
||||
{
|
||||
var result = GBOAMDFCNJA.CMIGBOACMAG.NDGAGMGBOMD("radicade: file check bypassed");
|
||||
__result = NOKBHGDKGNF.BGFCCOEKHOE(result);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static unsafe class PebModuleHider
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_BASIC_INFORMATION
|
||||
{
|
||||
public IntPtr Reserved1;
|
||||
public IntPtr PebBaseAddress;
|
||||
public IntPtr Reserved2_0;
|
||||
public IntPtr Reserved2_1;
|
||||
public IntPtr UniqueProcessId;
|
||||
public IntPtr Reserved3;
|
||||
}
|
||||
|
||||
[DllImport("ntdll.dll")]
|
||||
private static extern int NtQueryInformationProcess(
|
||||
IntPtr hProcess, int processInfoClass, ref PROCESS_BASIC_INFORMATION pbi,
|
||||
int cb, out int returnLength);
|
||||
|
||||
private struct LIST_ENTRY { public IntPtr Flink, Blink; }
|
||||
|
||||
private const int OffInMemoryOrder = 0x10;
|
||||
private const int OffInInitOrder = 0x20;
|
||||
private const int OffBaseDllName = 0x58;
|
||||
|
||||
public static bool HideModule(string dllName)
|
||||
{
|
||||
var pbi = default(PROCESS_BASIC_INFORMATION);
|
||||
int status = NtQueryInformationProcess(new IntPtr(-1), 0, ref pbi,
|
||||
Marshal.SizeOf<PROCESS_BASIC_INFORMATION>(), out _);
|
||||
if (status != 0 || pbi.PebBaseAddress == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
byte* peb = (byte*)pbi.PebBaseAddress;
|
||||
byte* ldr = *(byte**)(peb + 0x18);
|
||||
if (ldr == null)
|
||||
return false;
|
||||
|
||||
LIST_ENTRY* head = (LIST_ENTRY*)(ldr + 0x10);
|
||||
byte* target = null;
|
||||
for (LIST_ENTRY* n = (LIST_ENTRY*)head->Flink; n != head && target == null; n = (LIST_ENTRY*)n->Flink)
|
||||
{
|
||||
byte* entry = (byte*)n;
|
||||
ushort* us = (ushort*)(entry + OffBaseDllName);
|
||||
ushort lenBytes = *us;
|
||||
char* buf = *(char**)(entry + OffBaseDllName + 8);
|
||||
if (buf == null || lenBytes == 0)
|
||||
continue;
|
||||
string name = new string(buf, 0, lenBytes / 2);
|
||||
if (name.Equals(dllName, StringComparison.OrdinalIgnoreCase))
|
||||
target = entry;
|
||||
}
|
||||
|
||||
if (target == null)
|
||||
return false;
|
||||
|
||||
Unlink((LIST_ENTRY*)target);
|
||||
Unlink((LIST_ENTRY*)(target + OffInMemoryOrder));
|
||||
Unlink((LIST_ENTRY*)(target + OffInInitOrder));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Unlink(LIST_ENTRY* e)
|
||||
{
|
||||
var flink = (LIST_ENTRY*)e->Flink;
|
||||
var blink = (LIST_ENTRY*)e->Blink;
|
||||
if (flink == e || blink == e)
|
||||
return;
|
||||
blink->Flink = e->Flink;
|
||||
flink->Blink = e->Blink;
|
||||
e->Flink = (IntPtr)e;
|
||||
e->Blink = (IntPtr)e;
|
||||
}
|
||||
}
|
||||
|
||||
internal static unsafe class PeHeaderWiper
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr GetModuleHandleW(string moduleName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
|
||||
|
||||
public static bool WipeSignatures(string dllName)
|
||||
{
|
||||
IntPtr baseAddr = GetModuleHandleW(dllName);
|
||||
if (baseAddr == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
byte* p = (byte*)baseAddr;
|
||||
int eLfanew = *(int*)(p + 0x3C);
|
||||
if (eLfanew <= 0 || eLfanew > 0x1000)
|
||||
return false;
|
||||
|
||||
if (!VirtualProtect(baseAddr, (UIntPtr)0x1000, 0x04 , out uint old))
|
||||
return false;
|
||||
|
||||
bool wasMz = p[0] == (byte)'M' && p[1] == (byte)'Z';
|
||||
bool wasPe = *(uint*)(p + eLfanew) == 0x00004550;
|
||||
|
||||
*(ushort*)p = 0;
|
||||
*(uint*)(p + eLfanew) = 0;
|
||||
|
||||
VirtualProtect(baseAddr, (UIntPtr)0x1000, old, out _);
|
||||
return wasMz && wasPe;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class ExitInitiatorTracerPatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
yield return AccessTools.Method(typeof(KJAMNFCDBIO), "GMAKAKECEDK");
|
||||
yield return AccessTools.Method(typeof(KJAMNFCDBIO), "FLMPEJAIKFO");
|
||||
yield return AccessTools.Method(typeof(KJAMNFCDBIO), "LHNNEFHHPBG");
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
internal static bool TraceAndBlock()
|
||||
{
|
||||
var armed = HileGate.DroppedCount > 0;
|
||||
if (armed)
|
||||
{
|
||||
var stack = "<unavailable>";
|
||||
try { stack = Environment.StackTrace; } catch { }
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ExitInit] blocked a managed exit initiator - caller trace:\n" + stack);
|
||||
}
|
||||
return !armed;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
[HarmonyPatch(typeof(BestHTTP.HTTPManager), "SendRequest", new[] { typeof(BestHTTP.HTTPRequest) })]
|
||||
internal static class ExitSequenceTracePatch
|
||||
{
|
||||
private static int _dumped;
|
||||
|
||||
[HarmonyPriority(Priority.First)]
|
||||
private static void Prefix(BestHTTP.HTTPRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = request?.Uri?.AbsolutePath;
|
||||
if (path == null) return;
|
||||
if (path.IndexOf("/matchmaking/player/logout", StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||
path.IndexOf("/playersettings/playersettings", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
return;
|
||||
if (++_dumped > 4) return;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[ExitTrace] shutdown request " + path + " - managed caller stack:" + ExitTraceHelper.SafeStack());
|
||||
}
|
||||
catch (Exception traceEx)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ExitTrace] dump failed: " + traceEx.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class UnityQuitVetoPatch
|
||||
{
|
||||
internal static bool Enabled => _enabled;
|
||||
|
||||
private static readonly bool _enabled = IsEnabled();
|
||||
|
||||
private static int _blocks;
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_QUIT_VETO");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var noArg = AccessTools.Method(typeof(KJAMNFCDBIO), "OFBGCPAHADA", Type.EmptyTypes);
|
||||
if (noArg != null) yield return noArg;
|
||||
|
||||
var flag = AccessTools.Method(typeof(KJAMNFCDBIO), "OFBGCPAHADA", new[] { typeof(bool) });
|
||||
if (flag != null) yield return flag;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Veto(ref bool __result)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
__result = false;
|
||||
|
||||
var count = ++_blocks;
|
||||
if (count <= 3 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
"[QuitVeto] wantsToQuit answered false (block #" + count + ") - Unity's quit is cancelled. " +
|
||||
"This is the anti-cheat shutdown, not a user quit; the veto is armed by RADICADE_QUIT_VETO=1, set it to 0 to disable." +
|
||||
ExitTraceHelper.SafeStack());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ModuleHidingPolicy
|
||||
{
|
||||
internal static bool Enabled { get; } = ReadFlag("RADICADE_MODULE_HIDE");
|
||||
|
||||
private static bool ReadFlag(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatScanSuppressPatch
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_SCAN");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Reflection.MethodBase TargetMethod()
|
||||
{
|
||||
var type = AccessTools.TypeByName("CheatManager");
|
||||
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
|
||||
|
||||
var method = AccessTools.Method(type, "EKGOMPDGNEK", System.Type.EmptyTypes);
|
||||
if (method == null) throw new System.MissingMethodException("CheatManager.EKGOMPDGNEK() is gone - the scan moved or was renamed.");
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip()
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
var count = ++_skips;
|
||||
if (count <= 3 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatScan] skipped CheatManager.EKGOMPDGNEK() (#" + count
|
||||
+ ") - this is the pass behind the Obscured/winhttp.dll verdict, so no report and no shutdown follow from it.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatDetectionSuppressPatch
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_TYPE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var type = AccessTools.TypeByName("CheatManager");
|
||||
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
|
||||
|
||||
var found = new System.Collections.Generic.List<string>();
|
||||
var missing = new System.Collections.Generic.List<string>();
|
||||
foreach (var name in new[] { "OnObscuredTypeCheatDetected", "OnTimeCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
{
|
||||
var method = AccessTools.Method(type, name, System.Type.EmptyTypes);
|
||||
if (method == null)
|
||||
{
|
||||
missing.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
found.Add(name);
|
||||
yield return method;
|
||||
}
|
||||
|
||||
_guarded = found.Count;
|
||||
_unguarded = missing.Count == 0 ? null : string.Join(", ", missing);
|
||||
}
|
||||
|
||||
internal static int Guarded => _guarded;
|
||||
|
||||
internal static string Unguarded => _unguarded;
|
||||
|
||||
private static int _guarded;
|
||||
|
||||
private static string _unguarded;
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
var count = ++_skips;
|
||||
if (count <= 5 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatType] skipped CheatManager." + __originalMethod.Name + "() (#" + count
|
||||
+ ") - this is a verdict handler that ends in the dorm drop with FOKOIBODNAD and the shutdown behind it, so the room survives the detection.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatDormDropGuardPatch
|
||||
{
|
||||
private const string AntiCheatReason = "385120";
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _skips;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_DROP");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Reflection.MethodBase TargetMethod()
|
||||
{
|
||||
var type = AccessTools.TypeByName("CheatManager");
|
||||
if (type == null) throw new System.MissingMemberException("CheatManager could not be resolved.");
|
||||
|
||||
var method = AccessTools.Method(type, "FOKOIBODNAD", new[] { typeof(string) });
|
||||
if (method == null)
|
||||
throw new System.MissingMethodException("CheatManager.FOKOIBODNAD(string) is gone - the dorm drop moved or was renamed, so the guard is NOT armed.");
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(string __0)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
|
||||
if (string.IsNullOrEmpty(__0) || __0.IndexOf(AntiCheatReason, StringComparison.Ordinal) < 0)
|
||||
return true;
|
||||
|
||||
var count = ++_skips;
|
||||
if (count <= 5 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatDrop] skipped CheatManager.FOKOIBODNAD('" + __0 + "') (#" + count
|
||||
+ ") - the anti-cheat's own drop, so the player stays where they are instead of being sent to the dormroom mid-session.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class CheatManagerTracePatch
|
||||
{
|
||||
private const int PerMethodLimit = 4;
|
||||
private const int LineLimit = 90;
|
||||
|
||||
private static readonly System.Diagnostics.Stopwatch Clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
private static readonly System.Collections.Generic.Dictionary<string, int> Seen = new System.Collections.Generic.Dictionary<string, int>();
|
||||
private static int _lines;
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var outer = AccessTools.TypeByName("CheatManager");
|
||||
if (outer == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] CheatManager not resolvable - nothing traced.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var types = new System.Collections.Generic.List<System.Type> { outer };
|
||||
foreach (var nested in outer.GetNestedTypes(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
|
||||
types.Add(nested);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
yield return method;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly bool Enabled = IsEnabled();
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_CHEAT_TRACE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static void Trace(System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
try
|
||||
{
|
||||
if (_lines >= LineLimit) return;
|
||||
|
||||
var key = __originalMethod.DeclaringType.Name + "." + __originalMethod.Name;
|
||||
|
||||
Seen.TryGetValue(key, out var count);
|
||||
if (count >= PerMethodLimit) return;
|
||||
Seen[key] = count + 1;
|
||||
_lines++;
|
||||
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[CheatTrace] +" + Clock.ElapsedMilliseconds + "ms " + key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ExitTraceHelper
|
||||
{
|
||||
internal static string SafeStack()
|
||||
{
|
||||
try { return "\n" + Environment.StackTrace; } catch { return "\n<stack unavailable>"; }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EacGate
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static int _logCount;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_EAC_NEUTRALIZE");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
internal static System.Type Find(string fullName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var byName = System.Type.GetType(fullName + ", EasyAntiCheat.Client");
|
||||
if (byName != null) return byName;
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var forced = typeof(EasyAntiCheat.Client.Hydra.Runtime).Assembly;
|
||||
var direct = forced.GetType(fullName);
|
||||
if (direct != null) return direct;
|
||||
}
|
||||
catch { }
|
||||
|
||||
try { return AccessTools.TypeByName(fullName); } catch { return null; }
|
||||
}
|
||||
|
||||
private const int LogLimit = 8;
|
||||
|
||||
internal static void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (++_logCount > LogLimit) return;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class EacNativeClientStubPatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var type = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
|
||||
if (type == null)
|
||||
{
|
||||
EacGate.Log("[EacStub] Hydra.NativeClient not resolvable - EAC runtime left untouched.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var method in type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
if (method.Name == "Initialize")
|
||||
yield return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!EacGate.Enabled) return true;
|
||||
|
||||
EacGate.Log("[EacStub] skipped " + __originalMethod.DeclaringType + "." + __originalMethod.Name
|
||||
+ " - EasyAntiCheat's native client never starts, so its module scanner never runs.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class EacHealthQueryLiePatch
|
||||
{
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var native = EacGate.Find("EasyAntiCheat.Client.Hydra.NativeClient");
|
||||
if (native != null)
|
||||
foreach (var method in native.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
if (method.Name == "IsStartedWithEasyAntiCheat" && method.ReturnType == typeof(bool))
|
||||
yield return method;
|
||||
|
||||
var runtime = EacGate.Find("EasyAntiCheat.Client.Hydra.Runtime");
|
||||
if (runtime != null)
|
||||
foreach (var method in runtime.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
if ((method.Name == "IsActive" || method.Name == "get_Initialized") && method.ReturnType == typeof(bool))
|
||||
yield return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Lie(ref bool __result, System.Reflection.MethodBase __originalMethod)
|
||||
{
|
||||
if (!EacGate.Enabled) return true;
|
||||
|
||||
__result = true;
|
||||
EacGate.Log("[EacStub] " + __originalMethod.DeclaringType + "." + __originalMethod.Name + " answered true.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
[HarmonyPatch]
|
||||
internal static class ToolCollisionResetGuardPatch
|
||||
{
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_TOOL_RESET");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
return raw.Trim().ToLowerInvariant() is "0" or "off" or "false" or "no";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Reflection.MethodBase TargetMethod()
|
||||
{
|
||||
var method = AccessTools.Method(typeof(Tool), "ResetToDefaultCollisionLayer", Type.EmptyTypes);
|
||||
if (method == null)
|
||||
throw new MissingMethodException(
|
||||
"Tool.ResetToDefaultCollisionLayer() not found - the room crash guard is NOT armed.");
|
||||
return method;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Skip(Tool __instance)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
ToolCollisionLayerRestore.Restore(__instance);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Describe(Tool tool)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tool == null) return "<null tool>";
|
||||
var owner = tool.gameObject;
|
||||
return (owner == null) ? "<no gameObject>" : "'" + owner.name + "'";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return "<describe failed: " + ex.GetType().Name + ">";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ExitStateVetoPatch
|
||||
{
|
||||
internal static bool Enabled => _enabled;
|
||||
|
||||
private static readonly bool _enabled = IsEnabled();
|
||||
|
||||
private static int _blocks;
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("RADICADE_EXIT_VETO");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
raw = raw.Trim().ToLowerInvariant();
|
||||
return raw is "1" or "on" or "true" or "yes";
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
var property = AccessTools.PropertySetter(typeof(KJAMNFCDBIO), "JGKJEDKNFHN");
|
||||
if (property != null) yield return property;
|
||||
|
||||
var raw = AccessTools.Method(typeof(KJAMNFCDBIO), "EHPDJDJLPKM",
|
||||
new[] { typeof(KJAMNFCDBIO.CNNHHNMPLJE) });
|
||||
if (raw != null) yield return raw;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
private static bool Block(KJAMNFCDBIO.CNNHHNMPLJE __0)
|
||||
{
|
||||
if (!Enabled) return true;
|
||||
if (__0 == KJAMNFCDBIO.CNNHHNMPLJE.Running) return true;
|
||||
|
||||
var count = ++_blocks;
|
||||
var caller = count == 1 ? ExitTraceHelper.SafeStack() : string.Empty;
|
||||
if (count <= 3 || count % 25 == 0)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning(
|
||||
$"[ExitVeto] blocked AppExitState -> {__0} (block #{count}); the session keeps running. " +
|
||||
"This is the anti-cheat-driven exit, not a user quit - the veto is armed by RADICADE_EXIT_VETO=1, set it to 0 to disable." + caller);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>RadicadePatch</AssemblyName>
|
||||
<RootNamespace>RadicadePatch</RootNamespace>
|
||||
|
||||
<BepInExRoot Condition="'$(BepInExRoot)' == ''">C:\RadicadeBeta\BepInEx</BepInExRoot>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="$(BepInExRoot)\core\BepInEx.Core.dll;$(BepInExRoot)\core\BepInEx.Unity.IL2CPP.dll;$(BepInExRoot)\core\0Harmony.dll;$(BepInExRoot)\core\Il2CppInterop.Runtime.dll" Private="false" />
|
||||
<Reference Include="$(BepInExRoot)\interop\*.dll" Private="false" />
|
||||
<Compile Include="..\RadicadeEacBypass\EmbeddedTlsPatches.cs" Link="Shared\EmbeddedTlsPatches.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\NameserverConnectionPatches.cs" Link="Shared\NameserverConnectionPatches.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\NameserverRedirect.cs" Link="Shared\NameserverRedirect.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\ImageSignatureRequest.cs" Link="Shared\ImageSignatureRequest.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\HubSocketAuth.cs" Link="Shared\HubSocketAuth.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\WebSocketRedirectPatches.cs" Link="Shared\WebSocketRedirectPatches.cs" />
|
||||
|
||||
<Compile Include="..\RadicadeEacBypass\Diag.cs" Link="Shared\Diag.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\ResponseRepair.cs" Link="Shared\ResponseRepair.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\HttpTolerancePatches.cs" Link="Shared\HttpTolerancePatches.cs" />
|
||||
<Compile Include="..\RadicadeEacBypass\RealtimeDiagnostics.cs" Link="Shared\RealtimeDiagnostics.cs" />
|
||||
|
||||
<Compile Include="..\RadicadeEacBypass\VoiceChatRestrictionPatches.cs" Link="Shared\VoiceChatRestrictionPatches.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
|
||||
internal static class ToolCollisionLayerRestore
|
||||
{
|
||||
private static int _failures;
|
||||
|
||||
internal static void Restore(Tool tool)
|
||||
{
|
||||
if (tool == null) return;
|
||||
try
|
||||
{
|
||||
if (tool.MEDCLKFANNC) return;
|
||||
int layer = (int)tool.EHNBBIAIJIK;
|
||||
if (layer < 0 || layer > 31)
|
||||
throw new InvalidOperationException("Invalid default collision layer " + layer);
|
||||
|
||||
foreach (var collider in tool.GetComponentsInChildren<Collider>(true))
|
||||
{
|
||||
if (collider == null || collider.isTrigger) continue;
|
||||
if (collider.GetComponentInParent<Tool>() != tool) continue;
|
||||
var owner = collider.gameObject;
|
||||
if (!ShouldRestore(owner.layer, (int)tool.HeldCollisionLayer,
|
||||
(int)tool.EquippedCollisionLayer, (int)tool.AttachedCollisionLayer)) continue;
|
||||
if (owner.layer == layer) continue;
|
||||
owner.layer = layer;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (++_failures <= 5)
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[ToolGuard] collision-layer restore failed: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldRestore(int layer, int held, int equipped, int attached)
|
||||
{
|
||||
return layer == held || layer == equipped || layer == attached
|
||||
|| layer is 0 or 10 or 11 or 13 or 17 or 18 or 23 or 24 or 27;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using RecRoom;
|
||||
using RecRoom.ObjectModel;
|
||||
using UnityEngine;
|
||||
|
||||
[HarmonyPatch]
|
||||
internal static class UnboundPropFreezePatch
|
||||
{
|
||||
private static readonly bool _disabled = IsDisabled();
|
||||
|
||||
|
||||
private static int _alreadyInert;
|
||||
|
||||
private static bool _loggedNoRigidbody;
|
||||
|
||||
private static bool _loggedFailure;
|
||||
|
||||
private static readonly Type[] Companions = new Type[3]
|
||||
{
|
||||
typeof(EmbodiedObject),
|
||||
typeof(RbexEntity),
|
||||
typeof(TransformEntity)
|
||||
};
|
||||
|
||||
internal static bool Enabled => !_disabled;
|
||||
|
||||
private static bool IsDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
string environmentVariable = Environment.GetEnvironmentVariable("RADICADE_UNBOUND_PROP_FREEZE");
|
||||
if (string.IsNullOrWhiteSpace(environmentVariable))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool result;
|
||||
switch (environmentVariable.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "0":
|
||||
case "off":
|
||||
case "false":
|
||||
case "no":
|
||||
result = true;
|
||||
break;
|
||||
default:
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(RigidbodyEx), "Awake")]
|
||||
private static void Postfix(RigidbodyEx __instance)
|
||||
{
|
||||
if (!Enabled || __instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!IsBound(__instance))
|
||||
{
|
||||
Freeze(__instance);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_loggedFailure)
|
||||
{
|
||||
_loggedFailure = true;
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning((object)("[PropGuard] could not freeze an unbound prop (" + ex.GetType().Name + ": " + ex.Message + "); the prop is left exactly as the game built it."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBound(RigidbodyEx rigidbodyEx)
|
||||
{
|
||||
Type[] companions = Companions;
|
||||
foreach (Type type in companions)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (rigidbodyEx.GetComponent(Il2CppType.From(type)) != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Freeze(RigidbodyEx rigidbodyEx)
|
||||
{
|
||||
Rigidbody unityRigidbody = rigidbodyEx.unityRigidbody;
|
||||
if (unityRigidbody == null)
|
||||
{
|
||||
if (!_loggedNoRigidbody)
|
||||
{
|
||||
_loggedNoRigidbody = true;
|
||||
}
|
||||
try
|
||||
{
|
||||
rigidbodyEx.ClearUnityRigidbodyVelocity();
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (unityRigidbody.isKinematic)
|
||||
{
|
||||
_alreadyInert++;
|
||||
return;
|
||||
}
|
||||
unityRigidbody.isKinematic = true;
|
||||
unityRigidbody.velocity = Vector3.zero;
|
||||
unityRigidbody.angularVelocity = Vector3.zero;
|
||||
|
||||
}
|
||||
|
||||
private static string Describe(RigidbodyEx rigidbodyEx)
|
||||
{
|
||||
try
|
||||
{
|
||||
GameObject gameObject = ((Component)rigidbodyEx).gameObject;
|
||||
return (gameObject == null) ? "<no game object>" : ("'" + gameObject.name + "'");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<unnamed>";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
|
||||
internal static class VoiceChatBanFlagPatches
|
||||
{
|
||||
private static bool _armed;
|
||||
|
||||
internal static void Apply(Harmony harmony)
|
||||
{
|
||||
if (_armed) return;
|
||||
_armed = true;
|
||||
|
||||
Patch(harmony, "IsVoiceChatBanned", nameof(BannedPostfix));
|
||||
Patch(harmony, "AllowedToVoiceChat", nameof(AllowedPostfix));
|
||||
}
|
||||
|
||||
private static void Patch(Harmony harmony, string propertyName, string handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
|
||||
var getter = AccessTools.Method(typeof(SettingsModel), "get_" + propertyName)
|
||||
?? AccessTools.Property(typeof(SettingsModel), propertyName)?.GetGetMethod(true);
|
||||
if (getter == null)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[VoiceBanFlag] SettingsModel."
|
||||
+ propertyName + " has no getter in this build, so the Audio page keeps its own value.");
|
||||
return;
|
||||
}
|
||||
|
||||
harmony.Patch(getter, postfix: new HarmonyMethod(typeof(VoiceChatBanFlagPatches)
|
||||
.GetMethod(handler, BindingFlags.Static | BindingFlags.NonPublic)));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RadicadeEacBypassPlugin.LogSource.LogWarning("[VoiceBanFlag] could not patch SettingsModel."
|
||||
+ propertyName + ": " + ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BannedPostfix(ref bool __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!VoiceChatRestrictionPatches.RestrictionInForce) return;
|
||||
|
||||
__result = true;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void AllowedPostfix(ref bool __result)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!VoiceChatRestrictionPatches.RestrictionInForce) return;
|
||||
|
||||
__result = false;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0"?>
|
||||
|
||||
<Obfuscator>
|
||||
<Var name="InPath" value="bin/Release/net6.0" />
|
||||
<Var name="OutPath" value="bin/obfuscated" />
|
||||
|
||||
|
||||
<Var name="BepInExRoot" value="C:\RadicadeBeta\BepInEx" />
|
||||
<Var name="ExtraFrameworkFolders" value="$(BepInExRoot)\core;$(BepInExRoot)\interop" />
|
||||
|
||||
<Var name="KeepPublicApi" value="false" />
|
||||
<Var name="HidePrivateApi" value="true" />
|
||||
<Var name="HideStrings" value="true" />
|
||||
<Var name="UseUnicodeNames" value="false" />
|
||||
<Var name="ReuseNames" value="true" />
|
||||
<Var name="RenameProperties" value="true" />
|
||||
<Var name="RenameEvents" value="true" />
|
||||
<Var name="RenameFields" value="true" />
|
||||
<Var name="RegenerateDebugInfo" value="false" />
|
||||
<Var name="SuppressIldasm" value="true" />
|
||||
|
||||
|
||||
<Module file="$(InPath)/RadicadePatch.dll">
|
||||
|
||||
<SkipMethod name="Prepare" />
|
||||
<SkipMethod name="Cleanup" />
|
||||
<SkipMethod name="TargetMethod" />
|
||||
<SkipMethod name="TargetMethods" />
|
||||
<SkipMethod name="Prefix" />
|
||||
<SkipMethod name="Postfix" />
|
||||
<SkipMethod name="Transpiler" />
|
||||
<SkipMethod name="Finalizer" />
|
||||
<SkipMethod name="ReversePatch" />
|
||||
<SkipMethod name="ILManipulator" />
|
||||
|
||||
|
||||
<SkipMethod rx=".*(Prefix|Postfix|Transpiler|Finalizer)$" />
|
||||
<SkipMethod name="EnableDialog" />
|
||||
<SkipMethod name="GraphicEvent" />
|
||||
<SkipMethod name="IncomingDialog" />
|
||||
<SkipMethod name="NoteCollider" />
|
||||
<SkipMethod name="Repair" />
|
||||
<SkipMethod name="Swallow" />
|
||||
|
||||
|
||||
<SkipMethod name="Block" />
|
||||
<SkipMethod name="Bypass" />
|
||||
<SkipMethod name="Challenge" />
|
||||
<SkipMethod name="Lie" />
|
||||
<SkipMethod name="Ready" />
|
||||
<SkipMethod name="Skip" />
|
||||
<SkipMethod name="Trace" />
|
||||
<SkipMethod name="TraceAndBlock" />
|
||||
<SkipMethod name="Veto" />
|
||||
</Module>
|
||||
</Obfuscator>
|
||||
Reference in New Issue
Block a user