14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is the native (C/Win32) redirector that points the Rec Room client at a self-hosted server
without any managed mod loader. It replaces the sibling ../recnet-patcher project (a BepInEx/
MelonLoader Harmony plugin) — both loaders fail on current Rec Room builds (BepInEx crashes in
il2cpp_init; the loader trips the anti-cheat memory-integrity scan). This build is loaded as a
version.dll proxy and hooks Winsock + il2cpp methods directly in native code. Read README.md for
the user-facing overview; this file is the stuff you only learn by getting burned.
Build & deploy
Rec Room / GameAssembly.dll is 64-bit — you must build x64. A 32-bit DLL silently fails to load.
The default VS dev shell is x86, and the PowerShell tool does not persist env vars between calls,
so the amd64 env import and the cmake/build must run in the same call, else you get an x86 DLL:
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat"
cmd /c "`"$vcvars`" amd64 >nul 2>&1 && set" | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { Set-Item -Path "Env:$($matches[1])" -Value $matches[2] } }
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release # add -DGAME_DIR="C:\Games\recflare-client-unstable" to deploy
cmake --build build
- Verify arch from the PE header after building: machine word at
(int32 @0x3C)+4must be 0x8664, not 0x014C.cmakerefuses to configure a non-64-bit toolchain (guard inCMakeLists.txt). - Toolchain: VS 2022 Community;
cmake/ninjaship with it. Installed Windows SDK is 10.0.19041.0; the VS-generator MSBuild probe can't find it without the VC env — another reason to use Ninja inside the imported amd64 env. - Build output is
build\version.dll(OUTPUT_NAME version,PREFIX "") and that is the only artifact — it is self-contained. It used to ship alongside aversion_orig.dll(a copy of the system DLL) that its exports statically forwarded to; that dependency was removed in f3eb296 in favour of runtime forwarding, so do not re-add a copy step for it.-DGAME_DIR=...copiesversion.dllinto the game folder. The copy fails while Rec Room is running (DLL locked) — close the game first.
Architecture
DllMain (src/dllmain.c) spawns one background thread — HookThread in
src/hooks/hook_manager.c — which gates on IsGameProcess(), installs the ws2_32 hooks inline, then
fans each il2cpp patch out onto its own thread, since they each wait independently on the runtime
coming up. Order matters only for the memcheck patch (started first, see 5). The pipeline:
-
Loader vector —
version.dllproxy (src/proxy/version_proxy.c).RecRoom.exeimportsVERSION.dllby name, and the loader searches the app dir before System32, so ourversion.dllloads very early (beforeUnityPlayer.dll). All 17 real exports are satisfied by localmy_*wrappers, aliased to the real names via#pragma comment(linker, "/export:NAME=my_NAME")(aliases, not PE forwarders — no dot in the target). Each wrapper lazilyLoadLibraryWs the genuine%SYSTEM32%\version.dllby absolute path (so the app-dir search can't loop back into us) and calls through.DllMainstarts the hook thread. To retarget atwinhttp.dllinstead, swap this file's export list for winhttp's. -
DNS host rewrite (
src/hooks/dns_hook.c, detoursws2_32!getaddrinfo). On an exact-match lookup it swaps the hostname (ns.rec.net→ns.recflare.net) and delegates to the realgetaddrinfo, so the client reaches the target's current IP. This alone is not sufficient — it only changes DNS resolution; SNI and the HTTPHost:header still sayns.rec.net. Kept as a safety net. -
HTTP host rewrite (
src/unity/http_rewrite.c) — the real fix. The alternate backend (ns.recflare.net) serves its own vhost/cert, so requests must carry that host in URL + SNI + Host. This replicates the managedSendRequestPatch: it waits for the il2cpp runtime, resolves the concrete staticBestHTTP.HTTPManager.SendRequest(HTTPRequest), readsreq.get_Uri().get_AbsoluteUri(), swaps the host, andreq.set_Uri(new System.Uri(...))before forwarding. This is a call-through hook (must run the original) so it depends on the trampoline indetour.c. -
TLS pinning bypass (
src/unity/ssl_patch.c). Redirecting to a mismatched cert fails the handshake; this resolves the concreteOrg.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication.NotifyServerCertificateand detours it to an accept-all no-op. Replace-only hook (never calls the original). -
Memory-integrity scan neutralizer (
src/unity/memcheck_patch.c) — the reason 3/4/6 are possible at all. A background scan hashesGameAssembly.dllcode against baked-in hashes; our inline patches change that memory, so boot dies "Launch validation failed. Is Rec Room installed correctly?". The scanner class is obfuscated and rotates every build, so it is matched by shape: the class inAssembly-CSharp.dllcarrying both aSystem.Threading.Threadand aCancellationTokenSourcefield. Its public/instance/0-param/non-void method is the scan entry; its return type is the promise the boot step awaits. We detour that entry (replace-only) to return an already-resolved promise, obtained byil2cpp_runtime_invokeon the promise type's static special-name 0-paramResolvedgetter (found by sweeping every image for a getter returning that exact class, skipping_k__BackingField). The getter is test-invoked before committing the detour — if it returns null or throws we skip the hook rather than hand boot a null promise.PatchMemoryIntegrityCheckis started first among the il2cpp patches: the boot step that awaits the scan can fire early and the reflection sweep takes ~700 ms, so it needs the head start. -
EAC neutralizer (
src/unity/eac_patch.c). Two replace-only hooks on the literalRecRoom.AntiCheat.EACManager(namespace+class are not obfuscated; the methods are):- Readiness → true. The real check needs EasyAntiCheat services that no longer exist. Matched by
shape: the sole static, 0-param,
bool-returning, non-special-name method (excluding special-name is what keeps property getters out). Hook returns 1 — note the native signature is(void *methodInfo), since a static il2cpp method still getsMethodInfo*in RCX. GenerateChallengeResponse(string)→base64(challenge),base64("nothing")for null/empty. Unobfuscated name, resolved directly.g_gcr_staticis read from the method flags because it decides whether the string arg arrives in RCX or RDX — get that wrong and you base64 athispointer.
Both are only safe because 5 has already neutralized the hash check.
- Readiness → true. The real check needs EasyAntiCheat services that no longer exist. Matched by
shape: the sole static, 0-param,
src/core/ = logger + JSON-ish config + process info (incl. IsGameProcess). src/utils/strings.c = host match/rewrite.
src/debug/ and connect_hook.c are logging stubs / the disabled connect hook. Config is
redirector.json next to RecRoom.exe (sample .example), parsed by a flat key-scan, not real
JSON — keep it flat, one object per rewrite.
Hard-won gotchas (read before touching hooks)
-
The detour engine has two modes; picking wrong corrupts code (
src/memory/detour.c).InstallDetour(target, hook, backup, outTrampoline):outTrampoline != NULL→ call-through: it length-decodes the prologue (decode/steal_len), copies whole instructions (≥14 bytes) into a trampoline with relocation fixups (RelocateInto), and NOP-pads. Use when the hook calls the original (DNS, HTTP).outTrampoline == NULL→ replace-only: a blind 14-byte overwrite. Only safe when the hook never calls through (SSL), because we jump away immediately so a torn trailing instruction is never executed. The original crash bug was a blind 14-byte copy on a call-through target:ws2_32!getaddrinfo's prologue has instruction boundaries at 3/7/11/15, so 14 bytes tore the 4thmovand the trampoline ran garbage → whichever thread called the original died. The symptom was subtle: the game booted (the first lookup runs on a Unity background thread that died silently) but the client's real API lookup later hit the same broken trampoline and its thread died before any request left the process.
-
The length decoder relocates two cases and bails on the rest.
decode()classifies each instruction:RK_RIPREL(rip-relative disp32,mod=00,rm=101) andRK_REL32(E8/E9) are relocated —AllocNearplaces the trampoline within ±2 GB of the target so the rewritten displacements still fit in int32, and each fixup is range-checked. rip is computed from the end of the whole instruction, past any trailing immediate.decodereturns 0 on a two-byte (0x0F) opcode or anything it doesn't model, andrel8branches are measured but flaggedRK_UNSUPPORTED(they'd need a rel8→rel32 rewrite); in all those casesInstallDetourrefuses the hook (logs it) rather than corrupt code. This is what unblocked the call-through hook onHTTPManager.SendRequest, whose prologue is the usual il2cpp class-init checkcmp byte [rip+disp], 0+jne— it now steals 16 bytes and relocates cleanly. ws2_32 stubs are position-independent and hook fine as-is (getaddrinfosteals 15). -
il2cpp method resolution. Resolve types by literal namespace+name across all loaded assemblies (
il2cpp_domain_get_assemblies→il2cpp_assembly_get_image→il2cpp_class_from_name— the last only searches the image you give it, so sweep). Get methods withil2cpp_class_get_method_from_name(argc counts declared params only). There is noil2cpp_method_get_pointerexport — read the compiled entry fromMethodInfooffset 0 (methodPointer,*(void**)method). Wait foril2cpp_domain_get()to be non-NULL (runtime init) before resolving, andil2cpp_thread_attachyour native thread before any metadata call. Interop DLLs name the typeIl2CppSystem.Uri, but the runtime metadata namespace is plainSystem/Uri. -
Framework names are stable; patch the concrete class.
SendRequest,get_Uri/set_Uri,AbsoluteUri,NotifyServerCertificate,HTTPManager,LegacyTlsAuthenticationare unobfuscated and have survived build changes — this is why these are the hook points. As in the managed project, hook the concrete impl, never an il2cpp interface. Verify signatures with Mono.Cecil against the interop in../recflare-client/BepInEx/interopwhen they drift (see the sibling../recnet-patcher/CLAUDE.mdfor the Cecil load snippet; that project's interop has the same types). -
version.dll loads into multiple processes — log per-PID. Our DLL loads into the game, the EasyAntiCheat launcher/bootstrap, and the crash handler. They previously shared
redirector.logopened with"w"and truncated each other (the EAC process, stuck forever inWaitForUnitybecauseUnityPlayer.dllnever loads there, buried the game's diagnostics under module dumps). The logger now writesredirector_<pid>.log, andWaitForUnityis time-bounded.HookThreadnow returns immediately unlessIsGameProcess()(src/core/process.c, basename ==RecRoom.exe) — that check sits beforeInitConsole/InitLogger, becauseAllocConsolein the crash-handler process was opening a second debug window on every launch. So onlyRecRoom.exewrites a log at all now; if you need diagnostics from a sibling process, move the gate to wrapInitConsolealone. -
The connect hook is intentionally disabled.
src/hooks/connect_hook.cblindly redirects all :443 traffic (would break Photon/CDN/telemetry).getaddrinfocovers the il2cpp DNS path surgically.gethostbynamealso has a latent self-recursion bug (callsreal_gethostbyname, not a trampoline) if ever installed. -
Rec Room's own names are obfuscated and rotate every build — match by shape, never by name. Gotcha 4's framework names are the exception; anything in
Assembly-CSharpis an 11-char scramble (NAEMGPMOPED,JMCKLNABHHJ) that differs next build, so never hard-code one you saw in a log. The two patches that need such a target (5, 6) locate it through the il2cpp reflection API by structure instead — field types, static-ness, param count, return type, special-name flag. Rules that follow from getting this wrong:- Log every candidate; warn on >1. The method searches count matches and log
WARNING N candidateswhen ambiguous (scan-start in 5, readiness in 6). The scanner class search in 5 does not — it logs each[MEMCHECK] scanner candidateand silently keeps the last. Real logs already show two, so that field signature is not unique and the patch is riding on ordering. It works today; treat it as the most fragile thing here, and read those lines before trusting a boot. - Never fall back to "close enough". Every resolver bails with a log line rather than hooking a guess — a wrong detour on a rotating target corrupts an unrelated method.
- Verify before committing an irreversible detour where you can (5 test-invokes the
Resolvedgetter first).
- Log every candidate; warn on >1. The method searches count matches and log
Inspecting the game
- Runtime: the per-PID log is the source of truth. Our tags:
[STATUS] [HOOK] [DETOUR] [DNS ...][REWRITE] [REDIRECT] [SSL] [HTTP] [MEMCHECK] [EAC]. Success =[HTTP] host rewrite installed on SendRequestthen[HTTP] https://ns.rec.net/... -> https://ns.recflare.net/...per request. A[DETOUR] ... refusing hookline means gotcha 2 — the decoder hit a prologue it won't relocate (0x0Fopcode,rel8branch, or an unmodelled opcode), and that patch is not active. - Static il2cpp: dump prologue bytes from
GameAssembly.dllby converting the logged runtimecode=address to an RVA (subtract the loggedGameAssembly.dll Base) and mapping RVA→file offset via the PE section headers. Method/field signatures: Mono.Cecil over the interop DLLs (see gotcha 4). - PowerShell here is Windows PowerShell 5.1 — no
?.; use explicit$x -eq $null. Avoid2>&1on native exes.