mirror of
https://github.com/djdevin/recnet-plugin.git
synced 2026-09-08 14:41:30 -07:00
Compare commits
9 Commits
20230414.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f42eb661 | |||
| ec11bd2f21 | |||
| 2a9414a88b | |||
| 591618af0f | |||
| f18542f61e | |||
| 2880e46ca7 | |||
| c086e3d3d4 | |||
| 1127727270 | |||
| 6d5f06c940 |
@@ -12,7 +12,7 @@ dotnet build -c Debug -p:GamePath="C:\Games\depots\471711\23191908"
|
|||||||
|
|
||||||
- `GamePath` points at the Rec Room install root. It's normally set in the gitignored
|
- `GamePath` points at the Rec Room install root. It's normally set in the gitignored
|
||||||
`GamePath.props` (see `GamePath.props.example`); the `-p:GamePath=...` override is handy for one-offs.
|
`GamePath.props` (see `GamePath.props.example`); the `-p:GamePath=...` override is handy for one-offs.
|
||||||
- The project references ~150 interop DLLs from `$(GamePath)\BepInEx\interop`. Those are generated by
|
- The project references ~300 interop DLLs from `$(GamePath)\BepInEx\interop`. Those are generated by
|
||||||
Il2CppInterop the first time the game runs under BepInEx — if they're missing, launch the game once.
|
Il2CppInterop the first time the game runs under BepInEx — if they're missing, launch the game once.
|
||||||
- A post-build `DeployPlugin` target copies `RecNetPlugin.dll` into `$(GamePath)\BepInEx\plugins\`.
|
- A post-build `DeployPlugin` target copies `RecNetPlugin.dll` into `$(GamePath)\BepInEx\plugins\`.
|
||||||
**The copy fails while Rec Room is running** (the DLL is locked) — that's an MSB3027 error, not a
|
**The copy fails while Rec Room is running** (the DLL is locked) — that's an MSB3027 error, not a
|
||||||
@@ -74,7 +74,7 @@ the new interop by signature**, which is what actually identifies the target:
|
|||||||
> that got overwritten on the next launch back to the names below — patching against it failed at load.
|
> that got overwritten on the next launch back to the names below — patching against it failed at load.
|
||||||
|
|
||||||
Renames observed in the **20230414 build** (`C:\Games\recflare-client`, Steam manifest
|
Renames observed in the **20230414 build** (`C:\Games\recflare-client`, Steam manifest
|
||||||
`3668280474894052876`):
|
`6426603215211043630`):
|
||||||
|
|
||||||
- `LEALBOODIEE.GBNKOFMAJPA` → `HPEENKELKDJ.MGKINLFMJLB`
|
- `LEALBOODIEE.GBNKOFMAJPA` → `HPEENKELKDJ.MGKINLFMJLB`
|
||||||
- `EACManager.IMMGELPFGCK` → `EACManager.MCFIOBHCFBB`
|
- `EACManager.IMMGELPFGCK` → `EACManager.MCFIOBHCFBB`
|
||||||
@@ -96,34 +96,129 @@ The client verifies images against an RSA public key whose modulus is a string l
|
|||||||
`global-metadata.dat`. Patching that literal is fragile; **don't**. Hook the framework instead.
|
`global-metadata.dat`. Patching that literal is fragile; **don't**. Hook the framework instead.
|
||||||
|
|
||||||
The decisive observation: the literal base64-decodes to **exactly 256 bytes and does not start with
|
The decisive observation: the literal base64-decodes to **exactly 256 bytes and does not start with
|
||||||
`0x30`**, so it is a *raw* 2048-bit modulus, not a DER/SPKI blob. The client therefore cannot hand it
|
`0x30`**, so it is a *raw* 2048-bit modulus, not a DER/SPKI blob. The client base64-decodes it and
|
||||||
to a key-import API — it has to base64-decode it and hand-build `RSAParameters { Modulus, Exponent }`.
|
hand-builds `RSAParameters`, then verifies with `mscorlib` RSA — plain unobfuscated names. Rather than
|
||||||
Those are plain `mscorlib` calls with unobfuscated names. Confirmed working at runtime: images load
|
touch the modulus, we just force the verify to succeed. Confirmed working at runtime: images load with
|
||||||
with no signing check.
|
no signing check.
|
||||||
|
|
||||||
`Patches/ImageSigningPatch.cs` hooks, all in `Il2Cppmscorlib.dll`:
|
`Patches/ImageSigningPatch.cs` hooks, all in `Il2Cppmscorlib.dll`:
|
||||||
|
|
||||||
| Hook | Purpose |
|
| Hook | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Convert.FromBase64String` | catches the literal regardless of which crypto stack consumes it (guarded by a length check first — it runs for every base64 decode in the game) |
|
| `RSACryptoServiceProvider.VerifyData` / `VerifyHash` ×2 | **the fix:** forces the verify to succeed |
|
||||||
| `RSACryptoServiceProvider.ImportParameters` | swaps the modulus in place (both keys are 2048-bit, so no realloc) |
|
|
||||||
| `RSACryptoServiceProvider.VerifyData` / `VerifyHash` ×2 | forces the verify to succeed |
|
|
||||||
|
|
||||||
Config lives in `[Signing]`. `Disable Signature Verification` defaults **true** — that's the shipped
|
Config lives in `[Signing]`, a single knob: `Disable Signature Verification` defaults **true** —
|
||||||
behaviour, since this setup doesn't use image signing. `Signing Modulus Override` is the secondary
|
that's the shipped behaviour, since this setup doesn't use image signing. The patch only *removes* the
|
||||||
path for a deployment that *does* want signed images (keeps real verification, against your keypair).
|
check (forces verify-true); it does not swap in a replacement key.
|
||||||
|
|
||||||
Two things to remember:
|
Two things to remember:
|
||||||
|
|
||||||
- **Blast radius:** forcing verify-true affects *all* mscorlib RSA verification, not just images.
|
- **Blast radius is the point, not a wart.** Forcing verify-true affects *all* mscorlib RSA
|
||||||
BestHTTP's TLS uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred
|
verification, not just images. That breadth is load-bearing — see the warning below. BestHTTP's TLS
|
||||||
from assembly layout, not proven. Keep it behind the config knob.
|
uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred from assembly
|
||||||
- **If it ever stops working:** the patch logs `[SIG] stock modulus seen at <hook>` on first sighting.
|
layout, not proven. Keep it behind the config knob.
|
||||||
No such line = verification moved to `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent
|
- **If it ever stops working:** images failing to load with the knob on means verification moved off
|
||||||
hooks there are `RsaKeyParameters..ctor(bool, BigInteger, BigInteger)` and the **concrete**
|
mscorlib RSA onto `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent hooks there are the
|
||||||
`RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3).
|
**concrete** `RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3).
|
||||||
- The stock modulus is hardcoded in the patch. If a future build re-rolls the key, the match silently
|
|
||||||
stops firing; that log line is how you'd notice.
|
## 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
|
## Inspecting the game
|
||||||
|
|
||||||
@@ -208,6 +303,7 @@ those are investigation tools, not normal config. See the patch files for detail
|
|||||||
`DUIDMismatchPatch` has three modes: `Simulate` → force true, `Suppress` → force false, neither →
|
`DUIDMismatchPatch` has three modes: `Simulate` → force true, `Suppress` → force false, neither →
|
||||||
pass through to the real check (needed to observe a genuinely corrupt stored value).
|
pass through to the real check (needed to observe a genuinely corrupt stored value).
|
||||||
|
|
||||||
**The diagnostic patches should not ship in a release build** — strip them (or at least confirm their
|
**The diagnostic patches ship** — the DUID hang is still unsolved, so the tooling stays in the build
|
||||||
knobs default false: `Simulate`, `Corrupt`, `Restore`, `DeviceId Response Override`) before cutting a
|
where affected users can turn it on. Before cutting a release, confirm their knobs still default
|
||||||
release. `Suppress DUID Mismatch` is the real fix and defaults **true**, so it stays on.
|
false (`Simulate`, `Corrupt`, `Restore`, `DeviceId Response Override`); an accidentally-true default
|
||||||
|
would break normal play. `Suppress DUID Mismatch` is the real fix and defaults **true**, so it stays on.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Lapis
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
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() + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,156 +1,27 @@
|
|||||||
using System;
|
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||||
using Il2CppSystem.Security.Cryptography;
|
using Il2CppSystem.Security.Cryptography;
|
||||||
using Convert = Il2CppSystem.Convert;
|
|
||||||
|
|
||||||
namespace RecNetPlugin.Patches;
|
namespace RecNetPlugin.Patches;
|
||||||
|
|
||||||
// Image signing: the client verifies images against an RSA public key whose modulus is a string
|
// Image signing: the client verifies images against an RSA public key whose modulus is a string
|
||||||
// literal in global-metadata.dat. Patching that literal is fragile, so we intervene at the framework
|
// literal in global-metadata.dat. Patching that literal is fragile, so we intervene at the framework
|
||||||
// level instead, between the literal and the verify.
|
// level instead, by forcing the mscorlib RSA verify to succeed.
|
||||||
//
|
//
|
||||||
// The stored value is 256 bytes once base64-decoded and does NOT start with 0x30, so it is a RAW
|
// One knob, see [Signing] in the .cfg:
|
||||||
// 2048-bit modulus, not a DER/SPKI blob. The client therefore has to base64-decode it and hand-build
|
|
||||||
// an RSAParameters { Modulus, Exponent }. Both of those steps are plain framework calls with
|
|
||||||
// unobfuscated names, which is why hooking here survives game rebuilds (unlike the obfuscated
|
|
||||||
// PromisePatch this replaces).
|
|
||||||
//
|
|
||||||
// Two knobs, see [Signing] in the .cfg:
|
|
||||||
// Disable Signature Verification -> THE FIX (default true). Forces the RSA verify to succeed, so
|
// Disable Signature Verification -> THE FIX (default true). Forces the RSA verify to succeed, so
|
||||||
// the modulus never has to match and unsigned images load. This
|
// the modulus never has to match and unsigned images load. This
|
||||||
// self-hosted setup does not use image signing.
|
// self-hosted setup does not use image signing.
|
||||||
// Signing Modulus Override -> secondary: swap in your own modulus and keep real verification,
|
|
||||||
// for a deployment that DOES want signed images. Ignored (well,
|
|
||||||
// redundant) while the verify is disabled.
|
|
||||||
//
|
//
|
||||||
// Both are belt-and-braces: we swap at Convert.FromBase64String (catches the value regardless of
|
// If images ever stop loading with this on, the client has moved verification off mscorlib RSA onto
|
||||||
// which crypto stack consumes it) AND at ImportParameters (catches it if the client uses some other
|
// BestHTTP.SecureProtocol.Org.BouncyCastle; the equivalent hooks there are the concrete
|
||||||
// base64 decoder). Whichever fires first wins; the second sees the already-swapped value and no-ops.
|
// RsaDigestSigner/PssSigner.VerifySignature (NOT the abstract ISigner "interface", which never
|
||||||
//
|
// dispatches).
|
||||||
// If the log never shows "[SIG] stock modulus seen", the client is not using mscorlib RSA at all —
|
|
||||||
// the fallback is BestHTTP.SecureProtocol.Org.BouncyCastle, where the equivalent hooks are
|
|
||||||
// RsaKeyParameters..ctor(bool, BigInteger, BigInteger) and the concrete RsaDigestSigner/PssSigner
|
|
||||||
// .VerifySignature (NOT the ISigner "interface", which is abstract and never dispatches).
|
|
||||||
[HarmonyPatch]
|
[HarmonyPatch]
|
||||||
public static class ImageSigningPatch
|
public static class ImageSigningPatch
|
||||||
{
|
{
|
||||||
// The stock 2048-bit public modulus baked into global-metadata.dat (base64, 344 chars).
|
|
||||||
private const string StockModulusBase64 =
|
|
||||||
"X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIP" +
|
|
||||||
"MaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJ" +
|
|
||||||
"AijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo" +
|
|
||||||
"9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ==";
|
|
||||||
|
|
||||||
private const int ModulusBytes = 256;
|
|
||||||
|
|
||||||
private static byte[] _stockModulus;
|
|
||||||
private static byte[] _override;
|
|
||||||
private static bool _overrideResolved;
|
|
||||||
private static bool _loggedSeen;
|
|
||||||
private static bool _loggedForced;
|
private static bool _loggedForced;
|
||||||
|
|
||||||
private static byte[] Stock => _stockModulus ??= System.Convert.FromBase64String(StockModulusBase64);
|
|
||||||
|
|
||||||
// Decoded lazily and cached, so a malformed config value is reported once instead of per call.
|
|
||||||
private static byte[] Override
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_overrideResolved)
|
|
||||||
return _override;
|
|
||||||
_overrideResolved = true;
|
|
||||||
|
|
||||||
var raw = Plugin.SigningModulusOverride.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(raw))
|
|
||||||
return _override = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = System.Convert.FromBase64String(raw.Trim());
|
|
||||||
if (bytes.Length != ModulusBytes)
|
|
||||||
{
|
|
||||||
Plugin.Log.LogError(
|
|
||||||
$"[SIG] 'Signing Modulus Override' decoded to {bytes.Length} bytes, expected {ModulusBytes} " +
|
|
||||||
"(a raw 2048-bit modulus). Ignoring it. Note this must be the bare modulus, NOT a PEM/DER key.");
|
|
||||||
return _override = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Plugin.Log.LogWarning("[SIG] signing modulus override active");
|
|
||||||
return _override = bytes;
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
|
||||||
Plugin.Log.LogError("[SIG] 'Signing Modulus Override' is not valid base64. Ignoring it.");
|
|
||||||
return _override = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsStock(Il2CppStructArray<byte> value)
|
|
||||||
{
|
|
||||||
if (value == null || value.Length != ModulusBytes)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var stock = Stock;
|
|
||||||
for (var i = 0; i < ModulusBytes; i++)
|
|
||||||
if (value[i] != stock[i])
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void NoteSeen(string where)
|
|
||||||
{
|
|
||||||
if (_loggedSeen)
|
|
||||||
return;
|
|
||||||
_loggedSeen = true;
|
|
||||||
Plugin.Log.LogWarning($"[SIG] stock modulus seen at {where}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choke point 1: the base64 decode of the literal. Guarded by a length check first, because this
|
|
||||||
// runs for every base64 decode in the game.
|
|
||||||
[HarmonyPrefix]
|
|
||||||
[HarmonyPatch(typeof(Convert), nameof(Convert.FromBase64String))]
|
|
||||||
private static bool FromBase64StringPrefix(string __0, ref Il2CppStructArray<byte> __result)
|
|
||||||
{
|
|
||||||
if (__0 == null || __0.Length != StockModulusBase64.Length || __0 != StockModulusBase64)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
NoteSeen("Convert.FromBase64String");
|
|
||||||
|
|
||||||
var replacement = Override;
|
|
||||||
if (replacement == null)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
var arr = new Il2CppStructArray<byte>(ModulusBytes);
|
|
||||||
for (var i = 0; i < ModulusBytes; i++)
|
|
||||||
arr[i] = replacement[i];
|
|
||||||
|
|
||||||
__result = arr;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choke point 2: the key import. Mutates the modulus in place — both are 2048-bit, so the array
|
|
||||||
// is already the right size and no reallocation is needed.
|
|
||||||
[HarmonyPrefix]
|
|
||||||
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.ImportParameters))]
|
|
||||||
private static void ImportParametersPrefix(RSAParameters __0)
|
|
||||||
{
|
|
||||||
var modulus = __0?.Modulus;
|
|
||||||
if (!IsStock(modulus))
|
|
||||||
return;
|
|
||||||
|
|
||||||
NoteSeen("RSACryptoServiceProvider.ImportParameters");
|
|
||||||
|
|
||||||
var replacement = Override;
|
|
||||||
if (replacement == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (var i = 0; i < ModulusBytes; i++)
|
|
||||||
modulus[i] = replacement[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forces EVERY mscorlib RSA verification to succeed, not just image signatures. BestHTTP's TLS
|
// Forces EVERY mscorlib RSA verification to succeed, not just image signatures. BestHTTP's TLS
|
||||||
// uses its own bundled BouncyCastle rather than mscorlib RSA, so this should not touch
|
// uses its own bundled BouncyCastle rather than mscorlib RSA, so this should not touch
|
||||||
// certificate validation — but it is a blunt instrument, so it stays behind a config knob rather
|
// certificate validation — but it is a blunt instrument, so it stays behind a config knob rather
|
||||||
|
|||||||
@@ -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<string> DeviceIdResponseOverride { get; private set; }
|
||||||
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
|
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
|
||||||
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
|
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
|
||||||
public static ConfigEntry<string> SigningModulusOverride { get; private set; }
|
public static ConfigEntry<bool> DisableTelemetry { get; private set; }
|
||||||
|
|
||||||
private static bool _corruptDone;
|
private static bool _corruptDone;
|
||||||
|
|
||||||
@@ -52,8 +52,13 @@ public class Plugin : BasePlugin
|
|||||||
DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone.");
|
DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone.");
|
||||||
DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set.");
|
DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set.");
|
||||||
|
|
||||||
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. Set false only if you actually want signed images, in which case use 'Signing Modulus Override' instead. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures.");
|
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.");
|
||||||
SigningModulusOverride = Config.Bind("Signing", "Signing Modulus Override", "", "Optional alternative to disabling verification: your own RSA public modulus, base64, RAW 2048-bit (256 bytes decoded) — NOT a PEM/DER key. When set, it is substituted for the modulus baked into global-metadata.dat and real verification still runs, so images stay signed with your keypair. Redundant while 'Disable Signature Verification' is true. Empty = leave the stock modulus alone.");
|
|
||||||
|
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);
|
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);
|
||||||
|
|
||||||
@@ -62,6 +67,9 @@ public class Plugin : BasePlugin
|
|||||||
|
|
||||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
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
|
// 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).
|
// container resolves for account creation / login (destroying it removes that service).
|
||||||
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
|
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
|
||||||
|
|||||||
@@ -2,15 +2,21 @@
|
|||||||
|
|
||||||
A [BepInEx 6](https://github.com/BepInEx/BepInEx) (IL2CPP) plugin that points the Rec Room client at a self-hosted / private server.
|
A [BepInEx 6](https://github.com/BepInEx/BepInEx) (IL2CPP) plugin that points the Rec Room client at a self-hosted / private server.
|
||||||
|
|
||||||
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk (except global-metadata.dat - needed for image signatures). The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning) that would otherwise reject a non-official server.
|
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk. The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning, image signature verification) that would otherwise reject a non-official server.
|
||||||
|
|
||||||
> ⚠️ This disables anti-cheat and certificate validation on the client. Use at your own risk.
|
> ⚠️ This disables anti-cheat, certificate validation, and RSA signature verification on the client. Use at your own risk.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
[<img width="100" height="100" alt="image" src="https://github.com/user-attachments/assets/f0b91aa3-49f5-4077-8eb9-5ae676888709" />](https://www.recflare.net)
|
||||||
|
|
||||||
|
This plugin powers [RecFlare](https://www.recflare.net) - an open source, cloud-native Rec Room server.
|
||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
Using BepInEx plugins may cause anti-virus scanners or Windows Defender to pick it up as a threat.
|
Using BepInEx plugins may cause anti-virus scanners or Windows Defender to pick it up as a threat.
|
||||||
|
|
||||||
If you don't trust the complied .DLL, you can build it yourself.
|
If you don't trust the compiled .DLL, you can build it yourself.
|
||||||
|
|
||||||
See https://github.com/djdevin/recnet-plugin#from-source
|
See https://github.com/djdevin/recnet-plugin#from-source
|
||||||
|
|
||||||
@@ -22,9 +28,10 @@ See https://github.com/djdevin/recnet-plugin#from-source
|
|||||||
| Photon override | `Patches/PhotonPatches.cs` | Replaces the Realtime / Voice / Chat App IDs (and optionally the Photon name server + port) with your own. |
|
| Photon override | `Patches/PhotonPatches.cs` | Replaces the Realtime / Voice / Chat App IDs (and optionally the Photon name server + port) with your own. |
|
||||||
| EAC bypass | `Patches/EACPatches.cs` | Forces EasyAntiCheat "ready" and stubs the challenge-response so the client connects without the official anti-cheat. |
|
| EAC bypass | `Patches/EACPatches.cs` | Forces EasyAntiCheat "ready" and stubs the challenge-response so the client connects without the official anti-cheat. |
|
||||||
| TLS bypass | `Patches/DisableTLSPinning.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
|
| TLS bypass | `Patches/DisableTLSPinning.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
|
||||||
| Promise stub | `Patches/PromisePatch.cs` | Allows custom global-metadata.dat files without the game crashing. |
|
| Image signing bypass | `Patches/ImageSigningPatch.cs` | Forces the mscorlib RSA verify to succeed, so images your server serves load without being signed by Rec Room's key. **On by default.** |
|
||||||
| CheatManager handling | `Plugin.cs` | Deactivates the in-game `CheatManager` (which would otherwise boot you from rooms) while keeping it resolvable for account creation / login. |
|
| CheatManager handling | `Plugin.cs` | Deactivates the in-game `CheatManager` (which would otherwise boot you from rooms) while keeping it resolvable for account creation / login. |
|
||||||
| DUID mismatch workaround | `Patches/DUIDMismatchPatch.cs` | Forces the device-id mismatch check to "no mismatch" so the Create Account hang (below) is skipped. **On by default**; no-op on healthy machines. |
|
| DUID mismatch workaround | `Patches/DUIDMismatchPatch.cs` | Forces the device-id mismatch check to "no mismatch" so the Create Account hang (below) is skipped. **On by default**; no-op on healthy machines. |
|
||||||
|
| DUID diagnostics | `Patches/DUIDProbePatch.cs`, `Patches/CorruptDUIDPatch.cs`, `Patches/DeviceIdResponsePatch.cs` | Investigation tooling for the hang: PlayerPrefs/DUID call logging, deliberately corrupting or restoring the stored id, and rewriting the `deviceId` response in flight. All off by default — see [Configuration](#configuration). |
|
||||||
|
|
||||||
## The Create Account / DUID hang
|
## The Create Account / DUID hang
|
||||||
|
|
||||||
@@ -62,16 +69,18 @@ _Looking for a custom RecNet server?_ Try https://github.com/djdevin/recflare
|
|||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
1. Download the game using https://github.com/SteamRE/DepotDownloader. The manifest ID is `7859140924515540835`.
|
1. Download the game using https://github.com/SteamRE/DepotDownloader. The manifest ID is `6426603215211043630` (the **20230414** build).
|
||||||
Example: `depotdownloader -app 471710 -depot 471711 -manifest 7859140924515540835`
|
Example: `depotdownloader -app 471710 -depot 471711 -manifest 6426603215211043630`
|
||||||
**You must use this specific version.**
|
**You must use this specific version.** Rec Room's type and method names are obfuscated and
|
||||||
3. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
|
re-rolled every build, so the patches only bind against the build they were written for.
|
||||||
|
2. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
|
||||||
|
3. Launch the game once so BepInEx generates its `config/` folder and the IL2CPP interop assemblies.
|
||||||
|
|
||||||
Alternatively, use the [RecFlare client](https://github.com/djdevin/recflare-client)
|
Alternatively, use the [RecFlare client](https://github.com/djdevin/recflare-client)
|
||||||
|
|
||||||
### From release
|
### From release
|
||||||
|
|
||||||
1. Download a release from [/releases](/releases)
|
1. Download a release from [Releases](https://github.com/djdevin/recnet-plugin/releases)
|
||||||
2. Drop the `.dll` file into `BepInEx/plugins/`
|
2. Drop the `.dll` file into `BepInEx/plugins/`
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
@@ -123,11 +132,9 @@ Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed:
|
|||||||
|
|
||||||
**[Signing]**
|
**[Signing]**
|
||||||
- `Disable Signature Verification` — stops the client checking that images are signed with Rec Room's
|
- `Disable Signature Verification` — stops the client checking that images are signed with Rec Room's
|
||||||
private key, so your own server can serve images. **On by default**; leave it alone unless you
|
private key, so your own server can serve images. **On by default**; leave it alone.
|
||||||
specifically want signed images.
|
> ⚠️ This forces **all** mscorlib RSA verification to pass, not just image signatures. TLS is
|
||||||
- `Signing Modulus Override` — only for setups that *do* want image signing: your own RSA public
|
> unaffected (BestHTTP uses its own bundled BouncyCastle).
|
||||||
modulus (base64, raw 2048-bit — not a PEM/DER key). Keeps real verification, against your keypair.
|
|
||||||
Leave empty otherwise.
|
|
||||||
|
|
||||||
**[Advanced]**
|
**[Advanced]**
|
||||||
- `Enabled Advanced Settings` — must be `true` to apply the custom Photon name server / port below.
|
- `Enabled Advanced Settings` — must be `true` to apply the custom Photon name server / port below.
|
||||||
@@ -148,7 +155,7 @@ tools** used to investigate the hang. Leave them at their defaults unless you're
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Plugin.cs` | Plugin entry point, config bindings, Harmony bootstrap |
|
| `Plugin.cs` | Plugin entry point, config bindings, Harmony bootstrap |
|
||||||
| `Patches/` | Harmony patches (HTTP, EAC, TLS, Photon, DUID) |
|
| `Patches/` | Harmony patches (HTTP, EAC, TLS, Photon, image signing, DUID) |
|
||||||
| `CLAUDE.md` | Developer notes: build gotchas, IL2CPP/interop caveats, and the full DUID-hang investigation |
|
| `CLAUDE.md` | Developer notes: build gotchas, IL2CPP/interop caveats, and the full DUID-hang investigation |
|
||||||
| `RecNetPlugin.csproj` | Build config + interop references (driven by `GamePath`), and the `DeployPlugin` post-build copy |
|
| `RecNetPlugin.csproj` | Build config + interop references (driven by `GamePath`), and the `DeployPlugin` post-build copy |
|
||||||
| `GamePath.props.example` | Template for your local `GamePath.props` |
|
| `GamePath.props.example` | Template for your local `GamePath.props` |
|
||||||
|
|||||||
Reference in New Issue
Block a user