Initial RecNetPlugin

This commit is contained in:
Devin Zuczek
2026-07-08 17:11:58 -04:00
parent 6c9f4499e4
commit 326188c7a0
15 changed files with 1440 additions and 517 deletions
+20
View File
@@ -0,0 +1,20 @@
using HarmonyLib;
using Org.BouncyCastle.Crypto.Tls;
namespace RecNetPlugin.Patches;
/**
Disables TLS certificate pinning. Even though we connect over SSL it seems some certificates
might be pinned.
*/
public class DisableTLSPinning
{
[HarmonyPatch(typeof(LegacyTlsAuthentication), "NotifyServerCertificate")]
public class TlsPatch
{
private static bool Prefix()
{
return false;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
using HarmonyLib;
using RecRoom.AntiCheat;
using System.Text;
using Il2CppSystem;
namespace RecNetPlugin.Patches;
[HarmonyPatch]
public static class EACPatches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "FJLMLEPOKGE")]
private static bool IsReadyPatch(ref bool __result)
{
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "GenerateChallengeResponse")]
private static bool GenerateChallengeResponsePatch(string PGCINMIEBJP, ref string __result)
{
if (!string.IsNullOrEmpty(PGCINMIEBJP))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(PGCINMIEBJP));
else
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("nothing"));
return false;
}
}
+34
View File
@@ -0,0 +1,34 @@
using ExitGames.Client.Photon;
using HarmonyLib;
using Photon.Realtime;
namespace RecNetPlugin.Patches;
/**
Patches Photon to use the App IDs and server hostname/port specified in the plugin config.
*/
[HarmonyPatch(typeof(GPFPFDBGCEK), "AMOHMPKKGHL")]
public class PhotonPatches
{
[HarmonyPostfix]
private static void Postfix(ref AppSettings __result)
{
if (__result != null)
{
__result.AppIdRealtime = Plugin.AppIdRT.Value;
__result.AppIdVoice = Plugin.AppIdVoice.Value;
__result.AppIdChat = Plugin.AppIdChat.Value;
__result.FixedRegion = "us";
__result.UseNameServer = true;
__result.Protocol = ConnectionProtocol.Udp;
if (Plugin.EnableAdvancedSettings.Value)
{
__result.Server = Plugin.PhotonHostname.Value;
__result.Port = Plugin.PhotonPort.Value == 0
? 4533
: Plugin.PhotonPort.Value;
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using HarmonyLib;
namespace RecNetPlugin.Patches;
/**
* This allows the global-metadata.dat to be different on the client
* patched to allow a different modulus so we can sign images.
*/
[HarmonyPatch(typeof(JAPJPGNBMNM), "JOKECJKBJGD")]
public static class PromisePatch
{
public static bool Prefix(out HPHDJAFFHCN<JAPJPGNBMNM.AOFCCEACNNA> __result)
{
var result = JAPJPGNBMNM.AOFCCEACNNA.JGIHNLEFJEL();
var promise = HAAHJPGNIMD.NMOOLKAJDOC(result);
__result = promise;
return false;
}
}
+186
View File
@@ -0,0 +1,186 @@
using System;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
namespace RecNetPlugin.Patches;
/**
Intercept a variety of HTTP requests and rewrite them to point to our own custom server.
*/
public class SendRequestPatch
{
// Official name server host to redirect away from, swapped for the custom server.
private const string OfficialNameServer = "ns.rec.net";
// Skip when HTTP-logging so we don't spam the logs.
private static readonly string[] LogIgnoreSubstrings =
{
"/api/gamesight/event",
"/data/heartbeat",
"/identify",
"/httpapi",
"/data/event",
};
private static bool IsIgnoredForLogging(string url)
{
foreach (var s in LogIgnoreSubstrings)
if (url.Contains(s, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
// Cap logged bodies so a large response/request doesn't flood the log.
private const int MaxLoggedBodyLength = 1000;
private static string Truncate(string s)
{
if (string.IsNullOrEmpty(s) || s.Length <= MaxLoggedBodyLength)
return s;
return s.Substring(0, MaxLoggedBodyLength) + $"... <truncated {s.Length - MaxLoggedBodyLength} chars>";
}
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
public class ConnectToRecNetPatch
{
private static void Prefix(ref HTTPRequest request)
{
var debug = Plugin.Debug.Value && !IsIgnoredForLogging(request.Uri.AbsoluteUri);
if (debug)
{
var entityBody = request.GetEntityBody();
string body;
if (entityBody == null)
body = "<none>";
else if (IsBinaryContentType(request.GetFirstHeaderValue("content-type")) || LooksBinary(entityBody))
body = BinaryPreview(entityBody);
else
body = System.Text.Encoding.UTF8.GetString(entityBody);
Plugin.Log.LogInfo($"[HTTP] {request.MethodType} {request.Uri.AbsoluteUri} body={Truncate(body)}");
}
var host = request.Uri.Host;
if (host == OfficialNameServer)
{
// Redirect the nameserver lookup to the custom server, swapping only the host.
var newHost = new System.Uri(Plugin.ServerHostname.Value).Host;
var builder = new Il2CppSystem.UriBuilder(request.Uri) { Host = newHost };
request.Uri = builder.Uri;
if (debug)
Plugin.Log.LogInfo($"[HTTP] intercepted {host} -> {newHost}");
}
if (debug)
LogResponseWhenDone(request);
}
}
// Wraps the request's completion callback so we log the response (status + body) when it
// finishes, then forwards to the game's original callback. This is how we see *which*
// request comes back empty (RecNet throws "Response was empty" on a blank body).
private static void LogResponseWhenDone(HTTPRequest request)
{
try
{
var original = request.Callback;
var url = request.Uri.AbsoluteUri;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
(Action<HTTPRequest, HTTPResponse>)((req, resp) =>
{
if (resp == null)
Plugin.Log.LogWarning($"[HTTP] <- {url} NO RESPONSE (state={req.State})");
else
{
string text;
if (IsBinaryContentType(resp.GetFirstHeaderValue("content-type")))
text = "<binary>";
else
{
text = resp.DataAsText;
if (string.IsNullOrEmpty(text)) text = "<empty>";
}
var msg = $"[HTTP] <- {resp.StatusCode} {url} body={Truncate(text)}";
if (resp.StatusCode is >= 200 and < 300)
Plugin.Log.LogInfo(msg);
else
Plugin.Log.LogError(msg);
}
original?.Invoke(req, resp);
}));
}
catch (Exception e)
{
Plugin.Log.LogError($"[HTTP] failed to attach response logger: {e}");
}
}
// Content-Type prefixes/keywords we treat as textual; anything else is logged as <binary> so we
// don't dump image/asset bytes into the log.
private static readonly string[] TextContentTypes =
{
"text/", "application/json", "application/xml", "application/javascript",
"application/x-www-form-urlencoded", "+json", "+xml",
};
// True if the body is (probably) binary and shouldn't be logged as text. Defaults to text when
// there's no Content-Type, so we err toward logging rather than hiding.
private static bool IsBinaryContentType(string contentType)
{
if (string.IsNullOrEmpty(contentType)) return false;
foreach (var t in TextContentTypes)
if (contentType.Contains(t, StringComparison.OrdinalIgnoreCase))
return false;
return true;
}
// Render the leading bytes of a binary body as text so structured framing (e.g. multipart form
// boundaries and part headers) stays readable, while raw bytes are shown as \xNN escapes. Capped
// at MaxLoggedBodyLength since the interesting framing is at the front.
private static string BinaryPreview(byte[] data)
{
if (data.Length == 0) return "<binary empty>";
var sb = new System.Text.StringBuilder(MaxLoggedBodyLength + 32);
sb.Append("<binary ").Append(data.Length).Append(" bytes> ");
var i = 0;
// Cap on rendered length, not byte count: escapes expand a byte to 4 chars, so this keeps the
// preview near MaxLoggedBodyLength and avoids a second pass by Truncate at the log site.
for (; i < data.Length && sb.Length < MaxLoggedBodyLength; i++)
{
var b = data[i];
if (b == 0x09 || b == 0x0A || b == 0x0D || (b >= 0x20 && b < 0x7F))
sb.Append((char)b);
else
sb.Append("\\x").Append(b.ToString("x2"));
}
if (i < data.Length)
sb.Append($"... <truncated {data.Length - i} bytes>");
return sb.ToString();
}
// Content sniff for raw request bytes — the Content-Type header isn't reliably set at
// SendRequest time (e.g. multipart form bodies set it lazily, and the body still embeds the
// raw image), so look at the bytes: a NUL byte, or a high ratio of non-text control bytes in
// the first chunk, means it's binary (or binary-mixed like a multipart upload).
private static bool LooksBinary(byte[] data)
{
if (data.Length == 0) return false;
var sample = Math.Min(data.Length, 4096);
var nonText = 0;
for (var i = 0; i < sample; i++)
{
var b = data[i];
if (b == 0) return true;
// Control chars other than tab/newline/carriage-return.
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nonText++;
}
return nonText * 100 / sample > 10;
}
}