mirror of
https://github.com/djdevin/recnet-plugin.git
synced 2026-09-08 06:31:29 -07:00
attempting to patch some more noise calls
This commit is contained in:
@@ -121,6 +121,105 @@ Two things to remember:
|
||||
mscorlib RSA onto `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent hooks there are the
|
||||
**concrete** `RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3).
|
||||
|
||||
## Analytics / telemetry
|
||||
|
||||
**One knob**, `[Analytics] Disable Telemetry`, default **true**, gating all three patch files below.
|
||||
One switch is the deliberate choice: nobody wants Amplitude gone but the collector alive, and per-vendor
|
||||
knobs are just more ways to end up half-configured.
|
||||
|
||||
**But they are not one mechanism.** Each vendor sends over a different stack, and *that*, not the
|
||||
hostname, is what decides how you block it — a host blocklist would be dead code for two of these four:
|
||||
|
||||
| Vendor | Patch file | Stack it sends over | How it's blocked |
|
||||
| --- | --- | --- | --- |
|
||||
| Amplitude | `AmplitudePatch.cs` | BestHTTP | `Log*` prefixes + host block on `amplitude.com` |
|
||||
| Data collector | `AmplitudePatch.cs` | BestHTTP | host block on first label `datacollection*` |
|
||||
| Backtrace | `BacktracePatch.cs` | **UnityWebRequest** | `BacktraceHttpClient.Post` ×4 |
|
||||
| Unity Analytics | `UnityTelemetryPatch.cs` | **native (neither)** | Unity's own opt-out properties — **doesn't work, see below** |
|
||||
|
||||
The collector is matched on the hostname's *first label*, not a fixed domain, so it stays right across
|
||||
deployments (`…recflare.net`, `…rec.net`). It's a first-party endpoint, hence its own knob — you may
|
||||
want Amplitude gone but the collector alive, or the reverse.
|
||||
|
||||
The collector is matched on the hostname's *first label*, not a fixed domain, so it stays right across
|
||||
deployments (`…recflare.net`, `…rec.net`). It's a first-party endpoint, hence its own knob — you may
|
||||
want Amplitude gone but the collector alive, or the reverse.
|
||||
|
||||
**Blocking the `Log*` entrypoints is not sufficient, and this is the trap.** `LogEventAsync`,
|
||||
`LogSerializedEventAsync`, `LogIdentifyAsync` etc. only *queue*; the queue is persisted to the
|
||||
`pending_room_stats` PlayerPref and drained later by the client's own flush coroutines. So a batch
|
||||
queued before the plugin existed (or during a session where a `Log*` door we didn't cover was used)
|
||||
still ships on the next launch, and mitmproxy keeps showing `api2.amplitude.com` even though
|
||||
LogOutput.log says `[AMPLITUDE] ... blocked LogEventAsync`. **Symptom-to-cause: prefixes visibly
|
||||
firing + traffic still leaving means you blocked the producer, not the sender.**
|
||||
|
||||
The send path, for the record: `AmplitudeAnalyticsClient` → transport interface `FOMPBHDLPDO`
|
||||
(`MAJANJBIDMF` / `KBECOPLKGHL`) → concrete impl **`MHBPNDOGOLG` in `RecNet.Runtime.dll`** → BestHTTP.
|
||||
Note `RecRoom.Analytics.Runtime.dll` has *no* assembly reference to BestHTTP/UnityWebRequest — the
|
||||
transport is injected, so grepping the analytics assembly for an HTTP type finds nothing.
|
||||
|
||||
So the actual block is at the BestHTTP layer: a second prefix on
|
||||
`HTTPManager.SendRequest(HTTPRequest)` (the same method `SendRequestPatch` hooks) that drops any
|
||||
request to a blocked host. Two properties worth keeping:
|
||||
|
||||
- **No obfuscated names.** `HTTPManager`/`HTTPRequest`/`HTTPResponse` are BestHTTP's own types, so
|
||||
this survives a game upgrade even though every name in the analytics path above will re-roll.
|
||||
- **It fakes a 200, not a failure.** We build an `HTTPResponse` (status 200, Amplitude's real body
|
||||
shapes: `success` for `/identify`, the `{"code":200,...}` envelope otherwise; `{"success":true}` for
|
||||
the collector, whose shape we don't know — an empty body would trip the RecNet wrapper's "Response
|
||||
was empty"), set `State = Finished`, and invoke `request.Callback` inline. The transport resolves its
|
||||
promise, the client considers the batch delivered and clears `pending_room_stats`. Failing the
|
||||
request instead would leave the batch queued and retried every session forever.
|
||||
|
||||
RudderStack (`get_OutOfSessionRudderStackKey`) and gamesight are *not* covered — add their hosts to
|
||||
`AmplitudeDomains` / `CollectorLabelPrefixes` if they need to go too.
|
||||
|
||||
### Backtrace (`submit.backtrace.io`) — UnityWebRequest, not BestHTTP
|
||||
|
||||
`Backtrace.Unity.dll` references `UnityEngine.UnityWebRequestModule` and nothing else HTTP-shaped, so
|
||||
the BestHTTP host block never sees it. Two things make this one easy: the SDK is a third-party package
|
||||
and therefore **unobfuscated** (no per-build churn to survive), and every submission it makes — crash
|
||||
reports, minidumps, metrics — funnels through the four **concrete** `BacktraceHttpClient.Post`
|
||||
overloads (`IBacktraceHttpClient` is the interface — gotcha 3).
|
||||
|
||||
The overloads split by *who sends the request*, which decides the patch shape:
|
||||
|
||||
- `void Post(url, jObject, onComplete)` — the SDK sends internally. Prefix + skip, then invoke
|
||||
`onComplete(200, false, "{}")` ourselves. Answering it matters: the metrics queue holds the batch
|
||||
until the callback reports success, so a silent skip means it retries forever.
|
||||
- `UnityWebRequest Post(…)` ×3 — builds the request and hands it back; **the caller** sends it
|
||||
(`yield return request.SendWebRequest()`). A prefix returning null gets dereferenced, so instead a
|
||||
*postfix* repoints the finished request at `http://127.0.0.1:1/` — nothing listens, so it fails
|
||||
connection-refused in microseconds with no packet leaving the box, and the SDK takes its ordinary
|
||||
offline path. Repointing beats rebuilding: the SDK keeps its own handlers and headers, so there's
|
||||
nothing to guess about what the caller dereferences next.
|
||||
|
||||
Not covered: `RecRoomNativeClient` installs a native crash handler, and a minidump it uploads on the
|
||||
launch after a hard crash never passes through managed code. A multipart minidump POST to
|
||||
submit.backtrace.io with the hooks visibly firing is that path.
|
||||
|
||||
### Unity Analytics / Performance Reporting (`perf-events.cloud.unity3d.com`) — ⚠️ UNSOLVED, and fine
|
||||
|
||||
**Confirmed not working on the 20230414 build; accepted as-is — don't re-litigate it.** The setters are
|
||||
refused, `enabled` reads back `True` on every attempt, and the uploads keep flowing. The other three
|
||||
`[Analytics]` knobs all confirmed dropping at runtime, and they account for the bulk of the traffic, so
|
||||
this one is noise-floor. If it ever has to go for real it needs a hosts-file/DNS block or a native
|
||||
hook — there is no managed lever. What follows is why, kept because the *approach* is right even
|
||||
though this build refuses it.
|
||||
|
||||
|
||||
`UnityEngine.Analytics.Analytics` and `PerformanceReporting` are thin managed shims over native engine
|
||||
code; the uploads happen inside the player, on no managed send path and over neither HTTP stack. What
|
||||
they do have is a documented opt-out, so `UnityTelemetryPatch` just sets it: `PerformanceReporting.
|
||||
enabled = false`, `Analytics.enabled = false`, `deviceStatsEnabled = false`, `limitUserTracking = true`.
|
||||
|
||||
Two traps, both handled there: these are native setters that can be **silently refused**, so the patch
|
||||
*reads the properties back* and only believes it worked if they read false — the `[UNITY-TELEMETRY]`
|
||||
log line is the proof, the assignment isn't. (That read-back is what caught this build refusing them;
|
||||
without it the knob would have looked like it worked.) And since "not initialised yet" was the leading
|
||||
theory for the refusal, it retries from `OnSceneLoaded`, capped at 5 attempts — which ruled that theory
|
||||
out, because all five read back `True`.
|
||||
|
||||
## Inspecting the game
|
||||
|
||||
Use **Mono.Cecil** for static metadata/signature checks (accessibility, abstract-ness, exact param
|
||||
|
||||
+171
-7
@@ -1,7 +1,11 @@
|
||||
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;
|
||||
|
||||
@@ -9,8 +13,9 @@ namespace RecNetPlugin.Patches;
|
||||
// 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, see [Analytics] in the .cfg:
|
||||
// Disable Amplitude Analytics -> default true; skip every AmplitudeAnalyticsClient.Log* method.
|
||||
// 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
|
||||
@@ -24,6 +29,15 @@ namespace RecNetPlugin.Patches;
|
||||
// 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
|
||||
{
|
||||
@@ -42,24 +56,24 @@ public static class AmplitudePatch
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogEventAsync")]
|
||||
private static bool LogEventAsyncPrefix() =>
|
||||
Plugin.DisableAmplitudeAnalytics.Value && Block("LogEventAsync");
|
||||
Plugin.DisableTelemetry.Value && Block("LogEventAsync");
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogPrevSessionEventAsync")]
|
||||
private static bool LogPrevSessionEventAsyncPrefix() =>
|
||||
Plugin.DisableAmplitudeAnalytics.Value && Block("LogPrevSessionEventAsync");
|
||||
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.DisableAmplitudeAnalytics.Value && Block("LogSerializedEventAsync");
|
||||
Plugin.DisableTelemetry.Value && Block("LogSerializedEventAsync");
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogIdentifyAsync")]
|
||||
private static bool LogIdentifyAsyncPrefix() =>
|
||||
Plugin.DisableAmplitudeAnalytics.Value && Block("LogIdentifyAsync");
|
||||
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
|
||||
@@ -69,7 +83,7 @@ public static class AmplitudePatch
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogOutOfSessionEvent")]
|
||||
private static bool LogOutOfSessionEventPrefix(ref LAHBDKNMNHN __result)
|
||||
{
|
||||
if (!Plugin.DisableAmplitudeAnalytics.Value)
|
||||
if (!Plugin.DisableTelemetry.Value)
|
||||
return true;
|
||||
|
||||
var resolved = ResolvedPromise();
|
||||
@@ -116,4 +130,154 @@ public static class AmplitudePatch
|
||||
|
||||
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() + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Backtrace.Unity.Json;
|
||||
using Backtrace.Unity.Model;
|
||||
using HarmonyLib;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Backtrace crash reporting — the uploads to submit.backtrace.io.
|
||||
//
|
||||
// This one does not go through BestHTTP, so `AmplitudePatch`'s host block never sees it:
|
||||
// `Backtrace.Unity.dll` references `UnityEngine.UnityWebRequestModule` and nothing else HTTP-shaped.
|
||||
// The whole SDK is unobfuscated (it's a third-party package, so no per-build name churn to survive),
|
||||
// and every submission it makes — crash reports, minidumps, metrics — funnels through the four
|
||||
// `BacktraceHttpClient.Post` overloads. That's the concrete class; `IBacktraceHttpClient` is the
|
||||
// interface and patching it would silently never run (gotcha 3 in CLAUDE.md).
|
||||
//
|
||||
// The two overload shapes need different treatment, because of who owns the send:
|
||||
//
|
||||
// void Post(url, jObject, onComplete) - fire-and-forget, the SDK sends internally. We skip it and
|
||||
// invoke the callback with a 200 ourselves.
|
||||
// UnityWebRequest Post(...) x3 - builds the request and hands it back; *the caller* sends it
|
||||
// (`yield return request.SendWebRequest()`). Skipping the
|
||||
// original would hand the caller a null to dereference, so
|
||||
// instead we let it build whatever it likes and repoint the
|
||||
// finished request at a black hole.
|
||||
//
|
||||
// Not covered: `RecRoomNativeClient` installs a native crash handler, and a minidump uploaded from
|
||||
// native code on the next launch never passes through here. If submit.backtrace.io still shows a
|
||||
// multipart minidump POST with everything below firing, that's the path it took.
|
||||
[HarmonyPatch]
|
||||
public static class BacktracePatch
|
||||
{
|
||||
// Loopback port 1: nothing listens there, so the send fails with connection-refused in
|
||||
// microseconds without a packet leaving the machine, and the SDK takes its ordinary offline path.
|
||||
private const string BlackHoleUrl = "http://127.0.0.1:1/blocked-by-recnet-plugin";
|
||||
|
||||
private static readonly HashSet<string> _loggedBlocked = new();
|
||||
|
||||
// Fire-and-forget path (metrics). The SDK sends this one itself, so skipping the original is
|
||||
// enough — but the callback has to be answered or the submission queue keeps the batch it just
|
||||
// handed us and retries it forever. (statusCode, isError, response): a 200 with no error is what
|
||||
// it waits for before clearing the batch.
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(BacktraceJObject), typeof(Il2CppSystem.Action<long, bool, string>))]
|
||||
private static bool PostWithCallbackPrefix(string __0, Il2CppSystem.Action<long, bool, string> __2)
|
||||
{
|
||||
if (!Plugin.DisableTelemetry.Value)
|
||||
return true;
|
||||
|
||||
LogBlocked(__0);
|
||||
__2?.Invoke(200, false, "{}");
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(BacktraceJObject))]
|
||||
private static void PostJObjectPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(string),
|
||||
typeof(Il2CppSystem.Collections.Generic.IEnumerable<string>),
|
||||
typeof(Il2CppSystem.Collections.Generic.IDictionary<string, string>))]
|
||||
private static void PostJsonPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(Il2CppSystem.Collections.Generic.List<IMultipartFormSection>))]
|
||||
private static void PostFormPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
|
||||
|
||||
// Leave the request the SDK built exactly as it is — handlers, headers, body — and change only
|
||||
// where it points. Rebuilding it ourselves would mean guessing which handlers the caller goes on
|
||||
// to dereference; this way the coroutine keeps its shape and just gets an error back.
|
||||
private static void Neuter(string url, UnityWebRequest request)
|
||||
{
|
||||
if (!Plugin.DisableTelemetry.Value || request == null)
|
||||
return;
|
||||
|
||||
LogBlocked(url);
|
||||
request.url = BlackHoleUrl;
|
||||
}
|
||||
|
||||
// Once per host normally, every submission under [Advanced] Debug — same rule as the analytics
|
||||
// host block, and for the same reason: a deduped line can't answer "did *this* upload get
|
||||
// dropped?" when you're staring at a proxy trace.
|
||||
private static void LogBlocked(string url)
|
||||
{
|
||||
if (Plugin.Debug.Value)
|
||||
{
|
||||
Plugin.Log.LogInfo($"[BACKTRACE] dropped submission to {url}");
|
||||
return;
|
||||
}
|
||||
|
||||
var host = HostOf(url);
|
||||
if (_loggedBlocked.Add(host))
|
||||
Plugin.Log.LogInfo($"[BACKTRACE] telemetry disabled — dropping submissions to {host}");
|
||||
}
|
||||
|
||||
private static string HostOf(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Uri(url).Host;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Unity's own telemetry — the uploads to perf-events.cloud.unity3d.com.
|
||||
//
|
||||
// !! THIS DOES NOT WORK on the 20230414 build, and that is a known, accepted limitation — don't spend
|
||||
// another afternoon on it. Confirmed at runtime: the setters below are refused, `enabled` reads back
|
||||
// True on all five attempts, and perf-events uploads keep flowing. It's left in because it costs
|
||||
// nothing, is the correct thing to do if a future build stops refusing, and the read-back logs the
|
||||
// truth either way rather than pretending. The parts of `Disable Telemetry` that carry the actual win
|
||||
// are Amplitude, the collector and Backtrace — all confirmed dropping — and they take out the bulk of
|
||||
// the noise. If perf-events ever has to go for real, it needs a hosts-file/DNS block or a native hook;
|
||||
// there is no managed lever.
|
||||
//
|
||||
// There is nothing to hook here, and that's the point: `UnityEngine.Analytics.Analytics` and
|
||||
// `PerformanceReporting` are thin managed shims over native engine code, and the uploads happen inside
|
||||
// the player, not on any managed send path a Harmony prefix could sit on. Blocking this one at the HTTP
|
||||
// layer is equally hopeless — it never touches BestHTTP or UnityWebRequest. What it *does* have is a
|
||||
// documented opt-out, so we flip the switches at startup and read them back.
|
||||
//
|
||||
// Performance Reporting is the exception/crash reporter, Analytics is the event stream; both feed
|
||||
// perf-events, so both go off. `limitUserTracking` and `deviceStatsEnabled` cover the case where
|
||||
// something re-enables the event stream behind our back — with those set, what it can collect is
|
||||
// nothing worth sending.
|
||||
internal static class UnityTelemetryPatch
|
||||
{
|
||||
// Applied from Plugin.Load and again on each scene load until it sticks — these are native
|
||||
// properties whose setters can be refused (service not initialised yet, build flags), so
|
||||
// "set it once at load and assume" is exactly how this silently does nothing.
|
||||
private const int MaxAttempts = 5;
|
||||
|
||||
private static bool _done;
|
||||
private static int _attempts;
|
||||
|
||||
public static void Apply()
|
||||
{
|
||||
if (_done || !Plugin.DisableTelemetry.Value || _attempts >= MaxAttempts)
|
||||
return;
|
||||
|
||||
_attempts++;
|
||||
|
||||
try
|
||||
{
|
||||
UnityEngine.Analytics.PerformanceReporting.enabled = false;
|
||||
UnityEngine.Analytics.Analytics.enabled = false;
|
||||
UnityEngine.Analytics.Analytics.deviceStatsEnabled = false;
|
||||
UnityEngine.Analytics.Analytics.limitUserTracking = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// No _done: a later scene load gets another go, up to MaxAttempts.
|
||||
if (_attempts >= MaxAttempts)
|
||||
Plugin.Log.LogWarning($"[UNITY-TELEMETRY] gave up flipping the opt-out switches after {_attempts} attempts: {e.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// The read-back is the proof, not the assignment above. A refused setter is silent.
|
||||
var perf = UnityEngine.Analytics.PerformanceReporting.enabled;
|
||||
var analytics = UnityEngine.Analytics.Analytics.enabled;
|
||||
|
||||
_done = !perf && !analytics;
|
||||
|
||||
if (_done)
|
||||
Plugin.Log.LogInfo(
|
||||
$"[UNITY-TELEMETRY] disabled — PerformanceReporting.enabled={perf} Analytics.enabled={analytics} " +
|
||||
$"deviceStats={UnityEngine.Analytics.Analytics.deviceStatsEnabled} " +
|
||||
$"limitUserTracking={UnityEngine.Analytics.Analytics.limitUserTracking}");
|
||||
else if (_attempts >= MaxAttempts)
|
||||
// Info, not a warning: this is the known outcome on this build (see the header), not a
|
||||
// fault to go chasing. It stays logged so a build that *does* accept the switches is
|
||||
// visible as a change rather than a surprise.
|
||||
Plugin.Log.LogInfo(
|
||||
$"[UNITY-TELEMETRY] switches refused after {_attempts} attempts — " +
|
||||
$"PerformanceReporting.enabled={perf} Analytics.enabled={analytics}. " +
|
||||
"Known limitation: perf-events.cloud.unity3d.com uploads continue. The rest of Disable Telemetry is unaffected.");
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public class Plugin : BasePlugin
|
||||
public static ConfigEntry<string> DeviceIdResponseOverride { get; private set; }
|
||||
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
|
||||
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
|
||||
public static ConfigEntry<bool> DisableAmplitudeAnalytics { get; private set; }
|
||||
public static ConfigEntry<bool> DisableTelemetry { get; private set; }
|
||||
|
||||
private static bool _corruptDone;
|
||||
|
||||
@@ -54,7 +54,11 @@ public class Plugin : BasePlugin
|
||||
|
||||
DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures — that breadth is deliberate, see CLAUDE.md.");
|
||||
|
||||
DisableAmplitudeAnalytics = Config.Bind("Analytics", "Disable Amplitude Analytics", true, "Drop every AmplitudeAnalyticsClient.Log* call (ON by default) — LogEventAsync, LogPrevSessionEventAsync, LogSerializedEventAsync, LogIdentifyAsync and LogOutOfSessionEvent — so the client never ships telemetry events to Amplitude. A self-hosted server has no use for them. Set false to let analytics through.");
|
||||
DisableTelemetry = Config.Bind("Analytics", "Disable Telemetry", true, "Stop the client reporting to third-party telemetry services (ON by default). Covers: Amplitude analytics (every AmplitudeAnalyticsClient.Log* call plus any upload to amplitude.com, so batches queued in earlier sessions can't be flushed later); the data-collection endpoint (any host whose name starts with 'datacollection', e.g. datacollection.recflare.net); and Backtrace crash reports, minidumps and metrics to submit.backtrace.io. Blocked uploads get a synthetic 200 so the client carries on as if they had been accepted. It also asks Unity's own Analytics and Performance Reporting to switch off, but that part is KNOWN NOT TO WORK on this game build — those send from native engine code and the opt-out is refused, so perf-events.cloud.unity3d.com uploads continue; see the [UNITY-TELEMETRY] line in LogOutput.log. Not covered: RudderStack, gamesight, and minidumps sent by the native crash handler on the launch after a hard crash. Set false to let all of it through.");
|
||||
|
||||
// Not a patch — Unity's telemetry has a real opt-out, so we just set it. Retried from
|
||||
// OnSceneLoaded until it takes, since the native setters can refuse this early.
|
||||
Patches.UnityTelemetryPatch.Apply();
|
||||
|
||||
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);
|
||||
|
||||
@@ -63,6 +67,9 @@ public class Plugin : BasePlugin
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
// No-op once the switches have stuck; must run before the early return below.
|
||||
Patches.UnityTelemetryPatch.Apply();
|
||||
|
||||
// CheatManager boots us out of rooms when it runs, but it's ALSO the DUID service the DI
|
||||
// container resolves for account creation / login (destroying it removes that service).
|
||||
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
|
||||
|
||||
Reference in New Issue
Block a user