Files
2026-08-05 12:44:31 -04:00

284 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
namespace RecNetPlugin.Patches;
// Amplitude analytics: the client ships telemetry to Amplitude, which a self-hosted setup has no use
// for (and which leaks play data off-box). Prefix every event-logging entrypoint and swallow the call
// so nothing is ever queued, batched or sent.
//
// One knob for all telemetry, `[Analytics] Disable Telemetry`, default true — it gates this file plus
// BacktracePatch and UnityTelemetryPatch. Deliberately not split per vendor: nobody wants Amplitude
// gone but the collector alive, and four switches for one intention is four ways to be half-configured.
//
// Target resolution: AmplitudeAnalytics.AmplitudeAnalyticsClient in RecRoom.Analytics.Runtime.dll,
// concrete (it derives from SingletonMonoBehaviour<T>, so there is no abstract-interface dispatch
// trap here — see gotcha 3 in CLAUDE.md). All five Log* names are UNobfuscated in the 20230414 build.
// They are still strings, so a rename shows up only as a HarmonyX "Could not find method" in
// LogOutput.log, not as a build error — the per-method [AMPLITUDE] blocked log line below is the real
// proof a hook is live.
//
// Why all five: blocking LogEventAsync alone was not enough — a session's room_stats/perf_stats
// events still went out, and the LogEventAsync prefix never logged at all, so that entrypoint was
// never even called. Those events are the pre-serialized batch the client parks in the
// `pending_room_stats` PlayerPref (visible in the DUID-PROBE log), which points at
// LogSerializedEventAsync rather than LogEventAsync.
//
// Why the Log* prefixes alone STILL are not enough — and why `BlockAnalyticsUploadPatch` below is the
// part that actually stops the traffic: uploads to api2.amplitude.com kept showing up in mitmproxy
// with LogEventAsync/LogIdentifyAsync visibly blocked in LogOutput.log. The Log* methods only *queue*;
// the queue is persisted (`pending_room_stats`) and drained later by the client's own flush coroutines
// (Flush / AMEAMPDLJPN / PPOCFIHNKPP), which reach the network through the transport interface
// `FOMPBHDLPDO` — concrete impl `MHBPNDOGOLG` in RecNet.Runtime.dll, i.e. BestHTTP. So a batch queued
// in an earlier session ships on the next launch no matter what we do to the Log* doors. Blocking at
// the BestHTTP layer catches every path, present and future, and costs no obfuscated names.
[HarmonyPatch]
public static class AmplitudePatch
{
private static readonly HashSet<string> _loggedBlocked = new();
// Returns false to skip the original. Logs once per entrypoint so LogOutput.log shows which door
// the client actually used.
private static bool Block(string entrypoint)
{
if (_loggedBlocked.Add(entrypoint))
Plugin.Log.LogInfo($"[AMPLITUDE] analytics disabled — blocked {entrypoint}");
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogEventAsync")]
private static bool LogEventAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogEventAsync");
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogPrevSessionEventAsync")]
private static bool LogPrevSessionEventAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogPrevSessionEventAsync");
// The likely culprit for the room_stats/perf_stats batch — takes the already-serialized
// Dictionary<string, object> that gets parked in `pending_room_stats`.
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogSerializedEventAsync")]
private static bool LogSerializedEventAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogSerializedEventAsync");
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogIdentifyAsync")]
private static bool LogIdentifyAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogIdentifyAsync");
// The odd one out: static, and it returns a promise instead of void. Skipping it with a null
// __result would hand the caller something it will chain .Then() on, so we substitute an
// already-resolved promise — the call looks like it succeeded instantly. If we cannot build one,
// we let the original run rather than risk a null-deref at quit time.
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogOutOfSessionEvent")]
private static bool LogOutOfSessionEventPrefix(ref LAHBDKNMNHN __result)
{
if (!Plugin.DisableTelemetry.Value)
return true;
var resolved = ResolvedPromise();
if (resolved == null)
return true;
__result = resolved;
return Block("LogOutOfSessionEvent");
}
private static bool _promiseResolved;
private static MethodInfo _resolvedPromiseGetter;
// Finds the concrete Promise class's static `Resolved` property getter without hardcoding its
// obfuscated name. Among the static, 0-param property getters in RecRoom.Promises.Runtime that
// return the promise interface there are exactly two: the real (obfuscated) property getter and
// the compiler-generated `get_<Name>_k__BackingField`. The backing field may be null if the
// property initialises lazily, so we drop it by its compiler-generated name — which the
// obfuscator leaves alone — and keep the other one.
private static LAHBDKNMNHN ResolvedPromise()
{
if (!_promiseResolved)
{
_promiseResolved = true;
var candidates = typeof(LAHBDKNMNHN).Assembly.GetTypes()
.SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
.Where(m => m.ReturnType == typeof(LAHBDKNMNHN)
&& m.GetParameters().Length == 0
&& m.IsSpecialName
&& !m.Name.EndsWith("_k__BackingField"))
.ToList();
if (candidates.Count == 1)
_resolvedPromiseGetter = candidates[0];
else
Plugin.Log.LogWarning(
$"[AMPLITUDE] expected exactly one resolved-promise getter, found {candidates.Count} " +
"— LogOutOfSessionEvent will NOT be blocked");
}
if (_resolvedPromiseGetter == null)
return null;
return _resolvedPromiseGetter.Invoke(null, null) as LAHBDKNMNHN;
}
// Analytics hosts we refuse to talk to, matched two different ways because they identify
// themselves two different ways:
//
// AmplitudeDomains — a registrable domain plus everything under it, so api2.amplitude.com
// and api.eu.amplitude.com are both covered.
// CollectorLabelPrefixes — the *first* hostname label, for the telemetry collector that lives
// on whatever domain the deployment uses (datacollection.recflare.net,
// datacollection.rec.net, datacollection-eu.…). The domain varies, the
// label doesn't, so match on the label and stay deployment-agnostic.
//
// Split into separate lists because they're *matched* differently, not configured differently —
// one knob covers the lot.
private static readonly string[] AmplitudeDomains = { "amplitude.com" };
private static readonly string[] CollectorLabelPrefixes = { "datacollection" };
// Backtrace and Unity's perf-events don't normally reach BestHTTP at all — Backtrace goes through
// UnityWebRequest (see BacktracePatch) and Unity's is native (see UnityTelemetryPatch). They're
// listed here anyway because it costs a string comparison to be right if that ever changes, and
// because "the host block covers every host we don't want talked to" is easier to reason about
// than a list with holes in it. Only perf-events is named, not all of cloud.unity3d.com — the
// client uses other Unity services.
private static readonly string[] BacktraceDomains = { "backtrace.io" };
private static readonly string[] UnityTelemetryHosts = { "perf-events.cloud.unity3d.com" };
private static bool IsUnderAnyDomain(string host, string[] domains) =>
domains.Any(d => host.Equals(d, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase));
private static bool IsAmplitudeHost(string host) => IsUnderAnyDomain(host, AmplitudeDomains);
private static bool IsCollectorHost(string host)
{
var dot = host.IndexOf('.');
var firstLabel = dot < 0 ? host : host.Substring(0, dot);
return CollectorLabelPrefixes.Any(p => firstLabel.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
// One knob for the lot. The vendors are split into separate lists above because they're matched
// differently, not because they're configured differently.
private static bool IsBlockedHost(string host)
{
if (string.IsNullOrEmpty(host) || !Plugin.DisableTelemetry.Value)
return false;
return IsAmplitudeHost(host)
|| IsCollectorHost(host)
|| IsUnderAnyDomain(host, BacktraceDomains)
|| UnityTelemetryHosts.Any(h => host.Equals(h, StringComparison.OrdinalIgnoreCase));
}
// The part that actually stops the traffic — see the note at the top of the file. Prefixes the
// same BestHTTP entrypoint SendRequestPatch hooks (HTTPManager is BestHTTP's own type, so no
// obfuscated names are involved and this survives game upgrades) and, for analytics hosts, hands
// the caller a synthetic 200 instead of sending anything.
//
// Faking success rather than failure is deliberate: the transport resolves its promise, the flush
// coroutine considers the batch delivered, and the client clears `pending_room_stats` — so nothing
// accumulates and nothing retries. Failing the request instead would leave the batch queued and
// re-attempted every session.
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
public static class BlockAnalyticsUploadPatch
{
private static bool Prefix(HTTPRequest request, ref HTTPRequest __result)
{
// SendRequest returns the request it was handed; callers chain off it, so hand it back
// even though we never send it.
__result = request;
return !Drop(request);
}
// Second net, one layer down. Every SendRequest overload funnels into SendRequestImpl, and
// IL2CPP is free to inline the tiny SendRequest(HTTPRequest) body into its callers — a hook on
// it then never fires for those call sites (gotcha: a Harmony patch that loads clean can still
// never run). SendRequestImpl is the last managed-visible chokepoint before the connection, so
// anything that slipped past the hook above is caught here.
[HarmonyPatch(typeof(HTTPManager), "SendRequestImpl", [typeof(HTTPRequest)])]
public static class ImplPatch
{
private static bool Prefix(HTTPRequest request) => !Drop(request);
}
// True when the request was blocked (caller should skip the original).
private static bool Drop(HTTPRequest request)
{
if (!IsBlockedHost(request.Uri.Host))
return false;
// Once per host normally; every request under [Advanced] Debug, since "did this specific
// upload get dropped or did it slip past?" is exactly the question a mitmproxy trace
// raises, and a deduped line can't answer it.
if (Plugin.Debug.Value)
Plugin.Log.LogInfo($"[ANALYTICS] dropped {request.MethodType} {request.Uri.AbsoluteUri}");
else if (_loggedBlocked.Add("upload:" + request.Uri.Host))
Plugin.Log.LogInfo($"[ANALYTICS] dropping uploads to {request.Uri.Host}");
try
{
CompleteWithFakeSuccess(request);
}
catch (Exception e)
{
// Couldn't synthesize the response — still don't send. The request's callback never
// fires, so whatever promise the transport made stays pending; that's a stalled flush
// coroutine at worst, versus telemetry leaving the box.
Plugin.Log.LogWarning($"[ANALYTICS] blocked {request.Uri.Host} but could not fake a response: {e.Message}");
}
return true;
}
private static void CompleteWithFakeSuccess(HTTPRequest request)
{
var body = FakeBodyFor(request);
var response = new HTTPResponse(request, new Il2CppSystem.IO.MemoryStream(), false, false)
{
StatusCode = 200,
Message = "OK",
Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body)),
};
request.Response = response;
request.State = HTTPRequestStates.Finished;
// BestHTTP would normally fire this from HTTPManager's update loop a frame or more later.
// Firing it inline is safe here because the callback is assigned before SendRequest is
// called, and the promise it resolves already exists by then.
request.Callback?.Invoke(request, response);
}
// Whatever the endpoint would have said on a good day. Amplitude's real shapes are known:
// /identify answers with the literal "success", the v2 batch endpoint with a small JSON
// envelope. The collector's shape isn't known, so we fall back to `{"success":true}` — the
// envelope every first-party RecNet endpoint uses (it's what the real deviceId endpoint
// returns, see the DUID case study in CLAUDE.md) and a far better guess than an empty body,
// which the RecNet HTTP wrapper rejects outright with "Response was empty".
private static string FakeBodyFor(HTTPRequest request)
{
if (!IsAmplitudeHost(request.Uri.Host))
return "{\"success\":true}";
return request.Uri.AbsoluteUri.Contains("/identify", StringComparison.OrdinalIgnoreCase)
? "success"
: "{\"code\":200,\"events_ingested\":0,\"payload_size_bytes\":0,\"server_upload_time\":"
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "}";
}
}
}