Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/bin/
/obj/
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<BepInExCore Condition="'$(BepInExCore)' == ''">C:\RecRoom\2023-08-29T06-53-33Z\BepInEx\core</BepInExCore>
</PropertyGroup>
<ItemGroup>
<Reference Include="Iced"><HintPath>$(BepInExCore)\Iced.dll</HintPath></Reference>
<Reference Include="Mono.Cecil">
<HintPath>$(BepInExCore)\Mono.Cecil.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+134
View File
@@ -0,0 +1,134 @@
using System.Reflection;
using System.Reflection.PortableExecutable;
using Iced.Intel;
// Find the il2cpp methods that reference a given string literal.
//
// Neither the bytes nor the index of a literal appear directly in the code: on
// metadata v27+ the code loads a pointer from a metadata-usage slot, and statically
// that slot holds the encoded usage token `(type << 29) | (index << 1) | 1` with
// type 5 for string literals. So the search is:
// 1. resolve the literal's index from its text,
// 2. scan the image for the 8-byte token (this finds the slots),
// 3. scan the executable sections for instructions whose rip-relative operand
// lands on one of those slots. The displacement is the tail of such an
// instruction, so a cheap arithmetic pre-check narrows candidates and Iced
// then confirms by decoding from the candidate's start.
internal static class LiteralFinder
{
const ulong TokenBase = 5UL << 29;
static object Prop(object o, string n) => o.GetType().GetProperty(n)!.GetValue(o)!;
sealed class LineOutput : FormatterOutput
{
public string Value = "";
public override void Write(string text, FormatterTextKind kind) => Value += text;
}
public static void Run(object main, object metadata, string binaryPath, string needle)
{
var defs = (System.Collections.IEnumerable)metadata.GetType().GetField("methodDefs")!.GetValue(metadata)!;
var methods = new List<(object Method, ulong Address)>();
foreach (var m in defs.Cast<object>())
{
var address = (ulong)Prop(m, "MethodPointer");
if (address != 0) methods.Add((m, address));
}
methods.Sort((a, b) => a.Address.CompareTo(b.Address));
var table = metadata.GetType().GetField("stringLiterals",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!.GetValue(metadata) as Array;
var getLiteralText = metadata.GetType().GetMethod("GetStringLiteralFromIndex")!;
var tokens = new List<ulong>();
for (int i = 0; i < (table?.Length ?? 0); i++)
{
var text = (string)getLiteralText.Invoke(metadata, new object[] { (uint)i })!;
if (!text.Contains(needle, StringComparison.Ordinal)) continue;
// Both low-bit spellings resolve to the same index downstream.
tokens.Add(TokenBase | ((ulong)i << 1) | 1);
tokens.Add(TokenBase | ((ulong)i << 1));
Console.WriteLine($" literal #{i} = \"{text}\" token 0x{TokenBase | ((ulong)i << 1):X}");
}
if (tokens.Count == 0)
{
Console.WriteLine($"no literal contains \"{needle}\"");
return;
}
var bytes = File.ReadAllBytes(binaryPath);
using var pe = new PEReader(new MemoryStream(bytes));
var imageBase = pe.PEHeaders.PEHeader!.ImageBase;
// 1. slots: 8-byte tokens, so only probe 8-byte aligned positions.
var slots = new List<(string Section, ulong Address, ulong Value)>();
foreach (var section in pe.PEHeaders.SectionHeaders)
{
var size = Math.Min(section.SizeOfRawData, Math.Max(0, bytes.Length - section.PointerToRawData));
for (int i = section.PointerToRawData; i < section.PointerToRawData + size - 8; i += 8)
{
var value = BitConverter.ToUInt64(bytes, i);
if (!tokens.Contains(value)) continue;
var slot = imageBase + (ulong)section.VirtualAddress + (ulong)(i - section.PointerToRawData);
slots.Add((section.Name, slot, value));
Console.WriteLine($" slot 0x{slot:X} ({section.Name}, raw 0x{i:X}) = 0x{value:X}");
}
}
if (slots.Count == 0)
{
Console.WriteLine("no metadata-usage slot found");
return;
}
var slotSet = slots.Select(s => s.Address).ToHashSet();
// 2. references: pre-check the displacement, confirm by decoding from the start.
var formatter = new IntelFormatter();
int hits = 0, candidates = 0;
foreach (var section in pe.PEHeaders.SectionHeaders.Where(s => (s.SectionCharacteristics & SectionCharacteristics.MemExecute) != 0))
{
var rawToVa = (long)imageBase + section.VirtualAddress - section.PointerToRawData;
var size = Math.Min(section.SizeOfRawData, Math.Max(0, bytes.Length - section.PointerToRawData));
for (int i = section.PointerToRawData; i < section.PointerToRawData + size - 4; i++)
{
var disp = BitConverter.ToInt32(bytes, i);
var target = (ulong)((long)i + 4 + rawToVa + disp);
if (!slotSet.Contains(target)) continue;
candidates++;
for (int back = 0; back <= 7; back++)
{
var candidateRaw = i - back;
if (candidateRaw < section.PointerToRawData) break;
var reader = new ByteArrayCodeReader(bytes, candidateRaw, Math.Min(16, bytes.Length - candidateRaw));
var decoder = Decoder.Create(64, reader);
decoder.IP = (ulong)((long)candidateRaw + rawToVa);
decoder.Decode(out var instruction);
if (instruction.IsInvalid || !instruction.IsIPRelativeMemoryOperand) continue;
if (instruction.IPRelativeMemoryAddress != target) continue;
hits++;
var method = Nearest(methods, instruction.IP);
var name = method == null
? "<none>"
: Prop(Prop(method, "DeclaringType"), "Name") + "::" + Prop(method, "Name");
var offset = method == null ? 0 : instruction.IP - (ulong)Prop(method, "MethodPointer");
var output = new LineOutput();
formatter.Format(instruction, output);
Console.WriteLine($" REF 0x{instruction.IP:X} -> slot 0x{target:X} in {name} +0x{offset:X} [{output.Value}]");
break;
}
}
}
Console.WriteLine($"slots {slots.Count}; displacement candidates {candidates}; confirmed references {hits}");
}
static object? Nearest(List<(object Method, ulong Address)> methods, ulong address)
{
object? best = null;
foreach (var (method, addr) in methods)
{
if (addr > address) break;
best = method;
}
return best;
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Reflection;
using System.Collections;
using System.Reflection.PortableExecutable;
using Iced.Intel;
internal static class NativeInspect
{
static object Prop(object o, string n) => o.GetType().GetProperty(n)!.GetValue(o)!;
static string Label(object m) => m == null ? "<native>" : Prop(Prop(m,"DeclaringType"),"Name") + "::" + Prop(m,"Name");
public static void Run(Type main, object metadata, string binary, string typeName, string methodName)
{
var methods = ((IEnumerable)metadata.GetType().GetField("methodDefs")!.GetValue(metadata)!).Cast<object>()
.Select(m => (Method:m, Address:(ulong)Prop(m,"MethodPointer"))).Where(m=>m.Address!=0).OrderBy(m=>m.Address).ToArray();
// An address target (decimal or 0x hex) disassembles that instruction window directly.
var raw = typeName.Replace("0x", "").Trim();
if (raw.Length > 4 &&
(ulong.TryParse(raw, out var fixedAddress) ||
ulong.TryParse(raw, System.Globalization.NumberStyles.HexNumber, null, out fixedAddress)))
{
DisassembleOne(main, metadata, binary, fixedAddress, methods);
return;
}
var bytes=File.ReadAllBytes(binary);
using var pe=new PEReader(new MemoryStream(bytes));
ulong imageBase=pe.PEHeaders.PEHeader!.ImageBase;
int Raw(ulong address) { var rva=address-imageBase; var s=pe.PEHeaders.SectionHeaders.First(s=>rva>=(ulong)s.VirtualAddress && rva<(ulong)(s.VirtualAddress+s.SizeOfRawData)); return (int)(rva-(ulong)s.VirtualAddress)+s.PointerToRawData; }
object Nearest(ulong address) => methods.LastOrDefault(m=>m.Address<=address).Method;
foreach(var item in methods.Where(m=>Prop(Prop(m.Method,"DeclaringType"),"Name").ToString()==typeName && (methodName=="" || Prop(m.Method,"Name").ToString()==methodName)))
{
Console.WriteLine($"\nMETHOD {Label(item.Method)} 0x{item.Address:X}");
Emit(main, pe, imageBase, bytes, Raw, Nearest, methods, item.Address);
}
}
static void DisassembleOne(Type main, object metadata, string binary, ulong address, (object Method, ulong Address)[] methods)
{
var bytes=File.ReadAllBytes(binary);
using var pe=new PEReader(new MemoryStream(bytes));
ulong imageBase=pe.PEHeaders.PEHeader!.ImageBase;
int Raw(ulong address) { var rva=address-imageBase; var s=pe.PEHeaders.SectionHeaders.First(s=>rva>=(ulong)s.VirtualAddress && rva<(ulong)(s.VirtualAddress+s.SizeOfRawData)); return (int)(rva-(ulong)s.VirtualAddress)+s.PointerToRawData; }
object Nearest(ulong address) => methods.LastOrDefault(m=>m.Address<=address).Method;
Console.WriteLine($"\nMETHOD {Label(Nearest(address))}+0x{address - (Nearest(address)!=null ? (ulong)Prop(Nearest(address),"MethodPointer") : 0):X} 0x{address:X}");
Emit(main, pe, imageBase, bytes, Raw, Nearest, methods, address);
}
static void Emit(Type main, PEReader pe, ulong imageBase, byte[] bytes, Func<ulong,int> Raw, Func<ulong,object> Nearest,
(object Method, ulong Address)[] methods, ulong start)
{
var item = (Method: Nearest(start), Address: start);
foreach(var section in pe.PEHeaders.SectionHeaders.Where(s=>(s.SectionCharacteristics & SectionCharacteristics.MemExecute)!=0))
for(int i=section.PointerToRawData;i<section.PointerToRawData+section.SizeOfRawData-5;i++)
if(bytes[i]==0xE8 && (ulong)((long)imageBase+section.VirtualAddress+i-section.PointerToRawData+5+BitConverter.ToInt32(bytes,i+1))==item.Address)
{ ulong addr=imageBase+(ulong)(section.VirtualAddress+i-section.PointerToRawData); Console.WriteLine($"CALLER 0x{addr:X} {Label(Nearest(addr))}"); }
var decoder=Decoder.Create(64,new ByteArrayCodeReader(bytes,Raw(item.Address),Math.Min(10000,bytes.Length-Raw(item.Address)))); decoder.IP=item.Address;
var formatter=new IntelFormatter(); var output=new LineOutput();
var next = methods.FirstOrDefault(m => m.Address > item.Address).Address;
for(int i=0;i<1500 && (next == 0 || decoder.IP < next);i++)
{
decoder.Decode(out var instruction); output.Value=""; formatter.Format(instruction,output);
string note="";
if(instruction.FlowControl==FlowControl.Call && instruction.Op0Kind==OpKind.NearBranch64)
{ var called=Nearest(instruction.NearBranchTarget); if(called!=null && (ulong)Prop(called,"MethodPointer")==instruction.NearBranchTarget) note=" ; "+Label(called); }
if(instruction.IsIPRelativeMemoryOperand)
{ try { var literal=main.GetMethod("GetLiteralByAddress")!.Invoke(null,new object[]{instruction.IPRelativeMemoryAddress}); if(literal!=null) note+=" ; literal: "+literal; } catch {} }
Console.WriteLine($"{instruction.IP:X}: {output.Value}{note}");
}
}
sealed class LineOutput:FormatterOutput { public string Value=""; public override void Write(string text,FormatterTextKind kind)=>Value+=text; }
}
+64
View File
@@ -0,0 +1,64 @@
using System.Reflection;
using System.Runtime.Loader;
using Mono.Cecil;
using Mono.Cecil.Cil;
if (args.Length == 3 && args[0] == "--patch-loader")
{
if (Path.GetFullPath(args[1]).Equals(Path.GetFullPath(args[2]), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Write a separate loader copy; do not overwrite the source.");
using var assembly = AssemblyDefinition.ReadAssembly(args[1]);
int changes = 0;
foreach (var type in assembly.MainModule.Types)
foreach (var method in type.Methods.Where(m => m.HasBody))
foreach (var instruction in method.Body.Instructions)
if (type.FullName == "BepInEx.Unity.IL2CPP.Il2CppInteropManager" &&
instruction.OpCode == OpCodes.Ldstr && (string)instruction.Operand == "global-metadata.dat")
{
instruction.Operand = "global-metadata.interop.dat";
Console.WriteLine($"Redirected metadata input in {method.Name}");
changes++;
}
if (changes != 1) throw new InvalidOperationException($"Expected one loader metadata reference, found {changes}.");
assembly.Write(args[2]);
Console.WriteLine($"Created loader copy with {changes} metadata path reference(s) changed.");
return;
}
if (args.Length is < 2 or > 5)
{
Console.Error.WriteLine("Usage: AugustInterop <BepInEx-core> <normalized-metadata> [GameAssembly.dll]");
Console.Error.WriteLine(" or: AugustInterop --patch-loader <original-loader.dll> <output-loader.dll>");
Environment.ExitCode = 1;
return;
}
var core = args[0];
AssemblyLoadContext.Default.Resolving += (_, name) =>
{
var path = Path.Combine(core, name.Name + ".dll");
return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null;
};
var library = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(core, "LibCpp2IL.dll"));
if (args.Length > 1)
{
var primitives = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(core, "AssetRipper.Primitives.dll"));
var versionType = primitives.GetType("AssetRipper.Primitives.UnityVersion")!;
var version = versionType.GetMethod("Parse", new[] { typeof(string) })!.Invoke(null, new object[] { "2020.3.33f1" });
var type = library.GetType("LibCpp2IL.Metadata.Il2CppMetadata")!;
var metadata = type.GetMethod("ReadFrom")!.Invoke(null, new object[] { File.ReadAllBytes(args[1]), version });
Console.WriteLine("Metadata parsed successfully.");
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
if (field.GetValue(metadata) is Array array) Console.WriteLine($"{field.Name}: {array.Length}");
if (args.Length > 2)
{
var registry = library.GetTypes().Single(t => t.Name == "LibCpp2IlBinaryRegistry");
registry.GetMethod("RegisterBuiltInBinarySupport")!.Invoke(null, null);
var main = library.GetType("LibCpp2IL.LibCpp2IlMain")!;
var result = main.GetMethod("LoadFromFile")!.Invoke(null, new[] { (object)args[2], Path.GetFullPath(args[1]), version });
Console.WriteLine($"Binary + metadata initialization: {result}");
if (args.Length > 3 && args[3] == "--literal-find" && args.Length > 4)
LiteralFinder.Run(main, metadata, args[2], args[4]);
else if (args.Length > 3) NativeInspect.Run(main, metadata, args[2], args[3], args.Length > 4 ? args[4] : "");
}
}
+47
View File
@@ -0,0 +1,47 @@
# August 2023 interop repair
For the exact Rec Room 20230825 metadata header fingerprint and the installed
BepInEx 6.0.0-be.697 loader. This fixes interop generation, not login or gameplay.
It does not change GameAssembly.dll or the metadata used by the native game.
Requirements: Node.js, .NET 9 SDK, and the August build's BepInEx core libraries.
Run from the repository root, with the game closed during installation.
```powershell
$gameRoot = 'C:\RecRoom\2023-08-29T06-53-33Z'
$core = Join-Path $gameRoot 'BepInEx\core'
$metadataDir = Join-Path $gameRoot 'RecRoom_Data\il2cpp_data\Metadata'
node tools/AugustInterop/normalize.cjs "$metadataDir\global-metadata.dat" artifacts/august-normalized-metadata.dat
dotnet run --project tools/AugustInterop -- "$core" artifacts/august-normalized-metadata.dat "$gameRoot\GameAssembly.dll"
```
The normalizer checks the exact shuffled header, table bounds, image/type counts,
and every repaired module row. All other records remain unchanged. The C# tool
uses the installed LibCpp2IL to verify metadata and native registration parsing.
To reproduce the loader modification, use the original loader backup as input:
```powershell
dotnet run --project tools/AugustInterop -- --patch-loader "$core\BepInEx.Unity.IL2CPP.dll.before-august-interop" artifacts/BepInEx.Unity.IL2CPP.dll
```
For a different installation path, pass `-p:BepInExCore=<path-to-core>` when
building. The patch tool requires a separate output file and exactly one matching
metadata filename instruction. Preserve the original loader before installation.
Installation performed locally:
- Normalized copy: `RecRoom_Data/il2cpp_data/Metadata/global-metadata.interop.dat`.
- Modified loader: `BepInEx/core/BepInEx.Unity.IL2CPP.dll`.
- Original loader: same path plus `.before-august-interop`.
- Native `global-metadata.dat`: unchanged by this interop repair.
Launch `RecRoom.exe +forcemode:screen`. Successful generation ends with
`Il2CppInteropGen ... Done!` and `Chainloader startup complete`, and writes
`BepInEx/interop/assembly-hash.txt`. Keep `UpdateInteropAssemblies = true`.
Do not copy March-generated interop assemblies into this build.
Rollback: close the game and restore the original loader from its backup. The
separate normalized file can remain on disk; the original loader never reads it.
The old loader will again reject this release's custom metadata if regeneration
is needed. If the game build changes, this exact mapping must be revalidated.
+63
View File
@@ -0,0 +1,63 @@
// Diagnostic copy only. Never overwrite the metadata consumed by the game.
const fs = require('node:fs');
const crypto = require('node:crypto');
const [source, destination] = process.argv.slice(2);
if (!source || !destination || require('node:path').resolve(source) === require('node:path').resolve(destination))
throw new Error('Specify separate source and diagnostic output files.');
const input = fs.readFileSync(source);
const header = Buffer.from(input.subarray(0, 256));
header.fill(0, 0xc0, 0xc4);
header.fill(0, 0xc8, 0xcc);
if (crypto.createHash('sha256').update(header).digest('hex') !== '2af65a04fe43613b66fafbe269264a6ec2b4c834304db272f0df68e7503b59e2')
throw new Error('Not the verified 20230825 header.');
// Standard v27 section order. Each pair is the offset/size FIELD POSITION
// in the shuffled header, not a hardcoded section address.
const fields = [
[0x2c,0xbc], [0xc8,0xc0], [0xe8,0xc4], [0x20,0xa8],
[0x24,0x68], [0x44,0xb0], [0x64,0xec], [0xf0,0x04],
[0x38,0x3c], [0x28,0x80], [0xa0,0x50], [0xf8,0xd4],
[0xe0,0x5c], [0x08,0x00], [0x18,0x58], [0x4c,0xb4],
[0x30,0x9c], [0x34,0x98], [0x6c,0x8c], [0x14,0x48],
[0xd8,0xcc], [0x0c,0xb8], [0x88,0x70], [0x7c,0xd0],
[0xa4,0x90], [0x78,0x94], [0xe4,0xf4], [0x54,0x40],
[0xfc,0x10], [0x74,0x84], [0xac,0xdc]
];
const output = Buffer.from(input);
output.writeUInt32LE(0xfab11baf, 0);
output.writeUInt32LE(27, 4);
for (let i=0; i<fields.length; i++) {
const [a,b] = fields[i];
const offset=input.readUInt32LE(a), size=input.readUInt32LE(b);
if (offset < 256 || offset + size > input.length) throw new Error(`Section ${i} out of bounds`);
output.writeUInt32LE(offset, 8+i*8);
output.writeUInt32LE(size, 12+i*8);
}
if (!input.subarray(256).equals(output.subarray(256))) throw new Error('Payload changed');
// This release also places invalid generic-container indices in each image's
// synthetic <Module> type. Those types cannot have generic parameters. Repair
// only verified module rows in the tooling copy; game metadata is untouched.
const types = output.readUInt32LE(8 + 19*8);
const typeCount = output.readUInt32LE(12 + 19*8) / 88;
const images = output.readUInt32LE(8 + 20*8);
const imageCount = output.readUInt32LE(12 + 20*8) / 40;
const strings = output.readUInt32LE(24);
if (imageCount !== 305 || typeCount !== 42537 || output.subarray(strings+42, strings+51).toString() !== '<Module>\0')
throw new Error('Unexpected August type/image structure');
const modules = new Set();
for (let i=0; i<imageCount; i++) {
const index = output.readInt32LE(images + i*40 + 8);
const p = types + index*88;
if (index < 0 || index >= typeCount || modules.has(index) ||
output.readUInt32LE(p+84) !== 0x02000001 || output.readInt32LE(p) !== 0 ||
output.readInt32LE(p+24) < 6673)
throw new Error(`Unexpected module row for image ${i}`);
modules.add(index);
output.writeInt32LE(42, p);
output.writeInt32LE(-1, p+24);
}
for (let i=0; i<typeCount; i++) {
const generic = output.readInt32LE(types+i*88+24);
if (generic < -1 || generic >= 6673) throw new Error(`Invalid generic container on type ${i}`);
}
fs.writeFileSync(destination, output);
console.log(`Wrote tooling copy: ${destination}; normalized header and ${modules.size} synthetic module rows.`);
+2
View File
@@ -0,0 +1,2 @@
bin/
obj/
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<BepInExRoot Condition="'$(BepInExRoot)' == ''">C:\RecRoom\2023-09-06T06-53-47Z\BepInEx</BepInExRoot>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../2023Patch/RadicadePatch/ColliderCastGuardPatch.cs" Link="ColliderCastGuardPatch.cs" />
<Compile Include="../../2023Patch/RadicadePatch/ToolCollisionLayerRestore.cs" Link="ToolCollisionLayerRestore.cs" />
<Reference Include="$(BepInExRoot)/core/0Harmony.dll;$(BepInExRoot)/core/MonoMod.RuntimeDetour.dll;$(BepInExRoot)/core/MonoMod.Utils.dll;$(BepInExRoot)/core/Mono.Cecil.dll" />
</ItemGroup>
</Project>
+84
View File
@@ -0,0 +1,84 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using HarmonyLib;
var harmony = new Harmony("radicade.collider.regression");
foreach (var name in new[] { "DisposeBody", "Move", "Attach", "Unrelated" })
ColliderCastGuardPatch.Install(harmony, typeof(Targets).GetMethod(name));
Targets.DisposeBody();
Targets.Move(1, 2);
Targets.Attach(null, true);
try
{
Targets.Unrelated();
throw new Exception("Unrelated exception was swallowed");
}
catch (InvalidOperationException ex) when (ex.Message == "unrelated") { }
var dispose = Harmony.GetPatchInfo(typeof(Targets).GetMethod("DisposeBody"));
var move = Harmony.GetPatchInfo(typeof(Targets).GetMethod("Move"));
var attach = Harmony.GetPatchInfo(typeof(Targets).GetMethod("Attach"));
if (dispose.Prefixes.Count != 0 || move.Prefixes.Count != 0 || attach.Prefixes.Count != 1)
throw new Exception("Prefix was bound to an incompatible signature");
if (Targets.Calls != 3) throw new Exception("Original methods did not execute");
Console.WriteLine("PASS: zero-argument disposal, movement, collider prefix, cast handling, unrelated exception propagation");
var tool = new Tool();
var physical = new UnityEngine.Collider { Owner = tool, gameObject = new() { layer = 13 } };
var trigger = new UnityEngine.Collider { Owner = tool, isTrigger = true, gameObject = new() { layer = 13 } };
var nested = new UnityEngine.Collider { Owner = new Tool(), gameObject = new() { layer = 13 } };
var blocker = new UnityEngine.Collider { Owner = tool, gameObject = new() { layer = 14 } };
tool.Colliders = new[] { physical, trigger, nested, blocker };
ToolCollisionLayerRestore.Restore(tool);
if (physical.gameObject.layer != 10 || trigger.gameObject.layer != 13
|| nested.gameObject.layer != 13 || blocker.gameObject.layer != 14)
throw new Exception("Collision-layer restoration changed the wrong colliders");
physical.gameObject.layer = 13;
tool.MEDCLKFANNC = true;
ToolCollisionLayerRestore.Restore(tool);
if (physical.gameObject.layer != 13) throw new Exception("Collision-layer lock was ignored");
Console.WriteLine("PASS: dropped-item layer reset; triggers, nested tools, blockers and locks preserved");
public static class Targets
{
public static int Calls;
[MethodImpl(MethodImplOptions.NoInlining)]
public static void DisposeBody() { Calls++; throw new InvalidCastException("Unable to cast object of type CBKENBPBBPK"); }
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Move(float a, float b) { Calls++; }
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Attach(AOCLEIAJHKP collider, bool enabled) { Calls++; }
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Unrelated() => throw new InvalidOperationException("unrelated");
}
// Minimal game stand-ins: these tests exercise real Harmony registration without launching Unity.
public class AOCLEIAJHKP { }
public class CBKENBPBBPK { public UnityEngine.GameObject JPBNIBGFKJO => null; }
public class BPJMFIFDPJO { public UnityEngine.GameObject JPBNIBGFKJO => null; }
namespace UnityEngine
{
public class GameObject { public string name = "test"; public int layer; }
public class Collider
{
public bool isTrigger;
public GameObject gameObject;
public Tool Owner;
public T GetComponentInParent<T>() where T : class => Owner as T;
}
}
public class Tool
{
public bool MEDCLKFANNC;
public int EHNBBIAIJIK = 10;
public int HeldCollisionLayer = 13, EquippedCollisionLayer = 18, AttachedCollisionLayer = 23;
public UnityEngine.GameObject gameObject = new();
public UnityEngine.Collider[] Colliders = Array.Empty<UnityEngine.Collider>();
public T[] GetComponentsInChildren<T>(bool includeInactive) => Colliders.Cast<T>().ToArray();
}
internal static class RadicadeEacBypassPlugin { internal static readonly TestLog LogSource = new(); }
internal class TestLog
{
public void LogInfo(object value) { }
public void LogWarning(object value) { }
public void LogError(object value) => throw new Exception(value.ToString());
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>CommentStripper</AssemblyName>
<RootNamespace>CommentStripper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
</ItemGroup>
</Project>
+186
View File
@@ -0,0 +1,186 @@
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace CommentStripper;
/// <summary>
/// Removes every comment from the given C# files.
///
/// This is deliberately not a text scan. A line-based strip corrupts anything that merely looks
/// like a comment - this codebase is full of URLs and paths inside string and interpolated
/// literals, verbatim strings, char literals and C# 11 raw strings. The file is parsed with the
/// real C# parser instead, comment trivia is dropped from each token, and the result is written
/// back. Before a file is written, the tool re-parses its own output and checks that the sequence
/// of token kinds and token text is unchanged: if only trivia moved, the rewrite touched no code.
///
/// Usage: CommentStripper [--dry-run] &lt;path&gt; [path...]
/// </summary>
internal static class Program
{
private static readonly HashSet<string> SkippedDirectories = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", ".git", ".vs", "node_modules", "artifacts"
};
private static int Main(string[] args)
{
var dryRun = args.Contains("--dry-run");
var roots = args.Where(argument => !argument.StartsWith("--", StringComparison.Ordinal)).ToArray();
if (roots.Length == 0)
{
Console.Error.WriteLine("usage: CommentStripper [--dry-run] <path> [path...]");
return 2;
}
var filesChanged = 0;
var commentsRemoved = 0;
var linesRemoved = 0;
var filesSeen = 0;
var failures = 0;
foreach (var file in roots.SelectMany(EnumerateSources).Distinct(StringComparer.OrdinalIgnoreCase))
{
filesSeen++;
try
{
var result = Strip(file, dryRun);
if (result == null) continue;
filesChanged++;
commentsRemoved += result.Value.Comments;
linesRemoved += result.Value.Lines;
}
catch (Exception exception)
{
failures++;
Console.Error.WriteLine($"failed: {file}: {exception.Message}");
}
}
Console.WriteLine($"{(dryRun ? "would change" : "changed")} {filesChanged} of {filesSeen} files; "
+ $"{commentsRemoved} comments, {linesRemoved} lines");
return failures == 0 ? 0 : 1;
}
private static IEnumerable<string> EnumerateSources(string root)
{
if (File.Exists(root))
{
if (root.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) yield return root;
yield break;
}
if (!Directory.Exists(root)) yield break;
foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
{
var segments = file.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (segments.Any(segment => SkippedDirectories.Contains(segment))) continue;
yield return file;
}
}
private static (int Comments, int Lines)? Strip(string path, bool dryRun)
{
var bytes = File.ReadAllBytes(path);
var hasByteOrderMark = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
var original = new UTF8Encoding(false).GetString(bytes, hasByteOrderMark ? 3 : 0, bytes.Length - (hasByteOrderMark ? 3 : 0));
var tree = CSharpSyntaxTree.ParseText(original, new CSharpParseOptions(LanguageVersion.Latest));
var root = tree.GetRoot();
var stripper = new CommentStripper();
var rewritten = stripper.Visit(root) ?? root;
var stripped = rewritten.ToFullString();
if (stripper.Removed == 0 && stripped == original) return null;
// Blank out the whitespace that propped up a removed comment so the file does not fill with
// trailing-space-only lines. The split keeps each line's own terminator, because a verbatim
// string in this codebase carries CRLF of its own and rewriting it to LF would change the
// literal - which the token check below would then (correctly) refuse.
var cleaned = string.Concat(LinePattern.Split(stripped).Select(chunk =>
{
var body = chunk.TrimEnd('\r', '\n');
return body.Trim().Length == 0 ? chunk[body.Length..] : chunk;
}));
// Refuse to write anything that changed a token. Parsing the output and comparing the token
// stream is the check that this was trivia surgery and not code surgery.
var reparsed = CSharpSyntaxTree.ParseText(cleaned, new CSharpParseOptions(LanguageVersion.Latest));
var errors = reparsed.GetDiagnostics()
.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToList();
if (errors.Count > 0)
throw new InvalidOperationException("output does not parse: " + errors[0].GetMessage());
var before = Tokens(root);
var after = Tokens(reparsed.GetRoot());
if (before.Count != after.Count)
throw new InvalidOperationException($"token count changed: {before.Count} -> {after.Count}");
for (var index = 0; index < before.Count; index++)
{
if (before[index] != after[index])
throw new InvalidOperationException($"token {index} changed: {before[index]} -> {after[index]}");
}
var removedLines = CountLines(original) - CountLines(cleaned);
if (!dryRun)
File.WriteAllText(path, cleaned, new UTF8Encoding(hasByteOrderMark));
return (stripper.Removed, removedLines);
}
private static List<string> Tokens(SyntaxNode root) =>
root.DescendantTokens(descendIntoTrivia: false)
.Select(token => token.RawKind + ":" + token.Text)
.ToList();
private static int CountLines(string text) => text.Length == 0 ? 0 : text.Split('\n').Length;
/// <summary>Splits into chunks that each keep their own line terminator.</summary>
private static readonly System.Text.RegularExpressions.Regex LinePattern = new("(?<=\\n)", System.Text.RegularExpressions.RegexOptions.Compiled);
private sealed class CommentStripper : CSharpSyntaxRewriter
{
public int Removed { get; private set; }
public override SyntaxToken VisitToken(SyntaxToken token)
{
if (token.LeadingTrivia.Count == 0 && token.TrailingTrivia.Count == 0) return token;
return token
.WithLeadingTrivia(Clean(token.LeadingTrivia))
.WithTrailingTrivia(Clean(token.TrailingTrivia));
}
private SyntaxTriviaList Clean(SyntaxTriviaList trivia)
{
if (trivia.Count == 0) return trivia;
var kept = new List<SyntaxTrivia>(trivia.Count);
foreach (var item in trivia)
{
if (IsComment(item))
{
Removed++;
continue;
}
kept.Add(item);
}
return kept.Count == trivia.Count ? trivia : SyntaxFactory.TriviaList(kept);
}
private static bool IsComment(SyntaxTrivia trivia) => trivia.Kind() switch
{
SyntaxKind.SingleLineCommentTrivia => true,
SyntaxKind.MultiLineCommentTrivia => true,
SyntaxKind.SingleLineDocumentationCommentTrivia => true,
SyntaxKind.MultiLineDocumentationCommentTrivia => true,
_ => false
};
}
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+118
View File
@@ -0,0 +1,118 @@
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Text;
if (args.Length < 2)
{
Console.Error.WriteLine("Usage: InteropInspect <assembly.dll> <keyword>... | <assembly.dll> --type <FullName>");
return 1;
}
var path = args[0];
var detail = false;
string detailType = null;
var keywords = new List<string>();
for (int i = 1; i < args.Length; i++)
{
if (args[i] == "--type" && i + 1 < args.Length) { detail = true; detailType = args[++i]; }
else keywords.Add(args[i]);
}
using var fs = File.OpenRead(path);
using var pe = new PEReader(fs);
var md = pe.GetMetadataReader();
string GetString(StringHandle h) => md.GetString(h);
string GetNs(TypeDefinition t)
{
var ns = GetString(t.Namespace);
var decl = t.GetDeclaringType();
if (!decl.IsNil)
{
var outer = md.GetTypeDefinition(decl);
return GetNs(outer) + "+" + GetString(outer.Name);
}
return ns;
}
foreach (var tdh in md.TypeDefinitions)
{
var td = md.GetTypeDefinition(tdh);
var fullName = GetNs(td) + "." + GetString(td.Name);
bool match;
if (detail)
match = fullName.Equals(detailType, StringComparison.OrdinalIgnoreCase);
else
match = keywords.Any(k => fullName.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0 ||
(k.StartsWith("field:") && td.GetFields().Any(f =>
GetString(md.GetFieldDefinition(f).Name).Contains(k[6..], StringComparison.OrdinalIgnoreCase))));
if (!match) continue;
if (!detail)
{
Console.WriteLine(fullName);
continue;
}
var sb = new StringBuilder();
sb.AppendLine($"TYPE {fullName}");
if (!td.BaseType.IsNil)
{
try { sb.AppendLine($" base: {ResolveName(md, td.BaseType)}"); } catch { }
}
foreach (var iih in td.GetInterfaceImplementations())
{
var ii = md.GetInterfaceImplementation(iih);
try { sb.AppendLine($" impl: {ResolveName(md, ii.Interface)}"); } catch { }
}
sb.AppendLine(" fields:");
foreach (var fh in td.GetFields())
{
var f = md.GetFieldDefinition(fh);
var constantHandle = f.GetDefaultValue();
var constantValue = "";
if (!constantHandle.IsNil)
{
var constant = md.GetConstant(constantHandle);
var reader = md.GetBlobReader(constant.Value);
constantValue = " = " + reader.ReadConstant(constant.TypeCode);
}
sb.AppendLine($" {GetString(f.Name)}{constantValue}");
}
sb.AppendLine(" methods:");
foreach (var mh in td.GetMethods())
{
var m = md.GetMethodDefinition(mh);
var attrs = m.Attributes;
var prms = m.GetParameters().Select(ph => md.GetParameter(ph))
.Select(pp => $"{GetString(pp.Name)}");
sb.AppendLine($" {GetString(m.Name)}({string.Join(", ", prms)}) [{attrs}]");
}
sb.AppendLine(" properties:");
foreach (var ph in td.GetProperties())
{
var p = md.GetPropertyDefinition(ph);
sb.AppendLine($" {GetString(p.Name)}");
}
Console.Write(sb.ToString());
}
return 0;
static string ResolveName(MetadataReader md, EntityHandle h)
{
if (h.Kind == HandleKind.TypeReference)
{
var tr = md.GetTypeReference((TypeReferenceHandle)h);
return $"{md.GetString(tr.Namespace)}.{md.GetString(tr.Name)}";
}
if (h.Kind == HandleKind.TypeDefinition)
{
var td2 = md.GetTypeDefinition((TypeDefinitionHandle)h);
return $"{md.GetString(td2.Namespace)}.{md.GetString(td2.Name)}";
}
return h.Kind.ToString();
}
+431
View File
@@ -0,0 +1,431 @@
<#
Read-only triage for a Radicade-patched August 2023 Rec Room install.
Usage:
powershell -ExecutionPolicy Bypass -File august-client-triage.ps1
powershell -ExecutionPolicy Bypass -File august-client-triage.ps1 -GameDir "C:\path\to\2023-08-29T06-53-33Z"
It only reads files and opens two TCP sockets to localhost. It writes
august-client-report.txt next to this script (override with -OutFile).
Send that file back verbatim; it names exactly which piece of the stack is missing.
#>
[CmdletBinding()]
param(
[string]$GameDir,
[string]$OutFile
)
$ErrorActionPreference = 'Continue'
$script:Report = New-Object System.Collections.Generic.List[string]
$script:Failures = 0
$script:Warnings = 0
function Add-Line {
param([string]$Text = '')
$script:Report.Add($Text)
}
function Write-Both {
param([string]$Text = '')
Write-Host $Text
Add-Line $Text
}
function Add-Verdict {
param(
[ValidateSet('OK', 'WARN', 'MISSING')][string]$State,
[string]$Text
)
if ($State -eq 'MISSING') { $script:Failures++ }
if ($State -eq 'WARN') { $script:Warnings++ }
Add-Line ("[{0,-7}] {1}" -f $State, $Text)
if ($State -ne 'OK') { Write-Host ("[{0}] {1}" -f $State, $Text) }
}
function Get-Md5 {
param([string]$Path)
try { return (Get-FileHash -LiteralPath $Path -Algorithm MD5).Hash.ToLower() }
catch { return 'unreadable' }
}
function Get-ByteString {
param([string]$Path)
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
return [System.Text.Encoding]::GetEncoding(28591).GetString($bytes)
}
catch { return $null }
}
function Test-Utf16Literal {
param([string]$Path, [string]$Literal)
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$needle = [System.Text.Encoding]::Unicode.GetBytes($Literal)
for ($i = 0; $i -le ($bytes.Length - $needle.Length); $i++) {
if ($bytes[$i] -ne $needle[0]) { continue }
$hit = $true
for ($j = 1; $j -lt $needle.Length; $j++) {
if ($bytes[$i + $j] -ne $needle[$j]) { $hit = $false; break }
}
if ($hit) { return $true }
}
}
catch { }
return $false
}
function Test-Tcp {
param([int]$Port)
$client = New-Object System.Net.Sockets.TcpClient
try {
$task = $client.ConnectAsync('127.0.0.1', $Port)
if ($task.Wait(1500) -and $client.Connected) { return $true }
return $false
}
catch { return $false }
finally { $client.Close() }
}
function Get-LogMarkers {
param([string]$Path, [string[]]$Markers)
$result = @{}
foreach ($m in $Markers) { $result[$m] = 0 }
if (-not (Test-Path -LiteralPath $Path)) { return $result }
try {
$text = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
foreach ($m in $Markers) {
$result[$m] = ([regex]::Matches($text, [regex]::Escape($m))).Count
}
}
catch { }
return $result
}
# --- locate the install -------------------------------------------------------
if (-not $GameDir) {
$candidates = @(
(Join-Path $env:USERPROFILE 'Downloads\2023-08-29T06-53-33Z'),
(Join-Path $env:USERPROFILE 'Desktop\2023-08-29T06-53-33Z'),
'C:\2023-08-29T06-53-33Z'
)
$downloads = Join-Path $env:USERPROFILE 'Downloads'
if (Test-Path -LiteralPath $downloads) {
$candidates += (Get-ChildItem -LiteralPath $downloads -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'RecRoom.exe') } |
ForEach-Object { $_.FullName })
}
foreach ($c in $candidates) {
if ($c -and (Test-Path -LiteralPath (Join-Path $c 'RecRoom.exe'))) { $GameDir = $c; break }
}
}
if (-not $GameDir) { $GameDir = Read-Host 'Path to the Rec Room folder that contains RecRoom.exe' }
$GameDir = $GameDir.TrimEnd('\')
if (-not $OutFile) {
if ($PSScriptRoot) { $OutFile = Join-Path $PSScriptRoot 'august-client-report.txt' }
else { $OutFile = Join-Path (Get-Location) 'august-client-report.txt' }
}
$playerLog = Join-Path $env:USERPROFILE 'AppData\LocalLow\Against Gravity\Rec Room\Player.log'
$bepLog = Join-Path $GameDir 'BepInEx\LogOutput.log'
$metadata = Join-Path $GameDir 'Recroom_Data\il2cpp_data\Metadata\global-metadata.dat'
$interopDat = Join-Path $GameDir 'Recroom_Data\il2cpp_data\Metadata\global-metadata.interop.dat'
$bootConfig = Join-Path $GameDir 'Recroom_Data\boot.config'
$patchJson = Join-Path $GameDir 'Recroom_Data\radicade-patch.json'
$winhttp = Join-Path $GameDir 'winhttp.dll'
$doorstop = Join-Path $GameDir 'doorstop_config.ini'
$loader = Join-Path $GameDir 'BepInEx\core\BepInEx.Unity.IL2CPP.dll'
$plugin = Join-Path $GameDir 'BepInEx\plugins\RadicadeAugustClient.dll'
$assembly = Join-Path $GameDir 'GameAssembly.dll'
$interopDir = Join-Path $GameDir 'BepInEx\interop'
# Expected values taken from the reference install that logs in and loads rooms.
$expected = @{
MetadataSize = 38654794
MetadataMd5 = 'cc9c32014b5fc3dd1a2a8fb12376be15'
InteropSize = 38654794
InteropMd5 = '4a1a28078f957af470f3b31951b32648'
BootSize = 173
BootMd5 = 'dc2e5f7af7d5df736a4cc3477b4b1a1f'
WinhttpSize = 26112
WinhttpMd5 = 'b2a4d0cb04bf8f5a27ccab237ecc2586'
LoaderSize = 60928
LoaderMd5 = '9430bb84349d52b753a181788a86c5e2'
PluginSize = 62464
PluginMd5 = '8718a45dd42d7cb72b3e46e04b744d91'
InteropCount = 335
AssemblySize = 167390208
}
# --- header ------------------------------------------------------------------
Write-Both 'Radicade August 2023 client triage'
Write-Both ("generated : {0}" -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))
Write-Both ("game folder : {0}" -f $GameDir)
Write-Both ("powershell : {0}" -f $PSVersionTable.PSVersion)
Write-Both ''
Write-Both 'NOTE: "Failed to load Type RecRoom.Analytics.Statsig.StatsigDebugOverrides" in'
Write-Both 'Player.log is NORMAL. It appears in a completely unpatched launch of this'
Write-Both 'build and in every working install. It is not a symptom of anything.'
Write-Both ''
# --- install identity --------------------------------------------------------
Write-Both '== game build =='
if (Test-Path -LiteralPath $assembly) {
$info = Get-Item -LiteralPath $assembly
$md5 = Get-Md5 $assembly
Write-Both ("GameAssembly.dll : {0} b md5 {1}" -f $info.Length, $md5)
Write-Both (" file version : {0}" -f $info.VersionInfo.FileVersion)
if ($info.Length -eq $expected.AssemblySize) {
Add-Verdict 'OK' ('GameAssembly.dll size matches the reference build ({0} b)' -f $expected.AssemblySize)
} else {
Add-Verdict 'WARN' ("GameAssembly.dll is {0} b, the reference build is {1} b - a different build will not accept this patch stack" -f $info.Length, $expected.AssemblySize)
}
} else {
Add-Verdict 'MISSING' 'GameAssembly.dll - this is not a Rec Room install folder?'
}
if (Test-Path -LiteralPath $playerLog) {
$firstLine = (Get-Content -LiteralPath $playerLog -TotalCount 1 -ErrorAction SilentlyContinue)
Write-Both ("Player.log line 1 : {0}" -f $firstLine)
if ($firstLine -notmatch '2020\.3\.33f1') {
Add-Verdict 'WARN' 'Player.log does not report Unity 2020.3.33f1 - unexpected engine build'
}
} else {
Add-Verdict 'WARN' ('Player.log not found at {0}' -f $playerLog)
}
Write-Both ''
# --- the patched data files --------------------------------------------------
Write-Both '== patched data files =='
if (Test-Path -LiteralPath $metadata) {
$info = Get-Item -LiteralPath $metadata
$md5 = Get-Md5 $metadata
$text = Get-ByteString $metadata
$local = if ($text) { ([regex]::Matches($text, 'localhost:3030')).Count } else { -1 }
$recnet = if ($text) { ([regex]::Matches($text, 'rec\.net')).Count } else { -1 }
Write-Both ("global-metadata.dat : {0} b md5 {1}" -f $info.Length, $md5)
Write-Both (" localhost:3030 : {0} occurrence(s) (reference: 28)" -f $local)
Write-Both (" rec.net literals : {0} occurrence(s) (reference: 50)" -f $recnet)
if ($info.Length -eq $expected.MetadataSize -and $md5 -eq $expected.MetadataMd5) {
Add-Verdict 'OK' 'global-metadata.dat is the Radicade-patched file'
} elseif ($local -eq 0) {
Add-Verdict 'MISSING' 'global-metadata.dat is UNPATCHED - the client points at the official servers, the plugin cannot fix that'
} else {
Add-Verdict 'WARN' 'global-metadata.dat differs from the reference file (size or md5)'
}
} else {
Add-Verdict 'MISSING' 'Recroom_Data\il2cpp_data\Metadata\global-metadata.dat'
}
$metadataOrig = $metadata + '.orig'
if (Test-Path -LiteralPath $metadataOrig) {
$origInfo = Get-Item -LiteralPath $metadataOrig
Add-Line (" global-metadata.dat.orig: {0} b (a 32039456 b copy is the pristine vanilla file the patcher backed up)" -f $origInfo.Length)
}
if (Test-Path -LiteralPath $interopDat) {
$info = Get-Item -LiteralPath $interopDat
$md5 = Get-Md5 $interopDat
if ($info.Length -eq $expected.InteropSize -and $md5 -eq $expected.InteropMd5) {
Add-Verdict 'OK' 'global-metadata.interop.dat matches the reference'
} else {
Add-Verdict 'WARN' ("global-metadata.interop.dat is {0} b md5 {1} (reference {2} b {3})" -f $info.Length, $md5, $expected.InteropSize, $expected.InteropMd5)
}
} else {
Add-Verdict 'MISSING' 'Recroom_Data\il2cpp_data\Metadata\global-metadata.interop.dat - the patched BepInEx loader needs it to build interop assemblies'
}
if (Test-Path -LiteralPath $bootConfig) {
$info = Get-Item -LiteralPath $bootConfig
$md5 = Get-Md5 $bootConfig
Write-Both ("boot.config : {0} b md5 {1}" -f $info.Length, $md5)
Write-Both (' content : ' + ((Get-Content -LiteralPath $bootConfig) -join ' | '))
if ($info.Length -eq $expected.BootSize -and $md5 -eq $expected.BootMd5) {
Add-Verdict 'OK' 'boot.config is the Radicade variant'
} else {
Add-Verdict 'WARN' 'boot.config differs from the reference (vr-enabled=0 / player-connection-mode=Listen)'
}
} else {
Add-Verdict 'MISSING' 'Recroom_Data\boot.config'
}
if (Test-Path -LiteralPath $patchJson) {
Add-Line 'radicade-patch.json :'
foreach ($line in (Get-Content -LiteralPath $patchJson)) { Add-Line (' ' + $line) }
Add-Verdict 'OK' 'Recroom_Data\radicade-patch.json present - the metadata was patched on this machine'
} else {
Add-Verdict 'MISSING' 'Recroom_Data\radicade-patch.json - no evidence this install was ever patched'
}
Write-Both ''
# --- loader stack ------------------------------------------------------------
Write-Both '== loader stack =='
if (Test-Path -LiteralPath $winhttp) {
$info = Get-Item -LiteralPath $winhttp
$md5 = Get-Md5 $winhttp
if ($info.Length -eq $expected.WinhttpSize -and $md5 -eq $expected.WinhttpMd5) {
Add-Verdict 'OK' 'winhttp.dll is the Doorstop 4.3.0 proxy'
} else {
Add-Verdict 'WARN' ("winhttp.dll is {0} b md5 {1} (reference {2} b {3}) - another proxy or a disabled/renamed copy?" -f $info.Length, $md5, $expected.WinhttpSize, $expected.WinhttpMd5)
}
} else {
Add-Verdict 'MISSING' 'winhttp.dll - without the Doorstop proxy nothing loads BepInEx'
if (Test-Path -LiteralPath (Join-Path $GameDir 'winhttp.dll.baseline-disabled')) {
Add-Verdict 'MISSING' 'winhttp.dll is renamed to winhttp.dll.baseline-disabled - rename it back'
}
}
if (Test-Path -LiteralPath $doorstop) {
$cfg = Get-Content -LiteralPath $doorstop -Raw
if ($cfg -match 'enabled\s*=\s*true' -or $cfg -match '^\s*enabled\s*=\s*1') {
Add-Verdict 'OK' 'doorstop_config.ini has enabled = true'
} else {
Add-Verdict 'MISSING' 'doorstop_config.ini does not set enabled = true'
}
if ($cfg -match 'BepInEx\\core\\BepInEx\.Unity\.IL2CPP\.dll') {
Add-Verdict 'OK' 'doorstop_config.ini targets BepInEx\core\BepInEx.Unity.IL2CPP.dll'
} else {
Add-Verdict 'WARN' 'doorstop_config.ini target_assembly is not the IL2CPP preloader'
}
} else {
Add-Verdict 'MISSING' 'doorstop_config.ini'
}
if (Test-Path -LiteralPath (Join-Path $GameDir '.doorstop_version')) {
Add-Line (' .doorstop_version : ' + (Get-Content -LiteralPath (Join-Path $GameDir '.doorstop_version') -Raw).Trim())
}
if (Test-Path -LiteralPath $loader) {
$info = Get-Item -LiteralPath $loader
$md5 = Get-Md5 $loader
$patched = Test-Utf16Literal -Path $loader -Literal 'global-metadata.interop.dat'
if ($info.Length -eq $expected.LoaderSize -and $md5 -eq $expected.LoaderMd5) {
Add-Verdict 'OK' 'BepInEx.Unity.IL2CPP.dll is the patched loader (reads global-metadata.interop.dat)'
} elseif (-not $patched) {
Add-Verdict 'MISSING' 'BepInEx.Unity.IL2CPP.dll is UNPATCHED - Cpp2IL will reject this build''s metadata header'
} else {
Add-Verdict 'WARN' ("BepInEx.Unity.IL2CPP.dll is {0} b md5 {1} (reference {2} b {3})" -f $info.Length, $md5, $expected.LoaderSize, $expected.LoaderMd5)
}
} else {
Add-Verdict 'MISSING' 'BepInEx\core\BepInEx.Unity.IL2CPP.dll'
}
if (Test-Path -LiteralPath $interopDir) {
$count = (Get-ChildItem -LiteralPath $interopDir -File -ErrorAction SilentlyContinue).Count
if ($count -ge $expected.InteropCount) {
Add-Verdict 'OK' ("BepInEx\interop holds {0} assemblies" -f $count)
} else {
Add-Verdict 'WARN' ("BepInEx\interop holds {0} assemblies (reference {1}) - generation may have failed halfway" -f $count, $expected.InteropCount)
}
} else {
Add-Verdict 'MISSING' 'BepInEx\interop - the game''s managed proxy assemblies are absent'
}
if (Test-Path -LiteralPath $plugin) {
$info = Get-Item -LiteralPath $plugin
$md5 = Get-Md5 $plugin
if ($info.Length -eq $expected.PluginSize -and $md5 -eq $expected.PluginMd5) {
Add-Verdict 'OK' 'RadicadeAugustClient.dll matches the reference plugin build'
} else {
Add-Verdict 'WARN' ("RadicadeAugustClient.dll is {0} b md5 {1} (reference {2} b {3}) - a different build" -f $info.Length, $md5, $expected.PluginSize, $expected.PluginMd5)
}
} else {
Add-Verdict 'MISSING' 'BepInEx\plugins\RadicadeAugustClient.dll'
}
$otherPlugins = Get-ChildItem -LiteralPath (Join-Path $GameDir 'BepInEx\plugins') -File -Filter *.dll -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne 'RadicadeAugustClient.dll' }
foreach ($p in $otherPlugins) {
Add-Verdict 'WARN' ('extra plugin in BepInEx\plugins: {0} ({1} b) - it may not be August-compatible' -f $p.Name, $p.Length)
}
Write-Both ''
# --- server reachability -----------------------------------------------------
Write-Both '== local server =='
$https = Test-Tcp -Port 3030
$http = Test-Tcp -Port 3031
if ($https) { Add-Verdict 'OK' 'something is listening on https://127.0.0.1:3030' }
else { Add-Verdict 'MISSING' 'nothing is listening on port 3030 - the client is hardwired to https://localhost:3030, so the Radicade server must run on THIS machine' }
if ($http) { Add-Verdict 'OK' 'something is listening on http://127.0.0.1:3031' }
else { Add-Verdict 'WARN' 'nothing is listening on port 3031' }
Write-Both ''
# --- logs --------------------------------------------------------------------
Write-Both '== BepInEx log =='
if (Test-Path -LiteralPath $bepLog) {
$markers = Get-LogMarkers -Path $bepLog -Markers @(
'Chainloader startup complete',
'Radicade August Client',
'Bypassing native launch validator',
'global-metadata.interop.dat',
'Cpp2IL',
'[Error',
'[Warning'
)
foreach ($k in $markers.Keys) { Add-Line (" {0,-38}: {1}" -f $k, $markers[$k]) }
if ($markers['Chainloader startup complete'] -gt 0) { Add-Verdict 'OK' 'BepInEx chainloader came up' }
else { Add-Verdict 'MISSING' 'BepInEx chainloader never completed - read the log tail below' }
if ($markers['Radicade August Client'] -gt 0) { Add-Verdict 'OK' 'the Radicade plugin logged under its own name' }
else { Add-Verdict 'MISSING' 'the Radicade plugin never logged - it did not load' }
Add-Line ''
Add-Line ' last 25 lines:'
foreach ($line in (Get-Content -LiteralPath $bepLog -Tail 25 -ErrorAction SilentlyContinue)) { Add-Line (' ' + $line) }
} else {
Add-Verdict 'MISSING' ('BepInEx\LogOutput.log not found at {0} - BepInEx never started' -f $bepLog)
}
Write-Both ''
Write-Both '== Player.log =='
if (Test-Path -LiteralPath $playerLog) {
$markers = Get-LogMarkers -Path $playerLog -Markers @(
'Failed to load Type "RecRoom.Analytics.Statsig.StatsigDebugOverrides"',
'Could not resolve host',
"Could not resolve host 'ns.rec.net'",
'Launch validation failed',
'FailedToSpawnPlayer',
'Error downloading save data',
'ns.rec.net',
'localhost:3030'
)
foreach ($k in $markers.Keys) { Add-Line (" {0,-72}: {1}" -f $k, $markers[$k]) }
if ($markers["Could not resolve host 'ns.rec.net'"] -gt 0 -or $markers['ns.rec.net'] -gt 0) {
Add-Verdict 'MISSING' 'the client still talks to ns.rec.net - the metadata host literals are unpatched (or the plugin''s nameserver redirect never installed)'
} elseif ($markers['localhost:3030'] -gt 0) {
Add-Verdict 'OK' 'the client''s requests go to localhost:3030'
}
if ($markers['Launch validation failed'] -gt 0) {
Add-Verdict 'MISSING' 'the native launch validator rejected the install and no bypass ran'
}
Add-Line ''
Add-Line ' last 25 lines:'
foreach ($line in (Get-Content -LiteralPath $playerLog -Tail 25 -ErrorAction SilentlyContinue)) { Add-Line (' ' + $line) }
} else {
Add-Verdict 'MISSING' ('Player.log not found at {0}' -f $playerLog)
}
# --- verdict -----------------------------------------------------------------
Write-Both ''
Write-Both ('RESULT: {0} missing piece(s), {1} warning(s)' -f $script:Failures, $script:Warnings)
if ($script:Failures -eq 0) {
Write-Both 'Every required piece is present and the local server answers. If the game still'
Write-Both 'fails, the report above (BepInEx log tail + Player.log tail) names the next cause.'
} else {
Write-Both 'The [MISSING] lines above are the install steps that did not happen.'
}
Write-Both ''
Write-Both 'Reminder: the StatsigDebugOverrides line is expected noise in every launch.'
try {
Set-Content -LiteralPath $OutFile -Value ($script:Report -join "`r`n") -Encoding UTF8
Write-Host ''
Write-Host ("Report written to {0}" -f $OutFile)
}
catch {
Write-Host ("Could not write {0}: {1}" -f $OutFile, $_.Exception.Message)
}
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Check that obfuscating the September client cannot break a by-name lookup.
Obfuscar renames types and members, but it cannot see reflection. Anywhere this plugin hands a
*name* to a reflection API for one of its OWN types, that name has to survive the rename - hence
the SkipMethod rules in 2023Patch/RadicadePatch/obfuscar.xml.
This script enumerates the names that must survive (from the C# sources) and then checks each one
against Obfuscar's Mapping.txt to prove it was kept. Run after an obfuscation pass:
Tools/check-obfuscation-safety.py
Exit status is 0 only when every name was preserved.
"""
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
CLIENT = ROOT / "2023Patch" / "RadicadePatch"
SHARED = ROOT / "2023Patch" / "RadicadeEacBypass"
MAPPING = CLIENT / "bin" / "obfuscated" / "Mapping.txt"
SOURCES = [p for d in (CLIENT, SHARED) for p in d.rglob("*.cs") if "/obj/" not in p.as_posix() and "/bin/" not in p.as_posix()]
def source_texts():
for path in SOURCES:
yield path, path.read_text(encoding="utf-8", errors="replace")
def our_type_names():
"""Type names declared in the plugin and the shared folder (the ones Obfuscar can rename)."""
names = set()
pattern = re.compile(r"^\s*(?:public|internal|private|protected|static|sealed|abstract|partial|\s)*\b(?:class|struct|enum|interface|record)\s+([A-Za-z_]\w*)", re.M)
for _, text in source_texts():
names.update(pattern.findall(text))
return names
def names_resolved_at_runtime():
"""Every member name handed to reflection for one of our own types."""
ours = our_type_names()
found = {}
def add(name, where, why):
found.setdefault(name, (where, why))
for path, text in source_texts():
lines = text.splitlines()
for index, line in enumerate(lines, 1):
for match in re.finditer(r"nameof\(\s*([A-Za-z_][\w\.]*)\s*\)", line):
qualified = match.group(1)
owner = qualified.split(".")[0] if "." in qualified else None
member = qualified.split(".")[-1]
# nameof(SomeGameType.Member) is the game's own name and survives untouched.
if owner is not None and owner not in ours:
continue
add(member, f"{path.name}:{index}", f"nameof({qualified})")
# A literal handed to a reflection call on one of our own types.
for match in re.finditer(r"(?:typeof\(\s*([A-Za-z_]\w*)\s*\)|GetType\(\s*\))[^;\n]*?[\"']([A-Za-z_]\w*)[\"']", line):
owner, literal = match.group(1), match.group(2)
if owner in ours:
add(literal, f"{path.name}:{index}", f'"{literal}" on {owner}')
return found
def harmony_patch_methods():
"""Methods carrying a Harmony attribute.
Harmony resolves the special parameters __result/__instance/__state/__args/___field by NAME,
and Obfuscar nulls a parameter's name whenever it renames the declaring method (see
ShouldSkipParams). A renamed patch method therefore turns its own __result into P_0 and the
patch throws at Apply time - which aborts the rest of Plugin.Load, so later patches never arm.
Keeping the method name keeps the parameter names with it, so every one of these must be
pinned.
"""
attribute = re.compile(r"^\s*\[\s*Harmony")
declaration = re.compile(r"^\s*(?:[\w<>,\[\]\.\?]+\s+)+(?<![\w.])([A-Za-z_]\w*)\s*\(")
found = {}
for path, text in source_texts():
lines = text.splitlines()
for index, line in enumerate(lines):
match = declaration.match(line)
if not match or line.strip().startswith("["):
continue
name = match.group(1)
if name in ("if", "for", "while", "foreach", "switch", "catch", "using", "return", "lock"):
continue
above = []
cursor = index - 1
while cursor >= 0 and len(above) < 6:
stripped = lines[cursor].strip()
if not stripped:
cursor -= 1
continue
above.append(stripped)
if stripped.startswith("["):
cursor -= 1
continue
break
if any(attribute.match(a) for a in above):
found.setdefault(name, f"{path.name}:{index + 1}")
return found
def kept_by_obfuscar():
"""Members Obfuscar reported as skipped (i.e. not renamed)."""
kept = set()
for line in MAPPING.read_text(encoding="utf-8", errors="replace").splitlines():
if "skipped:" not in line:
continue
match = re.search(r"::([A-Za-z_]\w*)(?:\[|\()", line)
if match:
kept.add(match.group(1))
return kept
def renamed_by_obfuscar():
renamed = set()
for line in MAPPING.read_text(encoding="utf-8", errors="replace").splitlines():
if " -> " not in line or "::" not in line:
continue
match = re.search(r"::([A-Za-z_]\w*)(?:\[|\()", line)
if match:
renamed.add(match.group(1))
return renamed
def main():
if not MAPPING.exists():
print(f"no mapping at {MAPPING}; run obfuscar first", file=sys.stderr)
return 2
required = names_resolved_at_runtime()
patches = harmony_patch_methods()
for name, where in patches.items():
required.setdefault(name, (where, "Harmony patch method (keeps __parameters)"))
kept = kept_by_obfuscar()
renamed = renamed_by_obfuscar()
broken = sorted(n for n in required if n in renamed and n not in kept)
print(f"by-name lookups that must survive : {len(required)}")
print(f" of which Harmony patch methods : {len(patches)}")
print(f"members Obfuscar kept : {len(kept)}")
for name in sorted(required):
where, why = required[name]
status = "BROKEN" if name in broken else "kept"
print(f" [{status:6}] {name:36} {why:46} {where}")
if broken:
print(f"\nFAIL: {len(broken)} name(s) renamed but still looked up at runtime: {', '.join(broken)}")
return 1
print("\nOK: every by-name lookup points at a name Obfuscar preserved.")
return 0
if __name__ == "__main__":
sys.exit(main())
+38
View File
@@ -0,0 +1,38 @@
# Triage the last Rec Room run: crash vs orderly exit.
#
# The Unity log ends without a shutdown sequence in both cases, so the Windows
# Application log is what separates a fault (Application Error / WER) or a freeze
# (Application Hang) from a clean process end.
#
# Usage:
# powershell -NoProfile -File tools/client-exit-triage.ps1 [-Minutes 60]
param([int]$Minutes = 60)
$since = (Get-Date).AddMinutes(-$Minutes)
Write-Host "== Application log events touching the client (last $Minutes min) =="
Get-WinEvent -FilterHashtable @{ LogName = 'Application'; StartTime = $since } -ErrorAction SilentlyContinue |
Where-Object { $_.Message -match 'RecRoom|GameAssembly|UnityPlayer|breakpad' } |
Select-Object -First 12 |
ForEach-Object {
$first = ($_.Message -split [char]10 | Where-Object { $_ -and $_.Trim() }) | Select-Object -First 4
Write-Host ("{0} id={1} {2}" -f $_.TimeCreated, $_.Id, $_.ProviderName)
$first | ForEach-Object { Write-Host (" " + $_.Trim()) }
}
Write-Host ""
Write-Host "== WER reports for the client =="
Get-ChildItem "$env:LOCALAPPDATA\Microsoft\Windows\WER\ReportArchive" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'RecRoom' -and $_.LastWriteTime -gt $since } |
Select-Object LastWriteTime, Name |
Format-Table -AutoSize
Write-Host ""
Write-Host "== fresh minidumps =="
@("$env:LOCALAPPDATA\Temp", "$env:LOCALAPPDATA\CrashDumps", "$env:LOCALAPPDATA\Microsoft\Windows\WER\ReportQueue") |
Where-Object { Test-Path $_ } |
ForEach-Object {
Get-ChildItem $_ -Recurse -Depth 2 -Include *.dmp, *.mdmp -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -gt $since } |
Select-Object LastWriteTime, Length, FullName
} | Format-Table -AutoSize
+145
View File
@@ -0,0 +1,145 @@
"""Build an unsigned, logging-only diagnostic APK for the exact Quest Unity binary.
Preserves certificate decisions. Adds Android log probes after native X509
verification and after the Unity verification callback. Never edits the input.
Run zipalign and sign with the installed test application's key afterward.
"""
import argparse
import hashlib
import struct
import zipfile
from pathlib import Path
EXPECTED = 'd68dd33530dc9c6f963da17cad03d56fbaF51ac4bef297d05bf56684a44ee607'.lower()
CAVE = 0x125AE00
LIMIT = 0x125B100
LOG_PRINT = 0xBA9F0
def word(v):
return struct.pack('<I', v)
def branch(pc, target, link=False):
delta = target - pc
assert delta % 4 == 0 and -(1 << 27) <= delta < (1 << 27)
return word((0x94000000 if link else 0x14000000) | ((delta >> 2) & 0x3FFFFFF))
def adr(pc, target, reg):
delta = target - pc
assert -(1 << 20) <= delta < (1 << 20)
return word(0x10000000 | ((delta & 3) << 29) | (((delta >> 2) & 0x7FFFF) << 5) | reg)
def mov(dst, src, wide=True):
return word((0xAA0003E0 if wide else 0x2A0003E0) | (src << 16) | dst)
def pair(a, b, offset, load=False):
return word((0xA9400000 if load else 0xA9000000) | ((offset // 8) << 15) | (b << 10) | (31 << 5) | a)
def patch(binary, radicade_system_trust=False):
assert hashlib.sha256(binary).hexdigest() == EXPECTED, 'Unknown Unity binary'
out = bytearray(binary)
assert not any(out[CAVE:LIMIT]), 'Diagnostic space is occupied'
# Existing RX PT_LOAD ends before alignment padding; extend only into that
# verified empty padding, staying before the following RW PT_LOAD file range.
ph = struct.unpack_from('<Q', out, 32)[0]
assert struct.unpack_from('<II', out, ph) == (1, 5)
assert struct.unpack_from('<QQ', out, ph + 32) == (0x125ADF4, 0x125ADF4)
struct.pack_into('<QQ', out, ph + 32, LIMIT, LIMIT)
tag = CAVE + 0x240
fmt1 = tag + 16
fmt2 = fmt1 + 96
strings = {
tag: b'RadicadeTLS\0',
fmt1: b'x509 result=%d raw_flags=0x%x unity_flags=0x%x hostname=%s callback=%p\0',
fmt2: b'callback result=0x%x callback=%p error_code=%u\0',
}
for addr, data in strings.items():
assert addr + len(data) <= LIMIT
out[addr:addr + len(data)] = data
probes = [(0x75BA0C, CAVE, fmt1), (0x75F3B8, CAVE + 0x120, fmt2)]
for hook, start, fmt in probes:
code = bytearray(word(0xD10283FF)) # sub sp, sp, #160
for reg in range(0, 18, 2):
code += pair(reg, reg + 1, reg * 8)
code += pair(18, 30, 144)
if hook == 0x75BA0C:
assert binary[hook:hook+4] == word(0x1284DFE8) # mov w8, #-0x2700
code += mov(3, 21, False)
code += word(0xB8400000 | ((-0x5C & 0x1FF) << 12) | (29 << 5) | 4)
code += mov(5, 20, False) + mov(6, 28) + mov(7, 25)
else:
assert binary[hook:hook+4] == word(0xB90AC280) # str w0, [x20,#0xac0]
code += mov(3, 0, False) + mov(4, 24)
# Original errorstate at pre-probe SP+0x10; code is its +4 field.
code += word(0xB9400000 | ((180 // 4) << 10) | (31 << 5) | 5)
code += word(0x52800080) # mov w0, #4 (INFO)
code += adr(start + len(code), tag, 1)
code += adr(start + len(code), fmt, 2)
code += branch(start + len(code), LOG_PRINT, True)
for reg in range(0, 18, 2):
code += pair(reg, reg + 1, reg * 8, True)
code += pair(18, 30, 144, True)
code += word(0x910283FF) # add sp, sp, #160
code += binary[hook:hook+4]
code += branch(start + len(code), hook + 4)
assert len(code) <= 0x120
out[start:start+len(code)] = code
out[hook:hook+4] = branch(hook, start)
if radicade_system_trust:
# Route only this exact hostname through Unity's existing platform-root
# verifier. It checks the complete chain AND hostname. All other hosts
# retain the configured managed callback. No accept-all return patch.
start = CAVE + 0x90
host = CAVE + 0x2E0
hook = 0x75F3B4
assert binary[hook:hook+4] == word(0xD63F0300) # blr x24
hostname = b'ns.radicade.app\0'
out[host:host+len(hostname)] = hostname
code = bytearray(word(0xD10083FF)) # sub sp, sp, #32
code += pair(0, 1, 0) + pair(2, 30, 16)
code += word(0xF9400000 | ((0xA90 // 8) << 10) | (20 << 5))
# A null hostname must keep the original path.
null_branch = len(code)
code += word(0)
code += adr(start + len(code), host, 1)
code += branch(start + len(code), 0xBA7D0, True) # strcmp
mismatch_branch = len(code)
code += word(0)
code += pair(0, 1, 0, True) + pair(2, 30, 16, True)
code += word(0x910083FF) + mov(0, 20)
code += branch(start + len(code), 0x75F5D0, True)
code += branch(start + len(code), hook + 4)
original_path = start + len(code)
code += pair(0, 1, 0, True) + pair(2, 30, 16, True)
code += word(0x910083FF) + binary[hook:hook+4]
code += branch(start + len(code), hook + 4)
struct.pack_into('<I', code, null_branch, 0xB4000000 | (((original_path - start - null_branch) // 4) << 5))
struct.pack_into('<I', code, mismatch_branch, 0x35000000 | (((original_path - start - mismatch_branch) // 4) << 5))
assert len(code) <= 0x90
out[start:start+len(code)] = code
out[hook:hook+4] = branch(hook, start)
return bytes(out)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('input', type=Path)
p.add_argument('output', type=Path)
p.add_argument('--radicade-system-trust', action='store_true', help='Use Unity platform certificate+hostname verification for the exact Radicade host')
args = p.parse_args()
if args.output.exists():
raise SystemExit('Output already exists; choose a new file')
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.input) as source:
original = source.read('lib/arm64-v8a/libunity.so')
modified = patch(original, args.radicade_system_trust)
with zipfile.ZipFile(args.output, 'w') as target:
for entry in source.infolist():
if entry.filename.upper().startswith('META-INF/') and entry.filename.upper().endswith(('.RSA', '.SF', '.MF', '.DSA', '.EC')):
continue
data = modified if entry.filename == 'lib/arm64-v8a/libunity.so' else source.read(entry)
target.writestr(entry, data)
print('Unsigned diagnostic APK:', args.output)
print('Patched Unity SHA256:', hashlib.sha256(modified).hexdigest())
if __name__ == '__main__':
main()
+215
View File
@@ -0,0 +1,215 @@
<#
Builds a friend-ready Radicade August 2023 client bundle from a WORKING install.
It reads the source install, verifies every required piece against the reference
hashes, copies them to -OutDir, drops the triage script and a README in with them,
and optionally zips the result. Nothing in -GameDir is ever modified.
Usage:
powershell -ExecutionPolicy Bypass -File make-august-client-pack.ps1 -OutDir "D:\radicade-pack"
powershell -ExecutionPolicy Bypass -File make-august-client-pack.ps1 -OutDir "D:\radicade-pack" -Zip
Verification is fail-closed: if a piece is unpatched or a different build, the script
stops and names it instead of producing a bundle that cannot work.
#>
[CmdletBinding()]
param(
[string]$GameDir,
[Parameter(Mandatory = $true)][string]$OutDir,
[switch]$Zip,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
# Reference values: the install that logs in and loads rooms.
$expected = @{
AssemblySize = 167390208
AssemblyMd5 = '11e14eb35671e2725bd0c78a6a91bb48'
MetadataSize = 38654794
MetadataMd5 = 'cc9c32014b5fc3dd1a2a8fb12376be15'
InteropSize = 38654794
InteropMd5 = '4a1a28078f957af470f3b31951b32648'
BootSize = 173
BootMd5 = 'dc2e5f7af7d5df736a4cc3477b4b1a1f'
WinhttpSize = 26112
WinhttpMd5 = 'b2a4d0cb04bf8f5a27ccab237ecc2586'
LoaderSize = 60928
LoaderMd5 = '9430bb84349d52b753a181788a86c5e2'
PluginSize = 62464
PluginMd5 = '8718a45dd42d7cb72b3e46e04b744d91'
InteropCount = 335
}
function Get-Md5 {
param([string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm MD5).Hash.ToLower()
}
function Assert-Piece {
param(
[string]$Label,
[string]$Path,
[long]$Size,
[string]$Md5
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "$Label is missing: $Path"
}
$info = Get-Item -LiteralPath $Path
$actual = Get-Md5 $Path
if ($info.Length -ne $Size -or $actual -ne $Md5) {
$detail = "$Label does not match the reference: {0} b md5 {1} (expected {2} b md5 {3})" -f $info.Length, $actual, $Size, $Md5
if ($Force) { Write-Host ("WARNING: " + $detail) }
else { throw ($detail + ' - fix it or pass -Force if you know this build is fine.') }
} else {
Write-Host (" ok {0}" -f $Label)
}
}
# --- locate the source install ------------------------------------------------
if (-not $GameDir) {
$candidates = @(
(Join-Path $env:USERPROFILE 'Downloads\2023-08-29T06-53-33Z')
)
$downloads = Join-Path $env:USERPROFILE 'Downloads'
if (Test-Path -LiteralPath $downloads) {
$candidates += (Get-ChildItem -LiteralPath $downloads -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'RecRoom.exe') } |
ForEach-Object { $_.FullName })
}
foreach ($c in $candidates) {
if (Test-Path -LiteralPath (Join-Path $c 'BepInEx\plugins\RadicadeAugustClient.dll')) { $GameDir = $c; break }
}
}
if (-not $GameDir) { $GameDir = Read-Host 'Path to the patched Rec Room folder (the one that works)' }
$GameDir = $GameDir.TrimEnd('\')
$metadataDir = Join-Path $GameDir 'Recroom_Data\il2cpp_data\Metadata'
$bepInEx = Join-Path $GameDir 'BepInEx'
Write-Host 'Verifying the source install against the reference hashes...'
Assert-Piece 'GameAssembly.dll (build identity)' (Join-Path $GameDir 'GameAssembly.dll') $expected.AssemblySize $expected.AssemblyMd5
Assert-Piece 'global-metadata.dat (host literals patched)' (Join-Path $metadataDir 'global-metadata.dat') $expected.MetadataSize $expected.MetadataMd5
Assert-Piece 'global-metadata.interop.dat' (Join-Path $metadataDir 'global-metadata.interop.dat') $expected.InteropSize $expected.InteropMd5
Assert-Piece 'Recroom_Data\boot.config' (Join-Path $GameDir 'Recroom_Data\boot.config') $expected.BootSize $expected.BootMd5
Assert-Piece 'winhttp.dll (Doorstop 4.3.0)' (Join-Path $GameDir 'winhttp.dll') $expected.WinhttpSize $expected.WinhttpMd5
Assert-Piece 'BepInEx.Unity.IL2CPP.dll (patched loader)' (Join-Path $bepInEx 'core\BepInEx.Unity.IL2CPP.dll') $expected.LoaderSize $expected.LoaderMd5
Assert-Piece 'RadicadeAugustClient.dll' (Join-Path $bepInEx 'plugins\RadicadeAugustClient.dll') $expected.PluginSize $expected.PluginMd5
if (-not (Test-Path -LiteralPath (Join-Path $GameDir 'doorstop_config.ini'))) { throw 'doorstop_config.ini is missing' }
$interopCount = (Get-ChildItem -LiteralPath (Join-Path $bepInEx 'interop') -File -ErrorAction SilentlyContinue).Count
if ($interopCount -lt $expected.InteropCount) {
throw ("BepInEx\interop holds {0} assemblies, expected at least {1} - interop generation was incomplete" -f $interopCount, $expected.InteropCount)
}
Write-Host (" ok BepInEx\interop ({0} assemblies)" -f $interopCount)
Write-Host 'Source install verified.'
Write-Host ''
# --- assemble the bundle ------------------------------------------------------
$bundleRoot = Join-Path $OutDir 'RadicadeAugustClient'
if (Test-Path -LiteralPath $bundleRoot) {
if ($Force) { Remove-Item -LiteralPath $bundleRoot -Recurse -Force }
else { throw "$bundleRoot already exists - delete it or pass -Force to replace it." }
}
New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null
Write-Host ("Copying into {0} ..." -f $bundleRoot)
# Root-level loader pieces and the patched data files.
Copy-Item -LiteralPath (Join-Path $GameDir 'winhttp.dll') -Destination $bundleRoot
Copy-Item -LiteralPath (Join-Path $GameDir 'doorstop_config.ini') -Destination $bundleRoot
foreach ($name in @('.doorstop_version', 'changelog.txt')) {
$p = Join-Path $GameDir $name
if (Test-Path -LiteralPath $p) { Copy-Item -LiteralPath $p -Destination $bundleRoot }
}
$dataTarget = Join-Path $bundleRoot 'Recroom_Data'
New-Item -ItemType Directory -Path $dataTarget -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $GameDir 'Recroom_Data\boot.config') -Destination $dataTarget
foreach ($name in @('radicade-patch.json')) {
$p = Join-Path $GameDir ('Recroom_Data\' + $name)
if (Test-Path -LiteralPath $p) { Copy-Item -LiteralPath $p -Destination $dataTarget }
}
$metaTarget = Join-Path $dataTarget 'il2cpp_data\Metadata'
New-Item -ItemType Directory -Path $metaTarget -Force | Out-Null
foreach ($name in @('global-metadata.dat', 'global-metadata.interop.dat')) {
Copy-Item -LiteralPath (Join-Path $metadataDir $name) -Destination $metaTarget
}
# The whole BepInEx tree, minus logs, sidecar backups and caches.
$bepTarget = Join-Path $bundleRoot 'BepInEx'
# Excluded: logs, sidecar backups of previous builds, and the diagnostic RVA map the
# plugin writes (18 MB of local crash-analysis output).
robocopy $bepInEx $bepTarget /E /NFL /NDL /NJH /NJS /NP `
/XF 'LogOutput.log' '*.log' '*.before-*' '*.tracer-*' '*.march-backup' '*.baseline-disabled' `
'radicade-addr-map.txt' | Out-Null
if ($LASTEXITCODE -ge 8) { throw ("robocopy failed with exit code {0}" -f $LASTEXITCODE) }
$global:LASTEXITCODE = 0
# The triage script travels with the bundle so the friend can produce a report.
$triage = Join-Path $PSScriptRoot 'august-client-triage.ps1'
if (Test-Path -LiteralPath $triage) { Copy-Item -LiteralPath $triage -Destination $bundleRoot }
$assemblyInfo = Get-Item -LiteralPath (Join-Path $GameDir 'GameAssembly.dll')
$readme = @()
$readme += 'Radicade August 2023 client patch'
$readme += ''
$readme += 'This bundle carries the client half of a Rec Room 2023-08-29 (game version 20230825,'
$readme += 'Unity 2020.3.33f1) setup pointed at a Radicade server on YOUR OWN machine.'
$readme += ''
$readme += 'It only works on the identical game build. Check yours before copying anything:'
$readme += (" GameAssembly.dll must be {0} bytes, md5 {1}" -f $assemblyInfo.Length, $expected.AssemblyMd5)
$readme += ''
$readme += 'Install, into the folder that holds RecRoom.exe:'
$readme += ' 1. copy winhttp.dll, doorstop_config.ini and .doorstop_version into the game root'
$readme += ' 2. copy BepInEx\ next to them'
$readme += ' 3. copy Recroom_Data\boot.config and Recroom_Data\il2cpp_data\Metadata\*.dat over the'
$readme += ' existing files (keep copies of the originals; global-metadata.dat.orig is the'
$readme += ' patcher''s own backup and must stay untouched)'
$readme += ' 4. rename any winhttp.dll.baseline-disabled you already have out of the way'
$readme += ' 5. start your Radicade server first - https://localhost:3030 plus http://127.0.0.1:3031,'
$readme += ' ASPNETCORE_ENVIRONMENT=Development - and only then launch the game'
$readme += ''
$readme += 'Why the server has to be local: the patched global-metadata.dat rewrites the client''s'
$readme += 'bootstrap literals to https://localhost:3030, so a remote server cannot answer them.'
$readme += ''
$readme += 'Then check the install with the bundled triage script:'
$readme += ' powershell -ExecutionPolicy Bypass -File august-client-triage.ps1'
$readme += 'It prints one PASS/MISSING line per required piece and writes august-client-report.txt.'
$readme += ''
$readme += 'The line "Failed to load Type RecRoom.Analytics.Statsig.StatsigDebugOverrides" in'
$readme += 'Player.log is NORMAL and appears in a completely unpatched launch of this build too.'
$readme += 'It is never the reason a patched install fails.'
$readme += ''
$readme += 'Pieces and their reference hashes (the triage script checks these for you):'
$readme += ' GameAssembly.dll 167390208 b 11e14eb35671e2725bd0c78a6a91bb48'
$readme += ' Recroom_Data/il2cpp_data/Metadata/global-metadata.dat'
$readme += ' 38654794 b cc9c32014b5fc3dd1a2a8fb12376be15'
$readme += ' Recroom_Data/il2cpp_data/Metadata/global-metadata.interop.dat'
$readme += ' 38654794 b 4a1a28078f957af470f3b31951b32648'
$readme += ' Recroom_Data/boot.config 173 b dc2e5f7af7d5df736a4cc3477b4b1a1f'
$readme += ' winhttp.dll 26112 b b2a4d0cb04bf8f5a27ccab237ecc2586'
$readme += ' BepInEx/core/BepInEx.Unity.IL2CPP.dll'
$readme += ' 60928 b 9430bb84349d52b753a181788a86c5e2'
$readme += ' BepInEx/plugins/RadicadeAugustClient.dll'
$readme += ' 62464 b 8718a45dd42d7cb72b3e46e04b744d91'
$readme += ' BepInEx/interop/ 335 assemblies'
$readme += ''
Set-Content -LiteralPath (Join-Path $bundleRoot 'README-FRIEND.txt') -Value ($readme -join "`r`n") -Encoding UTF8
$size = (Get-ChildItem -LiteralPath $bundleRoot -Recurse -File | Measure-Object -Property Length -Sum).Sum
Write-Host ('Bundle assembled: {0} ({1:N1} MB)' -f $bundleRoot, ($size / 1MB))
if ($Zip) {
$zipPath = $bundleRoot.TrimEnd('\') + '.zip'
if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
Write-Host ('Compressing to {0} (this takes a while)...' -f $zipPath)
Compress-Archive -Path (Join-Path $bundleRoot '*') -DestinationPath $zipPath -CompressionLevel Optimal
Write-Host ('Zip written: {0} ({1:N1} MB)' -f $zipPath, ((Get-Item -LiteralPath $zipPath).Length / 1MB))
}
Write-Host ''
Write-Host 'Remind the friend that he still needs the game build above and his own Radicade server.'
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Rebuild an Android expansion OBB from the matching Apktool-decoded tree."""
from __future__ import annotations
import argparse
import os
import sys
import tempfile
import zipfile
from pathlib import Path
def read_no_compress(config: Path) -> tuple[str, list[str]]:
lines = config.read_text(encoding="utf-8").splitlines()
name = next((line.split(":", 1)[1].strip().strip("'\"")
for line in lines if line.startswith("apkFileName:")), None)
if not name:
raise ValueError(f"{config} has no apkFileName")
patterns: list[str] = []
in_list = False
for line in lines:
if line == "doNotCompress:":
in_list = True
continue
if in_list and line.startswith("- "):
patterns.append(line[2:].strip().strip("'\"").replace("\\", "/"))
elif in_list and line and not line.startswith(" "):
break
if not patterns:
raise ValueError(f"{config} has no doNotCompress list")
return name, patterns
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("decoded_obb", help="decoded OBB root containing apktool.yml and assets/")
parser.add_argument("output", help="path for the rebuilt .obb")
parser.add_argument("--check-only", action="store_true", help="validate inputs and print a packing summary")
args = parser.parse_args()
root = Path(args.decoded_obb).resolve()
config = root / "apktool.yml"
assets = root / "assets"
if not config.is_file() or not assets.is_dir():
print("decoded OBB must contain apktool.yml and assets/", file=sys.stderr)
return 2
output = Path(args.output).resolve()
if output.exists() and not args.check_only:
print(f"refusing to overwrite existing output: {output}", file=sys.stderr)
return 2
try:
obb_name, patterns = read_no_compress(config)
files = sorted((path for path in assets.rglob("*") if path.is_file()),
key=lambda path: path.relative_to(root).as_posix())
names = {path.relative_to(root).as_posix(): path for path in files}
exact = {pattern for pattern in patterns if pattern in names}
extension_rules = {pattern.lower().lstrip(".") for pattern in patterns if "/" not in pattern and pattern not in names}
unknown = [pattern for pattern in patterns if pattern not in names and pattern not in extension_rules]
if unknown:
raise ValueError(f"unmatched doNotCompress entries: {unknown[:8]}")
stored = {name for name in names if name in exact or
Path(name).suffix.lower().lstrip(".") in extension_rules}
matched_extensions = {Path(name).suffix.lower().lstrip(".") for name in names
if Path(name).suffix.lower().lstrip(".") in extension_rules}
missing_extensions = sorted(extension_rules - matched_extensions)
if missing_extensions:
raise ValueError(f"doNotCompress extensions have no matching file: {missing_extensions}")
uncompressed_bytes = sum(path.stat().st_size for name, path in names.items() if name in stored)
total_bytes = sum(path.stat().st_size for path in files)
if output.name != obb_name:
raise ValueError(f"output filename must be {obb_name!r}, not {output.name!r}")
except (OSError, ValueError) as exc:
print(f"cannot package decoded OBB: {exc}", file=sys.stderr)
return 2
print(f"source: {root}")
print(f"output: {output}")
print(f"entries: {len(files):,}; uncompressed bytes: {total_bytes:,}")
print(f"stored entries: {len(stored):,}; stored bytes: {uncompressed_bytes:,}; deflated entries: {len(files) - len(stored):,}")
if args.check_only:
return 0
output.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(prefix=output.name + ".", suffix=".tmp", dir=output.parent)
os.close(fd)
temporary = Path(temporary_name)
try:
with zipfile.ZipFile(temporary, "w", allowZip64=True, compression=zipfile.ZIP_DEFLATED,
compresslevel=6) as archive:
for path in files:
name = path.relative_to(root).as_posix()
archive.write(path, arcname=name,
compress_type=zipfile.ZIP_STORED if name in stored else zipfile.ZIP_DEFLATED,
compresslevel=None if name in stored else 6)
# Validate every CRC and path before publishing the archive at its final name.
with zipfile.ZipFile(temporary, "r") as archive:
bad_entry = archive.testzip()
if bad_entry is not None:
raise ValueError(f"archive CRC check failed at {bad_entry}")
if set(archive.namelist()) != set(names):
raise ValueError("archive entry list differs from the decoded assets tree")
os.replace(temporary, output)
except Exception:
temporary.unlink(missing_ok=True)
raise
print(f"created {output} ({output.stat().st_size:,} bytes); CRC and entry-list checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+785
View File
@@ -0,0 +1,785 @@
#!/usr/bin/env python3
"""Point a Rec Room 20230831 client at a local Radicade server.
Rewrites the rec.net bootstrap URLs that the client hardcodes as IL2CPP string
literals, so every service base it reads from GET /ns on the nameserver lands on
the local server. Same technique the 20230825 Radicade patch used: the rebuilt
literal blob is relaid at EOF and only the four header fields that describe it are
repointed, so every other byte of the metadata stays identical.
The shuffled-header field positions differ per release and are derived
structurally (see .tmp-newbuild/find_layout.py, chain.py); for 20230831 they are
literal table offset 0x74 / size 0x24, literal data offset 0xA0 / size 0x3C.
Usage:
python tools/patch-0831-client-metadata.py <game-root> [--server https://ns.radicade.app] [--apply]
Dry-run by default. Originals are kept as global-metadata.dat.orig on first patch.
An install already patched for the previous Radicade address is re-pointed
directly: that host sits in the string table verbatim, so the rules below rewrite
it and no pristine copy is needed. Anything else - an install patched by a
different tool, or with the address baked in some other way - still needs the
pristine copy FIRST, because the replacement is applied to the file as it stands
and would otherwise find nothing to rewrite and silently keep its old bootstrap
host:
cp global-metadata.dat.orig global-metadata.dat
python tools/patch-0831-client-metadata.py <game-root> --server <url> --apply
--bake writes the one file worth handing out. Relocating leaves the original
literal blob in the file (dead once the header is repointed) and appends a
verbatim copy of it, so the stock Rec Room hosts survive inside the metadata twice
and the file grows by the size of the blob. Baked mode rebuilds the blob inside
its own region instead: every record gets a fresh (length, index) pair into a
repacked blob holding only the final strings, the stock hosts are gone from the
file, and the file keeps its exact original size. The whole region is literal
data and nothing else - 99.9999% of its bytes are covered by the records' own
ranges - which is what makes rewriting it in place safe; the run refuses if that
coverage check fails. The records keep their table order, because the client's own
plugins find the literal table by that shape rather than by any header field, and
the bake is checked by running their detector (2023Patch/RadicadeEacBypass/
ImageSignatureRequest.cs) over the result. It reads the pristine copy when one is
present, so a single
invocation turns either a stock install or an already-patched one into the same
canonical file:
python tools/patch-0831-client-metadata.py <game-root> --bake --apply
The file that comes out is the whole fix, so it is also what to hand to someone
else - same 20230831 build, drop it in place of their global-metadata.dat. Use
--out to write it somewhere other than the install (which is also how to bake
while the game is running, since a running Radicade holds the metadata open):
python tools/patch-0831-client-metadata.py <game-root> --bake --apply --out out/global-metadata.dat
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
HEADER_FIELDS = {
"stringLiteral": (0x74, 0x24),
"stringLiteralData": (0xA0, 0x3C),
}
KEEP = [
"Upload to rec.net",
"rec.net/terms",
"rec.net/privacy",
"rec.net/rules",
"rec.net/parents",
"help.rec.net",
"api.steampowered.com",
"store.steampowered.com",
"firebase-settings.crashlytics.com",
"app-measurement.com",
]
# Static, server-independent replacements for the client's own branding/support
# strings and links. The 20230831 build keeps these in the same il2cpp string
# table as the service hosts (a contiguous block of legal/community URLs around
# "rec.net"/"recroom.com", plus one support-host sentence spread across ~34
# literals). Longest source wins, so the /home URL is retargeted before the
# bare-host rule rewrites the rest.
TEXT_RULES = [
("https://recroom.zendesk.com/home", "https://support.radicade.app/"),
("recroom.zendesk.com", "support.radicade.app"),
("https://www.recroom.com/privacy-policy/", "https://radicade.app/privacy-policy/"),
("https://recroom.com/terms-of-service", "https://radicade.app/terms-of-service/"),
("https://www.recroom.com/community/", "https://radicade.app/community/"),
# The rest of Rec Room's public pages and both of its support mailboxes: the
# Radicade site serves them at the same path, so these two catch-alls finish
# any page not named above. Rules run longest source first, so the named ones
# win and keep their exact destinations.
("https://www.recroom.com/", "https://radicade.app/"),
("https://recroom.com/", "https://radicade.app/"),
("tokens@recroom.com", "tokens@radicade.app"),
("support@recroom.com", "support@radicade.app"),
]
def build_rules(server: str):
b = server.rstrip("/")
return [
("https://ns.rec.net", b),
("https://auth.rec.net", b),
("https://api.rec.net", b),
("https://accounts.rec.net", b),
("https://cdn.rec.net", b),
("https://notifications.rec.net", b),
("https://www.rec.net", b),
("http://cdn.rec.net", b),
("http://img.rec.net/", f"{b}/images/img/"),
("https://img.rec.net", f"{b}/images/img"),
("https://images.rec.net", f"{b}/images/img"),
("https://rec.net/shop/subscriptions", f"{b}/shop/subscriptions"),
("https://rec.net/shop", f"{b}/shop"),
("https://rec.net/{0}", f"{b}/{{0}}"),
("https://rec.net", b),
("rec.net/creator", f"{b.split('://')[-1]}/creator"),
(".rec.net", ".radicade.internal"),
("rec.net", b),
# The previous Radicade address, so an install patched for it is re-pointed in
# one pass instead of needing the pristine copy. Longest source wins, exactly as
# above, and every destination matches what the rec.net rules above produce for
# the same literal - so an install patched from the pristine copy and one patched
# from the previous address converge on the same string table.
("https://radicade.example.com/images/img", f"{b}/images/img"),
("https://radicade.example.com/shop/subscriptions", f"{b}/shop/subscriptions"),
("https://radicade.example.com/shop", f"{b}/shop"),
("https://radicade.example.com/{0}", f"{b}/{{0}}"),
("https://radicade.example.com", b),
("http://radicade.example.com", b),
("radicade.example.com/creator", f"{b.split('://')[-1]}/creator"),
("radicade.example.com", b),
]
# Substring sharing makes a repacked blob comfortably smaller than the original
# dense one (1,567,053 of 1,657,800 bytes), which is the headroom that lets the
# rebuild land inside the same region. Overlaps past this length are not worth
# probing for; the longest one that matters here is a shared https:// prefix.
SHARE_LIMIT = 1024
def read_u32(buf, off: int) -> int:
return int.from_bytes(buf[off:off + 4], "little")
def read_i32(buf, off: int) -> int:
return int.from_bytes(buf[off:off + 4], "little", signed=True)
def resolve_metadata(game_root: str):
"""-> (data_dir, path to the live global-metadata.dat)"""
data_dir = next(
(name for name in ("RecRoom_Data", "Radicade_Data")
if os.path.isdir(os.path.join(game_root, name))),
"RecRoom_Data",
)
return data_dir, os.path.join(game_root, data_dir, "il2cpp_data", "Metadata", "global-metadata.dat")
def place_literals(buf, off, literals):
"""Add literals to buf (in the order given), reusing any that already appears
in it, and record where each one landed in off. Mutates and returns both.
A record's (length, index) pair is self-contained, so a literal may be placed
anywhere, including the middle of another one - that is what makes this pick
up the prefix sharing (https://ns.radicade.app inside .../shop) that IL2CPP's
own, suffix-only packer leaves on the table.
"""
for lit in literals:
if not lit or lit in off:
continue
found = buf.find(lit)
if found >= 0:
off[lit] = found
continue
used = len(buf)
# longest suffix of the buffer that is a prefix of the literal
lo, hi, tail = 1, min(len(lit), used, SHARE_LIMIT), 0
while lo <= hi:
mid = (lo + hi) // 2
if bytes(buf[used - mid:]) == lit[:mid]:
tail = mid
lo = mid + 1
else:
hi = mid - 1
if tail:
off[lit] = used - tail
buf += lit[tail:]
else:
off[lit] = used
buf += lit
return buf, off
def layout_blob(final, data_size):
"""Lay the literals out in the shape the game's own build writes them in.
IL2CPP writes this blob in table order, each record starting exactly where the
previous one ended, and the client finds the table again by that shape:
RadicadeEacBypass/ImageSignatureRequest.cs probes the first 8 and the first
1024 records and wants >=99% of them to chain before it will read the image key
id (KEY:RSA:p1...) out of the ".rec.net"/".radicade.internal" literal it finds
there. A blob packed purely for size - every literal placed wherever it shared
most - stops looking like a literal table at all, so the key id comes back
empty, image requests lose their ?rcsig, and the client is killed on the next
scene load. So records that fit stay in table order and only the tail that does
not fit is packed for size.
-> (blob, offset of every record, how many records stayed in table order)
"""
count = len(final)
ordered = [b or b"" for b in final]
tail = 0
while True:
keep = count - tail
blob = bytearray()
placements = [0] * count
off = {}
for i in range(keep):
placements[i] = len(blob)
if ordered[i]:
blob += ordered[i]
off.setdefault(ordered[i], placements[i])
place_literals(blob, off, sorted(
{ordered[i] for i in range(keep, count) if ordered[i]},
key=lambda b: (-len(b), b),
))
for i in range(keep, count):
if ordered[i]:
placements[i] = off[ordered[i]]
if len(blob) <= data_size or tail >= count:
return bytes(blob), placements, keep
tail = 64 if tail == 0 else tail * 2
def final_bytes_per_record(data, table_off, count, data_off, data_size, rules):
"""-> (bytes each record should expose, indices of the records we preserved).
None marks a record that exposes nothing (the build's one empty literal), and
a bail-out message comes back instead when a record cannot be resolved: a
record whose range is broken would have to keep an index into a blob we are
about to overwrite, so those fail the bake rather than get a guess.
"""
final = []
preserved = []
for i in range(count):
pos = table_off + i * 8
length = read_u32(data, pos)
index = read_i32(data, pos + 4)
if length == 0:
final.append(None)
continue
if index < 0 or index + length > data_size:
return None, None, f"literal #{i} has an unusable range ({length}, {index})"
raw = bytes(data[data_off + index: data_off + index + length])
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return None, None, f"literal #{i} is not UTF-8; it cannot be repacked"
if any(k.lower() in text.lower() for k in KEEP):
final.append(raw)
preserved.append(i)
continue
new = text
for frm, to in rules:
if frm in new:
new = new.replace(frm, to)
final.append(new.encode("utf-8"))
return final, preserved, None
def bake_metadata(data, table_off, table_size, data_off, data_size, rules):
"""Rebuild the literal blob inside its own region.
-> (out, report, error) where the report carries the per-record bytes, the
ranges we deliberately preserved, the packed blob size and how many literals
the rules actually rewrote.
"""
count = table_size // 8
final, preserved, err = final_bytes_per_record(data, table_off, count, data_off, data_size, rules)
if err:
return None, None, err
# Table order first, and never set iteration order for what is packed: an
# unstable order would let PYTHONHASHSEED change the layout, so two runs on the
# same input would produce different bytes and no one could check a copy by hash.
blob, placements, contiguous = layout_blob(final, data_size)
if len(blob) > data_size:
return None, None, (
f"repacked blob needs {len(blob):,} bytes but the region holds {data_size:,}"
)
if contiguous < min(count, 1024):
return None, None, (
f"only {contiguous:,} of {count:,} literals could stay in table order; the "
"client finds the literal table by that shape, so this file would not load"
)
out = bytearray(data)
out[data_off:data_off + len(blob)] = blob
out[data_off + len(blob):data_off + data_size] = b"\x00" * (data_size - len(blob))
for i, b in enumerate(final):
pos = table_off + i * 8
if b is None:
out[pos:pos + 4] = (0).to_bytes(4, "little")
out[pos + 4:pos + 8] = (0).to_bytes(4, "little", signed=True)
continue
out[pos:pos + 4] = len(b).to_bytes(4, "little")
out[pos + 4:pos + 8] = placements[i].to_bytes(4, "little", signed=True)
rewritten = sum(
1 for i, b in enumerate(final)
if b is not None
and bytes(data[data_off + read_i32(data, table_off + i * 8 + 4):
data_off + read_i32(data, table_off + i * 8 + 4) + read_u32(data, table_off + i * 8)]) != b
)
report = {
"final": final,
# file offsets, so they line up with the positions the host scan reports
"kept_ranges": [(data_off + placements[i], len(final[i])) for i in preserved],
"blob_size": len(blob),
"contiguous": contiguous,
"rewritten": rewritten,
}
return out, report, None
# ---------------------------------------------------------------------------
# The client's own table detection, ported from
# 2023Patch/RadicadeEacBypass/ImageSignatureRequest.cs.
#
# This metadata has no magic/version at 0/4, so the client takes the header-scan
# path: it builds candidates out of every plausible (offset, size) pair in the
# first 0x100 header bytes, keeps the ones whose records chain end-to-end for the
# first 8 and the first 1024 records, and reads the image key id (KEY:RSA:p1...) out
# of the ".rec.net"/".radicade.internal" literal it finds there. Running this after
# a bake is the point: it is the check that a repacked blob can pass every one of
# our own and still fail, and the failure is silent - no key id, no ?rcsig on image
# requests, and the client is killed on the next scene load.
# ---------------------------------------------------------------------------
DETECTOR_HEADER_SCAN_LIMIT = 0x100
DETECTOR_MINIMUM_RECORDS = 32
DETECTOR_PREFIX_PROBE = 8
DETECTOR_PACKING_PROBE = 1024
DETECTOR_MAX_LITERAL_LENGTH = 1 << 20
def detector_record(data, table_off, record):
pos = table_off + 8 * record
return read_u32(data, pos), read_i32(data, pos + 4)
def looks_like_literal_table(data, table_off, count, data_off, data_size, probe):
examined = min(count, probe)
contiguous = 0
running = 0
for record in range(examined):
length, index = detector_record(data, table_off, record)
if length < 0 or length > DETECTOR_MAX_LITERAL_LENGTH or index < 0 \
or index + length > data_size:
return False
if index == running:
contiguous += 1
running = index + length
return contiguous >= examined - max(1, examined // 100)
def sample_decodes_as_text(data, table_off, count, data_off, data_size):
step = max(1, count // 48)
for record in range(0, count, step):
length, index = detector_record(data, table_off, record)
if length < 0 or index < 0 or index + length > data_size:
return False
if length == 0:
continue
try:
bytes(data[data_off + index:data_off + index + length]).decode("utf-8")
except UnicodeDecodeError:
return False
return True
def scan_key_tails(data, table_off, count, data_off, data_size):
"""The one literal the client turns into its image key id."""
key = None
for record in range(count):
length, index = detector_record(data, table_off, record)
if length < 0 or index < 0 or index + length > data_size:
return None
if length != 8 and length != 18:
continue
literal = bytes(data[data_off + index:data_off + index + length]).decode("utf-8", "replace")
if literal != ".rec.net" and literal != ".radicade.internal":
continue
candidate = "KEY:RSA:p1" + literal
if key is not None and key != candidate:
return None
key = candidate
return key
def detect_key_id_from_game_metadata(data):
"""-> the image key id the client's plugin derives from this metadata, or None."""
if len(data) < 24:
return None
if read_u32(data, 0) == 0xFAB11BAF and read_i32(data, 4) == 27:
table, size = read_i32(data, 8), read_i32(data, 12)
data_off, data_size = read_i32(data, 16), read_i32(data, 20)
if (table < 24 or size < 0 or size % 8 != 0 or table + size > len(data)
or data_off < 24 or data_size < 0 or data_off + data_size > len(data)):
return None
return scan_key_tails(data, table, size // 8, data_off, data_size)
fields = []
for off in range(0, DETECTOR_HEADER_SCAN_LIMIT, 4):
value = read_i32(data, off)
if 0 < value <= len(data) and value not in fields:
fields.append(value)
tables = sorted(
((t, s // 8) for t in fields for s in fields
if s % 8 == 0 and s // 8 >= DETECTOR_MINIMUM_RECORDS
and t >= DETECTOR_HEADER_SCAN_LIMIT and t + s <= len(data)),
key=lambda t: -t[1],
)
slices = [(d, s) for d in fields for s in fields
if s > 0 and d >= DETECTOR_HEADER_SCAN_LIMIT and d + s <= len(data)]
for table_off, count in tables:
for data_off, data_size in slices:
if not looks_like_literal_table(data, table_off, count, data_off, data_size,
DETECTOR_PREFIX_PROBE):
continue
if not sample_decodes_as_text(data, table_off, count, data_off, data_size):
continue
if not looks_like_literal_table(data, table_off, count, data_off, data_size,
DETECTOR_PACKING_PROBE):
continue
key = scan_key_tails(data, table_off, count, data_off, data_size)
if key is not None:
return key
return None
def verify_baked(data, out, table_off, table_size, data_off, data_size, final, header_fields):
"""Read the baked file back the way the loader will. -> list of failures."""
count = table_size // 8
bad = []
for i in range(count):
pos = table_off + i * 8
length = read_u32(out, pos)
text = bytes(out[data_off + read_i32(out, pos + 4): data_off + read_i32(out, pos + 4) + length])
want = final[i] or b""
if text != want:
bad.append("literal #%d does not read back" % i)
_, data_size_f = HEADER_FIELDS["stringLiteralData"]
# everything outside the literal region, its table and the resized size field
# must be byte-identical: the bake moves no other structure.
def blanked(buf):
copy = bytearray(buf)
copy[data_off:data_off + data_size] = b"\x00" * data_size
copy[table_off:table_off + table_size] = b"\x00" * table_size
copy[data_size_f:data_size_f + 4] = b"\x00" * 4
return copy
want, got = blanked(data), blanked(out)
for off in range(min(len(want), len(got))):
if want[off] != got[off]:
bad.append("byte 0x%X outside the literal data and its table changed" % off)
break
if len(out) != len(data):
bad.append("file size changed (%d -> %d)" % (len(data), len(out)))
return bad
def scan_hosts(out, pattern, kept_ranges, region):
"""Hits of a bytes pattern in the baked file, split by the literal-data region.
-> (inside the literal data, elsewhere). A hit inside a literal we deliberately
preserve (KEEP) is not a leftover, so those ranges are exempted.
"""
inside, outside = [], []
for match in re.finditer(pattern, out):
if any(start <= match.start() and match.end() <= start + length
for start, length in kept_ranges):
continue
(inside if match.start() in region else outside).append(
(match.start(), bytes(match.group(0))))
return inside, outside
def bake_main(args, data_dir, metadata) -> int:
rules = sorted(build_rules(args.server) + TEXT_RULES, key=lambda r: -len(r[0]))
pristine = metadata + ".orig"
source = pristine if os.path.isfile(pristine) else metadata
note = "" if source == pristine else " (no pristine copy beside the live file)"
print(f"source : {source}{note}")
data = bytearray(open(source, "rb").read())
table_off, table_size = read_u32(data, HEADER_FIELDS["stringLiteral"][0]), read_u32(data, HEADER_FIELDS["stringLiteral"][1])
data_off, data_size = read_u32(data, HEADER_FIELDS["stringLiteralData"][0]), read_u32(data, HEADER_FIELDS["stringLiteralData"][1])
count = table_size // 8
print(f"size : {len(data):,}")
print(f"literal table: 0x{table_off:X} ({table_off:,}) records {count:,}")
print(f"literal data : 0x{data_off:X} ({data_off:,}) size {data_size:,}")
# The region is only safe to overwrite if the records account for all of it:
# that is what proves it holds literal data and nothing else.
coverage = bytearray(data_size)
for i in range(count):
pos = table_off + i * 8
length, index = read_u32(data, pos), read_i32(data, pos + 4)
if length == 0 or index < 0 or index + length > data_size:
continue
coverage[index:index + length] = b"\x01" * length
covered = coverage.count(1)
print(f"region coverage: {covered:,}/{data_size:,} bytes ({100.0 * covered / data_size:.4f}%)")
if covered < data_size * 0.999:
print("refusing to bake: the literal data region is not accounted for by the records", file=sys.stderr)
return 1
out, report, err = bake_metadata(data, table_off, table_size, data_off, data_size, rules)
if err:
print(f"bake failed: {err}", file=sys.stderr)
return 1
final, kept_ranges = report["final"], report["kept_ranges"]
blob_size = report["blob_size"]
print(f"literals : {report['rewritten']:,} rewritten by the rules, {len(final):,} repacked into the region")
print(f"repacked blob: {blob_size:,} bytes ({data_size - blob_size:,} reclaimed, tail zeroed)")
print(f"table shape : {report['contiguous']:,}/{count:,} literals kept in table order, "
f"{count - report['contiguous']:,} shared at the end of the blob")
region = range(data_off, data_off + data_size)
bad = verify_baked(data, out, table_off, table_size, data_off, data_size, final, HEADER_FIELDS)
sources = b"|".join(re.escape(frm.encode("utf-8")) for frm, _ in rules)
inside, outside = scan_hosts(out, sources, kept_ranges, region)
# Independently of the rule list: every Rec Room service host lives under
# rec.net, and in stock metadata lowercase rec.net only ever appears as one of
# those hosts, so anything still matching here is a bootstrap address the rules
# do not know about.
missed, _ = scan_hosts(out, rb"rec\.net", kept_ranges, region)
inside += missed
if inside:
bad.append("%d stock host reference(s) survived in the literal data, first at 0x%X: %r"
% (len(inside), inside[0][0], inside[0][1]))
key_id = detect_key_id_from_game_metadata(bytes(out))
if key_id is None:
bad.append("the client's own key-id detection finds nothing in this file "
"(ImageSignatureRequest.cs), so image requests would lose their ?rcsig")
else:
was = detect_key_id_from_game_metadata(bytes(data))
moved = f" (was {was})" if was and was != key_id else ""
print(f"image key id : {key_id}{moved}, derived the way the client derives it")
if bad:
for line in bad[:5]:
print(f"verification failed: {line}", file=sys.stderr)
return 1
print(f"verification : {count:,} literals read back; header, other tables and size untouched")
print(f"service hosts: none left in the literal data ({len(rules)} rule patterns + any *.rec.net)")
if outside:
kinds = sorted({text.decode("utf-8", "replace") for _, text in outside})
print(f"note : {len(outside)} reference(s) to {', '.join(kinds)} remain outside the")
print(" literal data, in the client's UI string table. They are display text")
print(" and do not decide which server the client contacts, so they are left")
print(" byte-for-byte alone.")
pages = sorted({b.decode("utf-8") for b in final if b and re.search(rb"(?i)recroom\.com", b)})
if pages:
print(f"note : {len(pages)} literal(s) still name a recroom.com page or address. Those")
print(" are browser links and support emails, not hosts the client connects to,")
print(" and there is no Radicade equivalent recorded for them:")
for text in pages[:8]:
print(f" {text}")
if len(pages) > 8:
print(f" ... and {len(pages) - 8} more")
if not args.apply:
print("\ndry run - pass --apply to write")
return 0
if args.out:
open(args.out, "wb").write(bytes(out))
print(f"written: {args.out}")
print("drop it in place of global-metadata.dat; the existing radicade-patch.json still")
print("names this server, so the client's own check stays satisfied")
return 0
backup = metadata + ".bak-" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(metadata, backup)
try:
with open(metadata, "wb") as handle:
handle.write(bytes(out))
except OSError as ex:
print(f"\ncannot write {metadata}: {ex}", file=sys.stderr)
print("the game keeps the metadata open while it runs - close Radicade and re-run,", file=sys.stderr)
print("or use --out to produce the file somewhere else and drop it in", file=sys.stderr)
return 1
if source == metadata and not os.path.exists(pristine):
shutil.copy2(backup, pristine)
print(f"pristine kept: {pristine}")
marker = os.path.join(args.game_root, data_dir, "radicade-patch.json")
json.dump({
"patchedAtUtc": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"gameVersion": "20230831",
"serverUrl": args.server,
"mode": "baked",
"metadata": {
"changedLiterals": report["rewritten"],
"repackedLiterals": sum(1 for b in final if b),
"oldDataSize": data_size,
"newDataSize": blob_size,
"headerSha256": hashlib.sha256(bytes(out[:256])).hexdigest(),
"imageKeyId": key_id,
"literalOrder": report["contiguous"],
"literalTableField": f"0x{HEADER_FIELDS['stringLiteral'][0]:02x}",
"literalDataField": f"0x{HEADER_FIELDS['stringLiteralData'][0]:02x}",
},
}, open(marker, "w"), indent=2)
print(f"written: {metadata}\nbackup : {backup}\nmarker : {marker}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("game_root")
ap.add_argument("--server", default="https://ns.radicade.app")
ap.add_argument("--bake", action="store_true",
help="rebuild the literal blob in place: one canonical file with no stock hosts left in it")
ap.add_argument("--out", default=None,
help="write the baked metadata here instead of into the install, to hand the file out")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
data_dir, metadata = resolve_metadata(args.game_root)
if not os.path.isfile(metadata):
print(f"not a Rec Room install: {metadata} missing", file=sys.stderr)
return 1
if args.bake:
return bake_main(args, data_dir, metadata)
data = bytearray(open(metadata, "rb").read())
n = len(data)
table_off_f, table_size_f = HEADER_FIELDS["stringLiteral"]
data_off_f, data_size_f = HEADER_FIELDS["stringLiteralData"]
def u32(byte: int) -> int:
return int.from_bytes(data[byte:byte + 4], "little")
table_off, table_size = u32(table_off_f), u32(table_size_f)
data_off, data_size = u32(data_off_f), u32(data_size_f)
count = table_size // 8
print(f"metadata : {metadata}")
print(f"size : {n:,}")
print(f"literal table: 0x{table_off:X} ({table_off:,}) records {count:,}")
print(f"literal data : 0x{data_off:X} ({data_off:,}) size {data_size:,}")
fp = hashlib.sha256(bytes(data[:256])).hexdigest()
print(f"header sha256: {fp}")
rules = build_rules(args.server) + TEXT_RULES
targets = {to for _, to in rules}
records = []
changes = []
saw_target = False
for i in range(count):
pos = table_off + i * 8
length = u32(pos)
index = int.from_bytes(data[pos + 4:pos + 8], "little", signed=True)
if length == 0 or index < 0 or index + length > data_size:
records.append((length, index, b"", False))
continue
raw = bytes(data[data_off + index: data_off + index + length])
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
records.append((length, index, raw, False))
continue
if any(k.lower() in text.lower() for k in KEEP):
records.append((length, index, raw, False))
continue
new = text
for frm, to in sorted(rules, key=lambda r: -len(r[0])):
if frm in new:
new = new.replace(frm, to)
if any(t and t in new for t in targets):
saw_target = True
if new != text:
changes.append((i, text, new))
records.append((len(new.encode()), index, new.encode(), True))
else:
records.append((length, index, raw, False))
if not changes:
print("nothing to rewrite" + (" (already patched)" if saw_target else ""))
return 0
# rebuild the blob: old bytes verbatim, changed literals appended after them
appended = bytearray()
new_index = {}
for i, old, new in changes:
new_index[i] = data_size + len(appended)
appended += new.encode()
new_data_size = data_size + len(appended)
out = bytearray(data)
out += bytearray(data[data_off: data_off + data_size])
out += appended
out[data_off_f:data_off_f + 4] = n.to_bytes(4, "little")
out[data_size_f:data_size_f + 4] = new_data_size.to_bytes(4, "little")
for i, old, new in changes:
pos = table_off + i * 8
out[pos:pos + 4] = len(new.encode()).to_bytes(4, "little")
out[pos + 4:pos + 8] = new_index[i].to_bytes(4, "little", signed=True)
print(f"\n{len(changes)} literal(s) to rewrite:")
for i, old, new in changes:
print(f" [{i}] {old!r}\n -> {new!r}")
print(f"blob: {data_size:,} -> {new_data_size:,} bytes, file: {n:,} -> {len(out):,}")
# --- verification -------------------------------------------------------
changed_positions = set()
for i, _, _ in changes:
pos = table_off + i * 8
changed_positions.update(range(pos, pos + 8))
for off in range(256):
if off in range(data_off_f, data_off_f + 4) or off in range(data_size_f, data_size_f + 4):
continue
if out[off] != data[off]:
print(f"verification failed: header byte 0x{off:X} changed", file=sys.stderr)
return 1
for off in range(256, n):
if off in changed_positions:
continue
if out[off] != data[off]:
print(f"verification failed: payload byte 0x{off:X} changed", file=sys.stderr)
return 1
if bytes(out[n:n + data_size]) != bytes(data[data_off:data_off + data_size]):
print("verification failed: relocated blob is not a verbatim copy", file=sys.stderr)
return 1
changed_map = {i: new for i, _, new in changes}
for i, (length, index, raw, _) in enumerate(records):
pos = table_off + i * 8
new_len = int.from_bytes(out[pos:pos + 4], "little")
new_idx = int.from_bytes(out[pos + 4:pos + 8], "little", signed=True)
text = bytes(out[new_data_size and n + new_idx: n + new_idx + new_len])
want = changed_map[i].encode() if i in changed_map else raw
if text != want:
print(f"verification failed: literal #{i} does not read back", file=sys.stderr)
return 1
print(f"verification: {count:,} literals read back, header and payload otherwise byte-identical")
if not args.apply:
print("\ndry run - pass --apply to write")
return 0
backup = metadata + ".orig"
if not os.path.exists(backup):
shutil.copy2(metadata, backup)
print(f"backup written: {backup}")
open(metadata, "wb").write(bytes(out))
marker = os.path.join(args.game_root, data_dir, "radicade-patch.json")
json.dump({
"patchedAtUtc": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"gameVersion": "20230831",
"serverUrl": args.server,
"metadata": {
"changedLiterals": len(changes),
"oldDataSize": data_size,
"newDataSize": new_data_size,
"headerSha256": fp,
"literalTableField": f"0x{table_off_f:02x}",
"literalDataField": f"0x{data_off_f:02x}",
},
}, open(marker, "w"), indent=2)
print(f"written: {metadata}\nmarker : {marker}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Point the unpacked Quest Rec Room 20230907 client at a Radicade server.
Accepts either the raw apktool-decoded APK directory (containing
assets/bin/Data/Managed/Metadata/global-metadata.dat) or a wrapper containing
apk/ and obb/ subdirectories like the provided download. It changes only exact
IL2CPP literal-table rows, not broad byte substrings.
Dry-run by default. Use --apply to back up the metadata and write the patch.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import struct
import sys
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
# The Quest release's header fields were mapped structurally from its unique
# metadata section chain. These contain the v27 string-literal table/data pairs.
FIELDS = {
"literal_table_offset": 0x44,
"literal_table_size": 0x6C,
"literal_data_offset": 0x28,
"literal_data_size": 0xEC,
}
GAME_VERSION = "20230907"
KNOWN_LITERALS = {
2011: ".rec.net",
2516: "20230907",
41319: "http://cdn.rec.net/avatar/",
41405: "https://auth.rec.net",
41411: "https://ns.rec.net",
41418: "https://rec.net",
41419: "https://rec.net/shop",
41420: "https://rec.net/shop/subscriptions",
41421: "https://rec.net/{0}",
}
def u32(data: bytes | bytearray, offset: int) -> int:
return struct.unpack_from("<I", data, offset)[0]
def validate_server(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValueError("server must be an https:// URL without user info")
if parsed.query or parsed.fragment:
raise ValueError("server URL cannot contain a query or fragment")
return value.rstrip("/")
def load_literals(blob: bytes) -> tuple[int, int, int, int, list[tuple[int, int]]]:
if len(blob) < 256:
raise ValueError("metadata is shorter than its 256-byte header")
table_offset = u32(blob, FIELDS["literal_table_offset"])
table_size = u32(blob, FIELDS["literal_table_size"])
data_offset = u32(blob, FIELDS["literal_data_offset"])
data_size = u32(blob, FIELDS["literal_data_size"])
if table_size % 8 or table_offset < 256 or table_offset + table_size > len(blob):
raise ValueError("Quest metadata literal-table header fields are invalid")
if data_offset < 256 or data_offset + data_size > len(blob):
raise ValueError("Quest metadata literal-data header fields are invalid")
literals = []
for index in range(table_size // 8):
length, string_index = struct.unpack_from("<Ii", blob, table_offset + index * 8)
if length == 0:
if string_index != 0:
raise ValueError(f"empty literal #{index} has unexpected data index {string_index}")
literals.append((length, string_index))
continue
if string_index < 0 or string_index + length > data_size:
raise ValueError(f"literal #{index} points outside its data segment")
try:
blob[data_offset + string_index:data_offset + string_index + length].decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"literal #{index} is not UTF-8") from exc
literals.append((length, string_index))
return table_offset, table_size, data_offset, data_size, literals
def build_patch(blob: bytes, server: str) -> tuple[bytearray, list[tuple[int, str, str]], int, int]:
table_offset, table_size, data_offset, data_size, literals = load_literals(blob)
# Refuse to patch a different client or a normalized/tooling metadata file.
for index, expected in KNOWN_LITERALS.items():
if index >= len(literals):
raise ValueError(f"expected Quest 20230907 literal #{index} is missing")
length, string_index = literals[index]
actual = blob[data_offset + string_index:data_offset + string_index + length].decode("utf-8")
if actual != expected:
raise ValueError(f"not the expected Quest 20230907 metadata: literal #{index} is {actual!r}, expected {expected!r}")
rules = {
"https://ns.rec.net": server,
"https://auth.rec.net": server,
"https://rec.net/shop/subscriptions": server + "/shop/subscriptions",
"https://rec.net/shop": server + "/shop",
"https://rec.net/{0}": server + "/{0}",
"https://rec.net": server,
# The server's signed image endpoint is /images/img/{name}.
"http://cdn.rec.net/avatar/": server + "/images/img/",
".rec.net": ".radicade.internal",
}
rows = []
for index, (length, string_index) in enumerate(literals):
if not length:
continue
old = blob[data_offset + string_index:data_offset + string_index + length].decode("utf-8")
# Retain the server host tail used by the runtime's image signature key id.
if index == 2011:
replacement = ".radicade.internal"
new = replacement if old == ".rec.net" else old
else:
new = old
# Longest-first prevents the root literal from consuming the shop forms.
for source, target in sorted(rules.items(), key=lambda item: -len(item[0])):
if source in new:
new = new.replace(source, target)
if new != old:
rows.append((index, old, new))
if not rows:
raise ValueError("no target literals found; this file may already be patched or not match the supported Quest build")
out = bytearray(blob)
appended = bytearray()
new_data_offsets: dict[int, int] = {}
for index, _old, new in rows:
encoded = new.encode("utf-8")
new_data_offsets[index] = data_size + len(appended)
appended.extend(encoded)
# As on the desktop patch, keep the old literal bytes intact and append the
# replacement strings, then update only the matching table rows and fields.
out.extend(blob[data_offset:data_offset + data_size])
out.extend(appended)
new_data_size = data_size + len(appended)
struct.pack_into("<I", out, FIELDS["literal_data_offset"], len(blob))
struct.pack_into("<I", out, FIELDS["literal_data_size"], new_data_size)
for index, _old, new in rows:
row_offset = table_offset + index * 8
struct.pack_into("<I", out, row_offset, len(new.encode("utf-8")))
struct.pack_into("<i", out, row_offset + 4, new_data_offsets[index])
# Read back all live string rows; unchanged rows must resolve identically.
relocated_data_offset = len(blob)
changed = {index: new for index, _old, new in rows}
for index, (old_length, old_index) in enumerate(literals):
row_offset = table_offset + index * 8
length, string_index = struct.unpack_from("<Ii", out, row_offset)
if length == 0:
continue
actual = out[relocated_data_offset + string_index:relocated_data_offset + string_index + length]
if index in changed:
expected = changed[index].encode("utf-8")
else:
expected = blob[data_offset + old_index:data_offset + old_index + old_length]
if actual != expected:
raise ValueError(f"read-back verification failed for literal #{index}")
return out, rows, data_size, new_data_size
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("apktool_dir", help="apktool-decoded directory containing apk/assets/bin/Data")
parser.add_argument("--server", default="https://ns.radicade.app")
parser.add_argument("--apply", action="store_true", help="write files (dry-run is the default)")
args = parser.parse_args()
try:
server = validate_server(args.server)
except ValueError as exc:
print(f"invalid server URL: {exc}", file=sys.stderr)
return 2
root = Path(args.apktool_dir).resolve()
candidates = [
root / "assets" / "bin" / "Data" / "Managed" / "Metadata" / "global-metadata.dat",
root / "apk" / "assets" / "bin" / "Data" / "Managed" / "Metadata" / "global-metadata.dat",
]
metadata = next((candidate for candidate in candidates if candidate.is_file()), candidates[-1])
if not metadata.is_file():
print(f"decoded Quest project missing {metadata}", file=sys.stderr)
return 1
try:
source = metadata.read_bytes()
patched, rows, old_size, new_size = build_patch(source, server)
except (OSError, ValueError, struct.error) as exc:
print(f"cannot patch Quest metadata: {exc}", file=sys.stderr)
return 1
print(f"metadata: {metadata}")
print(f"source sha256: {hashlib.sha256(source).hexdigest()}")
print(f"metadata size: {len(source):,} -> {len(patched):,}; literal bytes: {old_size:,} -> {new_size:,}")
print(f"server: {server}")
print(f"{len(rows)} live literal(s) will be redirected:")
for index, before, after in rows:
print(f" #{index}: {before!r}\n -> {after!r}")
print("verification: all literal rows read back; the patch changes only the table rows and its two data-segment header fields")
if not args.apply:
print("dry-run only; pass --apply to write the metadata backup and marker")
return 0
backup = Path(str(metadata) + ".orig")
if backup.exists() and backup.read_bytes() != source:
print(f"refusing to overwrite existing backup with different bytes: {backup}", file=sys.stderr)
return 1
if not backup.exists():
shutil.copy2(metadata, backup)
# Atomic replacement prevents a partially written 30 MB metadata file.
fd, temp_name = tempfile.mkstemp(prefix=metadata.name + ".", dir=metadata.parent)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(patched)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, metadata)
except Exception:
try:
os.unlink(temp_name)
except OSError:
pass
raise
marker = metadata.parents[2] / "radicade-quest-patch.json"
marker.write_text(json.dumps({
"gameVersion": GAME_VERSION,
"serverUrl": server,
"sourceSha256": hashlib.sha256(source).hexdigest(),
"patchedSha256": hashlib.sha256(patched).hexdigest(),
"changedLiterals": len(rows),
}, indent=2) + "\n", encoding="utf-8")
print(f"backup: {backup}\nmarker: {marker}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+91
View File
@@ -0,0 +1,91 @@
"""Experimental Quest 20230907 independent login; requires server platform-7 guard.
Preserves Quest VR setup before switching account initialization to standalone.
Redirects auth parameters, platform enum, and title-screen error-page navigation.
Produces an UNSIGNED APK. Does not assert that login/gameplay is supported.
The v1 full-initializer replacement failed with error 3 and no usable hands.
This v3 experiment restores SetVRDisplayMode, the password page, and Quest input.
Error navigation currently applies to all title-screen errors; not release-ready.
"""
import argparse
import hashlib
import struct
import zipfile
from pathlib import Path
EXPECTED = 'd9a72f2906ecee6cd292fa094fa57df8904b125dee161a04887603a3d0d3449e'
LIB = 'lib/arm64-v8a/libil2cpp.so'
REDIRECTS = (
('GetAuthParams', 0x3d0a930, 0x396ad48),
('get_CurrentPlatform', 0x3d096fc, 0x43d6eb8),
('Title-screen error to password page', 0x39c0f40, 0x39bbd80),
('Saved-account auto-login error to password page', 0x39c67e0, 0x39bbd80),
)
def patch(data):
if hashlib.sha256(data).hexdigest() != EXPECTED:
raise ValueError('Unknown IL2CPP binary; refusing to patch')
result = bytearray(data)
phoff = struct.unpack_from('<Q', data, 32)[0]
entsize, count = struct.unpack_from('<HH', data, 54)
segments = [struct.unpack_from('<IIQQQQQQ', data, phoff + i * entsize) for i in range(count)]
def offset(rva):
for typ, flags, off, va, _, size, _, _ in segments:
if typ == 1 and flags & 1 and va <= rva < va + size:
return off + rva - va
raise ValueError('Address outside file-backed executable segment')
for name, src, dst in REDIRECTS:
delta = dst - src
if delta % 4 or not -(1 << 27) <= delta < (1 << 27):
raise ValueError('Branch target out of range')
offset(dst)
struct.pack_into('<I', result, offset(src), 0x14000000 | ((delta // 4) & 0x3ffffff))
print(f'{name}: 0x{src:x} -> 0x{dst:x}')
# Preserve the original prologue and Quest-specific VR display setup through
# 0x3d09f14. Then run independent initialization and restore the original frame.
start = 0x3d09f18
words = [0xaa1303e0, 0xaa1f03e1,
0x94000000 | (((0x43d6f0c - (start + 8)) // 4) & 0x3ffffff)]
for addr in (0x3d0a124, 0x3d0a12c, 0x3d0a130, 0x3d0a138):
words.append(struct.unpack_from('<I', data, offset(addr))[0])
words.append(0xd65f03c0)
struct.pack_into('<' + 'I' * len(words), result, offset(start), *words)
print('Preserved Quest VR initialization; redirected account initialization after it')
# InputManager.FMJDLCGGGKP switches on CurrentPlatform. Standalone (7)
# originally returns no VR input; map it to the Oculus hardware-aware path.
table = 0xc35a550
for typ, flags, off, va, _, size, _, _ in segments:
if typ == 1 and va <= table and table + 36 <= va + size:
table_offset = off + table - va
assert table + struct.unpack_from('<i', data, table_offset + 28)[0] == 0x460a2b0
assert table + struct.unpack_from('<i', data, table_offset + 4)[0] == 0x460a2c0
result[table_offset + 28:table_offset + 32] = data[table_offset + 4:table_offset + 8]
break
else:
raise ValueError('Input dispatch table not found')
print('Standalone input dispatch uses existing Oculus hardware-specific handler')
return bytes(result)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('input', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
if args.output.exists():
parser.error('Output already exists; choose a new path')
with zipfile.ZipFile(args.input) as original:
patched = patch(original.read(LIB))
print('Patched library SHA256:', hashlib.sha256(patched).hexdigest())
if not args.apply:
print('Dry run; pass --apply to write unsigned APK')
return
with zipfile.ZipFile(args.output, 'w') as output:
for info in original.infolist():
if info.filename.upper().startswith('META-INF/'):
continue
output.writestr(info, patched if info.filename == LIB else original.read(info))
print('Wrote unsigned experimental APK:', args.output)
if __name__ == '__main__':
main()
+340
View File
@@ -0,0 +1,340 @@
<#
.SYNOPSIS
Points the Rec Room Studio Unity editor at a Radicade server instead of Rec Room's
real nameserver.
.DESCRIPTION
Everything the Rec Room Studio editor talks to is discovered from ONE compiled-in
string: the nameserver base URL inside RecRoom.Studio.Editor.Core.dll
(shipped default: https://deluxe-backend-test.oldrecroom.com/nameserver).
The editor GETs that URL and derives every other service base (Auth, API, CDN, ...)
from the JSON it returns. There is no config file, env var or command-line override
for it, so pointing RRS at a private server means rewriting that one #US heap string.
This patch is done in place and in a size-preserving way:
* the .NET #US string heap is addressed by offset, and no other entry's offset
moves because the entry keeps its exact old byte footprint;
* the freed tail of the entry is zero-filled and is never referenced;
* the file length is unchanged.
A one-time backup (<dll>.radicade-orig) is kept, so -Restore is always possible.
The backup deliberately lives OUTSIDE the package folder, under
<ProjectRoot>\radicade-backups\..., because Unity treats everything under
Library\PackageCache as an immutable package folder and logs
"asset ... has no meta file, but it's in an immutable folder" for anything
there it cannot match to a package manifest.
The target project is probed for writability first: a Rec Room Studio project sitting
in a OneDrive folder (or covered by Controlled folder access) rejects every create and
overwrite, and Unity cannot run from such a tree at all. Copy the project somewhere
writable and keep working there.
.PARAMETER ProjectRoot
Candidate project roots, tried in order; the first one that exists, holds the studio
package cache and actually accepts writes is used.
.PARAMETER Url
Nameserver base URL the editor should use.
.PARAMETER DllPath
Patch this DLL directly instead of probing ProjectRoot.
.PARAMETER Restore
Put the backed-up DLL back and exit.
.EXAMPLE
pwsh -File tools/patch-rrs-nameserver.ps1
pwsh -File tools/patch-rrs-nameserver.ps1 -Url http://127.0.0.1:3031/nameserver
pwsh -File tools/patch-rrs-nameserver.ps1 -Restore
#>
[CmdletBinding()]
param(
[string[]] $ProjectRoot = @(
'C:\RecRoom\Documents\EggFiles\ProjectTemplate',
'C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate'
),
[string] $Url = 'https://localhost:3030/nameserver',
[string] $DllPath,
[switch] $Restore
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# Latin-1 gives a 1:1 byte<->char mapping, so IndexOf() returns byte offsets and the
# search stays native-fast instead of looping over ~330 KB in PowerShell.
$latin1 = [System.Text.Encoding]::GetEncoding(28591)
# Known shipped values of the literal. The first one is what this template ships; the
# list keeps the tool working if a future build bakes a different host.
$knownUrls = @(
'https://deluxe-backend-test.oldrecroom.com/nameserver'
)
function Write-Status {
param([string] $Level, [string] $Message)
Write-Host ("[{0}] {1}" -f $Level, $Message)
}
function Find-EditorCoreDll {
param([string] $Root, [switch] $Quiet)
$pkgRoot = Join-Path $Root 'Library\PackageCache'
if (-not (Test-Path -LiteralPath $pkgRoot)) {
if ($Quiet) { return $null }
throw "No PackageCache under '$Root' - open the project in Unity once so packages get extracted."
}
$candidates = Get-ChildItem -LiteralPath $pkgRoot -Directory -Filter 'com.recroom.studio.build@*' -ErrorAction SilentlyContinue |
ForEach-Object { Join-Path $_.FullName 'Editor\RecRoom.Studio.Editor.Core.dll' } |
Where-Object { Test-Path -LiteralPath $_ }
if (-not $candidates) {
if ($Quiet) { return $null }
throw "RecRoom.Studio.Editor.Core.dll not found under $pkgRoot (looked in com.recroom.studio.build@*)."
}
if (@($candidates).Count -gt 1) {
Write-Status 'WARN' "multiple studio.build packages present, using the first: $($candidates[0])"
}
return @($candidates)[0]
}
function Get-BackupPath {
<# Maps <ProjectRoot>\Library\PackageCache\<pkg>\...\X.dll to
<ProjectRoot>\radicade-backups\<pkg>\...\X.dll.radicade-orig, so the backup stays a
real project asset tree instead of polluting the immutable package folder. #>
param([string] $DllPath)
$marker = '\Library\PackageCache\'
$idx = $DllPath.IndexOf($marker, [System.StringComparison]::OrdinalIgnoreCase)
if ($idx -lt 0) {
# Not inside a package cache (a -DllPath into Assets, say): keep it as a sibling file.
return "$DllPath.radicade-orig"
}
$root = $DllPath.Substring(0, $idx)
$relative = $DllPath.Substring($idx + $marker.Length)
return (Join-Path $root (Join-Path 'radicade-backups' "$relative.radicade-orig"))
}
function Test-Writable {
param([string] $Directory)
$probe = Join-Path $Directory ('radicade-write-probe-' + [Guid]::NewGuid().ToString('N') + '.tmp')
try {
[System.IO.File]::WriteAllText($probe, 'probe')
Remove-Item -LiteralPath $probe -Force
return $true
} catch {
return $false
}
}
function Resolve-Project {
param([string[]] $Candidates)
$notes = New-Object System.Collections.Generic.List[string]
foreach ($candidate in $Candidates) {
if (-not (Test-Path -LiteralPath $candidate)) {
$notes.Add("$candidate - does not exist")
continue
}
$dll = Find-EditorCoreDll -Root $candidate -Quiet
if (-not $dll) {
$notes.Add("$candidate - no com.recroom.studio.build package cache inside")
continue
}
if (-not (Test-Writable -Directory $candidate)) {
$notes.Add("$candidate - present, but the tree rejects writes")
continue
}
return $dll
}
$details = ($notes | ForEach-Object { " $_" }) -join [Environment]::NewLine
throw @"
No writable Rec Room Studio project found. Tried:
$details
A tree that rejects writes cannot hold this project: Unity has to create and overwrite
files in Library\ and Temp\ just to open it. Copy the project somewhere writable first
(always exclude the OneDrive copy from your Unity Hub list afterwards), for example:
robocopy "C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate" "C:\RecRoom\Documents\EggFiles\ProjectTemplate" /E /MT:16 /R:1 /W:1
then pass that root with -ProjectRoot, or just re-run this script.
"@
}
function Read-CompressedUInt {
param([byte[]] $Bytes, [int] $Offset)
$b0 = $Bytes[$Offset]
if (($b0 -band 0x80) -eq 0) { return @{ Value = [int]$b0; Size = 1 } }
if (($b0 -band 0xC0) -eq 0x80) {
return @{ Value = ((([int]$b0 -band 0x3F) -shl 8) -bor [int]$Bytes[$Offset + 1]); Size = 2 }
}
$v = (([int]$b0 -band 0x1F) -shl 24) -bor ([int]$Bytes[$Offset + 1] -shl 16) -bor `
([int]$Bytes[$Offset + 2] -shl 8) -bor [int]$Bytes[$Offset + 3]
return @{ Value = $v; Size = 4 }
}
function Write-CompressedUInt {
param([byte[]] $Bytes, [int] $Offset, [int] $Value)
if ($Value -lt 0x80) {
$Bytes[$Offset] = [byte]$Value
return 1
}
if ($Value -lt 0x4000) {
$Bytes[$Offset] = [byte](0x80 -bor ($Value -shr 8))
$Bytes[$Offset + 1] = [byte]($Value -band 0xFF)
return 2
}
$Bytes[$Offset] = [byte](0xC0 -bor ($Value -shr 24))
$Bytes[$Offset + 1] = [byte](($Value -shr 16) -band 0xFF)
$Bytes[$Offset + 2] = [byte](($Value -shr 8) -band 0xFF)
$Bytes[$Offset + 3] = [byte]($Value -band 0xFF)
return 4
}
function Get-NameserverEntry {
<# Locates the #US string heap entry whose payload is one of $Known or $Target.
Returns offset/prefix/payload geometry, or $null when the literal is absent. #>
param([string] $Path, [string[]] $Known, [string] $Target)
$bytes = [System.IO.File]::ReadAllBytes($Path)
$hay = $latin1.GetString($bytes)
foreach ($candidate in @($Known + @($Target))) {
$payload = [System.Text.Encoding]::Unicode.GetBytes($candidate)
$at = $hay.IndexOf($latin1.GetString($payload), [System.StringComparison]::Ordinal)
if ($at -lt 1) { continue }
for ($width = 1; $width -le 4; $width++) {
$prefixAt = $at - $width
if ($prefixAt -lt 0) { continue }
$prefix = Read-CompressedUInt -Bytes $bytes -Offset $prefixAt
if ($prefix.Size -ne $width) { continue }
if ($prefix.Value -ne (2 * $candidate.Length + 1)) { continue }
return [pscustomobject]@{
Bytes = $bytes
PayloadOffset = $at
PrefixOffset = $prefixAt
PrefixSize = $prefix.Size
PrefixValue = $prefix.Value
Original = $candidate
Footprint = $prefix.Size + 2 * $candidate.Length + 1
TerminalByte = $bytes[$at + 2 * $candidate.Length]
}
}
}
return $null
}
# ---- main -------------------------------------------------------------------------
Write-Host ''
if (-not $DllPath) {
$DllPath = Resolve-Project -Candidates $ProjectRoot
Write-Status 'INFO' 'project : writable, using the studio package cache below'
}
$backup = Get-BackupPath -DllPath $DllPath
Write-Status 'INFO' "studio editor core: $DllPath"
Write-Status 'INFO' "backup location : $backup"
if ($Restore) {
if (-not (Test-Path -LiteralPath $backup)) {
# Older runs kept the backup in the package folder; still honour one if it is all we have.
$legacy = "$DllPath.radicade-orig"
if (Test-Path -LiteralPath $legacy) {
Write-Status 'WARN' "using legacy in-package backup at $legacy"
$backup = $legacy
} else {
throw "No backup at '$backup' - nothing to restore."
}
}
try {
Copy-Item -LiteralPath $backup -Destination $DllPath -Force
} catch {
throw "Could not write to '$DllPath' - the project tree rejects writes. Copy the project somewhere writable and re-run with -ProjectRoot."
}
Write-Status 'PASS' 'restored the original DLL from backup'
exit 0
}
$entry = Get-NameserverEntry -Path $DllPath -Known $knownUrls -Target $Url
if (-not $entry) { throw "Nameserver literal not found in '$DllPath' (unexpected layout)." }
if ($entry.Original -eq $Url) {
Write-Status 'PASS' "already points at $Url"
exit 0
}
$newPayload = [System.Text.Encoding]::Unicode.GetBytes($Url)
$newValue = 2 * $Url.Length + 1
$free = $entry.Footprint - (1 + $newPayload.Length + 1)
if ($free -lt 0) {
throw "Replacement '$Url' ($($Url.Length) chars) does not fit in the original slot ($($entry.Original.Length) chars)."
}
try {
if (-not (Test-Path -LiteralPath $backup)) {
$backupDir = Split-Path -Parent $backup
if (-not (Test-Path -LiteralPath $backupDir)) {
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
}
Copy-Item -LiteralPath $DllPath -Destination $backup
Write-Status 'INFO' "backed up original to $backup"
}
} catch {
throw "Could not write to '$backup' - the project tree rejects writes. Copy the project somewhere writable and re-run with -ProjectRoot."
}
$bytes = [byte[]]::new($entry.Bytes.Length)
[Array]::Copy($entry.Bytes, $bytes, $entry.Bytes.Length)
# Rewrite prefix + payload + terminal byte, then zero the unused tail of the slot.
$cursor = $entry.PrefixOffset
$cursor += Write-CompressedUInt -Bytes $bytes -Offset $cursor -Value $newValue
[Array]::Copy($newPayload, 0, $bytes, $cursor, $newPayload.Length)
$cursor += $newPayload.Length
$bytes[$cursor] = $entry.TerminalByte
$cursor++
for ($i = $cursor; $i -lt ($entry.PrefixOffset + $entry.Footprint); $i++) { $bytes[$i] = 0 }
try {
[System.IO.File]::WriteAllBytes($DllPath, $bytes)
} catch {
throw "Could not write to '$DllPath' - the project tree rejects writes. Copy the project somewhere writable and re-run with -ProjectRoot."
}
# ---- verify -----------------------------------------------------------------------
$check = Get-NameserverEntry -Path $DllPath -Known $knownUrls -Target $Url
if (-not $check -or $check.Original -ne $Url) {
throw "Verification failed: heap entry does not decode to '$Url' after patching."
}
if ([System.IO.File]::ReadAllBytes($DllPath).Length -ne $entry.Bytes.Length) {
throw 'Verification failed: file length changed.'
}
# Every other heap entry must keep its offset, so compare everything outside the slot.
$after = [System.IO.File]::ReadAllBytes($DllPath)
$slotEnd = $entry.PrefixOffset + $entry.Footprint
$diffOutsideSlot = 0
for ($i = 0; $i -lt $after.Length; $i++) {
if ($i -ge $entry.PrefixOffset -and $i -lt $slotEnd) { continue }
if ($after[$i] -ne $entry.Bytes[$i]) { $diffOutsideSlot++ }
}
if ($diffOutsideSlot -ne 0) {
throw "Verification failed: $diffOutsideSlot bytes changed outside the string-heap slot."
}
Write-Status 'PASS' "nameserver: $($entry.Original) -> $Url"
Write-Status 'INFO' "slot $($entry.PrefixOffset), $($entry.Footprint) bytes, $free bytes of padding; file size unchanged"
Write-Status 'INFO' "open this copy from Unity Hub: $(Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $DllPath)))))"
Write-Status 'INFO' "undo with: pwsh -File tools/patch-rrs-nameserver.ps1 -Restore -DllPath `"$DllPath`""
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Slice the client's Player.log by wall-clock window and summarise it.
The Unity log is one flat file per launch whose lines start with
`[HH:MM:SS] [frame] [seconds] [Level] [Tag] ...`; anything that matters after a
test is usually a handful of error lines inside a one-minute window.
Usage:
python tools/player-log-window.py --from 14:21 --to 14:23 [--errors]
python tools/player-log-window.py --grep "studio|rrs" --from 14:22:30
python tools/player-log-window.py --log <path> --from ... --to ... --show
Options:
--errors dedupe and count Error/Warning/Exception lines instead of printing all
--show print every matching line (default when a --grep matches)
--tail N additionally print the last N raw lines of the file
"""
import argparse
import os
import re
import sys
DEFAULT_LOG = os.path.join(
os.environ.get("LOCALAPPDATA", ""), "..", "LocalLow",
"Against Gravity", "Rec Room", "Player.log")
LINE_RE = re.compile(r"^\[(\d\d:\d\d:\d\d(?:\.\d+)?)\]")
def pad(value, tail):
"""`14:22` means the whole minute, `14:22:30` the whole second."""
if not value:
return None
return value if value.count(":") == 2 else value + tail
def in_window(stamp, start, stop):
if start and stamp < start:
return False
if stop and stamp > stop:
return False
return True
def main(argv):
parser = argparse.ArgumentParser()
parser.add_argument("--log", default=os.path.normpath(DEFAULT_LOG))
parser.add_argument("--from", dest="start")
parser.add_argument("--to", dest="stop")
parser.add_argument("--grep")
parser.add_argument("--errors", action="store_true")
parser.add_argument("--show", action="store_true")
parser.add_argument("--tail", type=int, default=0)
args = parser.parse_args(argv[1:])
with open(args.log, "rb") as handle:
blob = handle.read().decode("utf-8", "replace")
lines = blob.split("\n")
print(f"{args.log}: {len(lines)} lines, {len(blob)} bytes")
pattern = re.compile(args.grep, re.I) if args.grep else None
selected = []
for text in lines:
match = LINE_RE.match(text)
if not match:
continue
if not in_window(match.group(1), pad(args.start, ":00"), pad(args.stop, ":59")):
continue
if pattern and not pattern.search(text):
continue
if args.errors and not re.search(r"\[(Error|Warning)\]|Exception:|Error:", text):
continue
selected.append(text.rstrip())
if args.errors or not (args.show or pattern):
counts = {}
order = []
for text in selected:
key = re.sub(r"\d+", "#", text[:220])
if key not in counts:
counts[key] = 0
order.append(key)
counts[key] += 1
for key in order:
print(f" x{counts[key]:<4} {key}")
else:
for text in selected:
print(" " + text[:300])
print(f"--- {len(selected)} selected line(s)")
if args.tail:
print(f"--- last {args.tail} lines ---")
for text in [t for t in lines if t.strip()][-args.tail:]:
print(" " + text[:300])
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+60
View File
@@ -0,0 +1,60 @@
"""Read-only ARM64 inspection with optional on-device method-map annotations."""
import argparse
import struct
import re
from pathlib import Path
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('library', type=Path)
p.add_argument('address', type=lambda s: int(s, 0))
p.add_argument('size', type=lambda s: int(s, 0))
p.add_argument('--map', type=Path)
p.add_argument('--metadata', type=Path, help='Exact Quest metadata for literal annotations')
p.add_argument('--august', action='store_true', help='Use August 31 metadata header fields')
a = p.parse_args()
b = a.library.read_bytes()
ph = struct.unpack_from('<Q', b, 32)[0]
es, n = struct.unpack_from('<HH', b, 54)
mapping = {}
segments = [struct.unpack_from('<IIQQQQQQ', b, ph + i * es) for i in range(n)]
def qword(rva):
for s in segments:
if s[0] == 1 and s[3] <= rva and rva + 8 <= s[3] + s[5]:
return struct.unpack_from('<Q', b, s[2] + rva - s[3])[0]
raise ValueError('Unmapped pointer')
meta = a.metadata.read_bytes() if a.metadata else None
previous = None
if a.map:
for line in a.map.read_text(encoding='utf-8-sig').splitlines():
parts = line.split('\t')
if len(parts) == 6:
mapping[int(parts[0], 16)] = '.'.join(parts[2:5]) + '/' + parts[5]
for i in range(n):
typ, flags, off, va, _, size, _, _ = struct.unpack_from('<IIQQQQQQ', b, ph + i * es)
if typ == 1 and flags & 1 and va <= a.address < va + size:
pos = off + a.address - va
for inst in Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN).disasm(b[pos:pos+a.size], a.address):
note = mapping.get(inst.address, '')
if meta and previous and previous.mnemonic == 'adrp' and inst.mnemonic == 'ldr':
m = re.fullmatch(r'(x\d+), \[(x\d+), #(0x[0-9a-f]+)\]', inst.op_str)
reg, _, page = previous.op_str.partition(', #')
if m and m[2] == reg:
try:
token = qword(qword(int(page, 0) + int(m[3], 0)))
if token >> 29 == 5:
index = (token & 0x1fffffff) >> 1
table = struct.unpack_from('<I', meta, 0x74 if a.august else 0x44)[0]
data = struct.unpack_from('<I', meta, 0xa0 if a.august else 0x28)[0]
length, offset = struct.unpack_from('<II', meta, table + index * 8)
if length < 512:
note += ' literal=' + repr(meta[data+offset:data+offset+length].decode('utf-8'))
except (ValueError, struct.error, UnicodeError):
pass
if inst.mnemonic in ('b', 'bl') and inst.op_str.startswith('#0x'):
note += ' -> ' + mapping.get(int(inst.op_str[1:], 16), '')
print(f'{inst.address:x}: {inst.mnemonic:8} {inst.op_str:36} {note}')
previous = inst
break
else:
raise ValueError('RVA is not in a file-backed executable segment')
+217
View File
@@ -0,0 +1,217 @@
// Exact-build diagnostic. Logs response paths/status and chunked-read completion only.
#include <android/log.h>
#include <dlfcn.h>
#include <jni.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include "quest-image-placeholder.h"
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "RadicadeHTTP", __VA_ARGS__)
static uintptr_t base;
static void (*original)(void *, void *, void *);
static void *(*current_uri)(void *, void *);
static void *(*absolute_path)(void *, void *);
static void (*original_quit)(int, void *, void *);
static void *(*original_send)(void *, void *);
static int (*original_image_verify)(void *, void *);
static int verify_image(void *data, void *signature) {
// This exact-build entry point is image-only, after key-id/header parsing.
// Recognize the private server's legacy placeholder, never arbitrary signatures.
if (data && signature && *(uintptr_t *)((char *)signature + 0x18) == sizeof(image_placeholder)
&& !memcmp((char *)signature + 0x20, image_placeholder, sizeof(image_placeholder))) {
LOG("Radicade legacy image placeholder accepted in image-only verifier");
return 1;
}
return original_image_verify(data, signature);
}
static void *(*object_class)(void *);
static void *(*object_new)(void *);
static void *(*string_new)(const char *);
static void *(*method_named)(void *, const char *, int);
static void *(*invoke)(void *, void *, void **, void **);
static void (*write_barrier)(void *, void **, void *);
static void *inspect_send(void *request, void *method) {
if (!request) return original_send(request, method);
void *uri = *(void **)((char *)request + 0x10);
if (!uri) return original_send(request, method);
void *klass = object_class(uri), *exc = 0;
void *getter = method_named(klass, "get_AbsoluteUri", 0);
if (!getter) return original_send(request, method);
void *str = invoke(getter, uri, 0, &exc);
if (!str || exc) return original_send(request, method);
int len = *(int *)((char *)str + 0x10);
if (len < 0 || len > 1700) return original_send(request, method);
const uint16_t *chars = (const uint16_t *)((char *)str + 0x14);
char url[2048];
for (int i = 0; i < len; ++i) {
if (chars[i] > 127) return original_send(request, method);
url[i] = (char)chars[i];
}
url[len] = 0;
const char *origin = "https://ns.radicade.app/";
if (!strncmp(url, origin, strlen(origin))) {
void *setter = method_named(object_class(request), "set_IsKeepAlive", 1);
if (setter) {
unsigned char enabled = 0;
void *ka_args[] = {&enabled};
invoke(setter, request, ka_args, &exc);
if (exc) return original_send(request, method);
}
}
const char *prefix = "https://ns.radicade.app/images/";
if (strncmp(url, prefix, strlen(prefix)) || strstr(url, "rcsig=") || strchr(url, '#'))
return original_send(request, method);
strcat(url, strchr(url, '?') ? "&" : "?");
strcat(url, "rcsig=KEY%3ARSA%3Ap1.radicade.internal");
void *ctor = method_named(klass, ".ctor", 1);
void *newuri = object_new(klass), *newstr = string_new(url);
if (!ctor || !newuri || !newstr) return original_send(request, method);
void *args[] = {newstr};
invoke(ctor, newuri, args, &exc);
if (!exc) {
write_barrier(request, (void **)((char *)request + 0x10), newuri);
LOG("Image request key-id aligned for Radicade HTTPS origin");
}
return original_send(request, method);
}
static void inspect_quit(int code, void *reason, void *method) {
LOG("fatal quit code=%d caller_rva=%lx reason_chars=%d", code,
(unsigned long)((uintptr_t)__builtin_return_address(0) - base),
reason ? *(int *)((char *)reason + 0x10) : 0);
original_quit(code, reason, method);
}
static void path_for(void *response, char *out, size_t size) {
strcpy(out, "<unknown>");
void *request = *(void **)((char *)response + 0x78);
if (!request) return;
void *uri = current_uri(request, 0);
if (!uri) return;
void *str = absolute_path(uri, 0);
if (!str) return;
int len = *(int *)((char *)str + 0x10);
const uint16_t *chars = (const uint16_t *)((char *)str + 0x14);
if (len < 0) return;
size_t i = 0;
for (; i < (size_t)len && i + 1 < size; ++i) {
// Never include query/fragment data even if a malformed URI reaches us.
if (chars[i] == '?' || chars[i] == '#') break;
out[i] = chars[i] >= 32 && chars[i] < 127 ? (char)chars[i] : '_';
}
out[i] = 0;
}
static void inspect_chunk(void *response, void *stream, void *method) {
char path[192];
path_for(response, path, sizeof(path));
int status = *(int *)((char *)response + 0x18);
LOG("chunk begin response=%p status=%d path=%s", response, status, path);
original(response, stream, method);
LOG("chunk end response=%p", response);
}
static void jump(unsigned char *dst, uintptr_t target) {
uint32_t code[] = {0x58000050, 0xd61f0200}; // ldr x16,+8; br x16
memcpy(dst, code, 8);
memcpy(dst + 8, &target, 8);
}
static void *install(void *unused) {
(void)unused;
sleep(30);
void *lib = dlopen("libil2cpp.so", RTLD_NOW | RTLD_NOLOAD);
if (!lib) { LOG("IL2CPP unavailable; no hook installed"); return 0; }
Dl_info info;
if (!dladdr(dlsym(lib, "il2cpp_domain_get"), &info)) return 0;
base = (uintptr_t)info.dli_fbase;
// ReadChunkLength's unused receiver is optimized away at call sites.
// Hook ReadChunked instead, which actually consumes its response receiver.
unsigned char *target = (unsigned char *)(base + 0x6d941e0);
// Position-independent original prologue; refuse any other code.
uint32_t expected[] = {0xd10203ff, 0xa9026ffc, 0xa90367fa, 0xa9045ff8};
if (memcmp(target, expected, sizeof(expected))) { LOG("Prologue mismatch; no hook installed"); return 0; }
long page = sysconf(_SC_PAGESIZE);
if (page <= 0) return 0;
unsigned char *trampoline = mmap(0, (size_t)page, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (trampoline == MAP_FAILED) return 0;
memcpy(trampoline, target, 16);
jump(trampoline + 16, (uintptr_t)target + 16);
if (mprotect(trampoline, (size_t)page, PROT_READ | PROT_EXEC)) return 0;
__builtin___clear_cache((char *)trampoline, (char *)trampoline + 32);
original = (void (*)(void *, void *, void *))trampoline;
current_uri = (void *(*)(void *, void *))(base + 0x6d8d328);
absolute_path = (void *(*)(void *, void *))(base + 0xa0a956c);
void *start = (void *)((uintptr_t)target & ~((uintptr_t)page - 1));
if (mprotect(start, (size_t)page, PROT_READ | PROT_WRITE | PROT_EXEC)) return 0;
jump(target, (uintptr_t)inspect_chunk);
__builtin___clear_cache((char *)target, (char *)target + 16);
mprotect(start, (size_t)page, PROT_READ | PROT_EXEC);
LOG("Chunk diagnostic active; path/status only");
unsigned char *quit = (unsigned char *)(base + 0x3a1c0b4);
uint32_t quit_expected[] = {0xf81d0ff5, 0xa9014ff4, 0xa9027bfd, 0x910083fd};
if (memcmp(quit, quit_expected, 16)) { LOG("Quit prologue mismatch"); return 0; }
unsigned char *qt = mmap(0, (size_t)page, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (qt == MAP_FAILED) return 0;
memcpy(qt, quit, 16);
jump(qt + 16, (uintptr_t)quit + 16);
if (mprotect(qt, (size_t)page, PROT_READ | PROT_EXEC)) return 0;
__builtin___clear_cache((char *)qt, (char *)qt + 32);
original_quit = (void (*)(int, void *, void *))qt;
void *qp = (void *)((uintptr_t)quit & ~((uintptr_t)page - 1));
if (mprotect(qp, (size_t)page, PROT_READ | PROT_WRITE | PROT_EXEC)) return 0;
jump(quit, (uintptr_t)inspect_quit);
__builtin___clear_cache((char *)quit, (char *)quit + 16);
mprotect(qp, (size_t)page, PROT_READ | PROT_EXEC);
LOG("Quit diagnostic active; code/caller only, original behavior preserved");
object_class = dlsym(lib, "il2cpp_object_get_class");
object_new = dlsym(lib, "il2cpp_object_new");
string_new = dlsym(lib, "il2cpp_string_new");
method_named = dlsym(lib, "il2cpp_class_get_method_from_name");
invoke = dlsym(lib, "il2cpp_runtime_invoke");
write_barrier = dlsym(lib, "il2cpp_gc_wbarrier_set_field");
if (!object_class || !object_new || !string_new || !method_named || !invoke || !write_barrier)
return 0;
unsigned char *send = (unsigned char *)(base + 0x688e200);
uint32_t send_expected[] = {0xd10103ff, 0xa90157f6, 0xa9024ff4, 0xa9037bfd};
if (memcmp(send, send_expected, 16)) { LOG("Send prologue mismatch"); return 0; }
unsigned char *st = mmap(0, (size_t)page, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (st == MAP_FAILED) return 0;
memcpy(st, send, 16);
jump(st + 16, (uintptr_t)send + 16);
if (mprotect(st, (size_t)page, PROT_READ | PROT_EXEC)) return 0;
__builtin___clear_cache((char *)st, (char *)st + 32);
original_send = (void *(*)(void *, void *))st;
void *spage = (void *)((uintptr_t)send & ~((uintptr_t)page - 1));
if (mprotect(spage, (size_t)page, PROT_READ | PROT_WRITE | PROT_EXEC)) return 0;
jump(send, (uintptr_t)inspect_send);
__builtin___clear_cache((char *)send, (char *)send + 16);
mprotect(spage, (size_t)page, PROT_READ | PROT_EXEC);
LOG("Radicade image key-id compatibility active; verification unchanged");
LOG("Radicade fresh-connection experiment active");
unsigned char *iv = (unsigned char *)(base + 0x6deb934);
uint32_t iv_expected[] = {0xd10343ff, 0xf9004bf7, 0xa90a57f6, 0xa90b4ff4};
if (memcmp(iv, iv_expected, 16)) { LOG("Image verifier prologue mismatch"); return 0; }
unsigned char *it = mmap(0, (size_t)page, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (it == MAP_FAILED) return 0;
memcpy(it, iv, 16);
jump(it + 16, (uintptr_t)iv + 16);
if (mprotect(it, (size_t)page, PROT_READ | PROT_EXEC)) return 0;
__builtin___clear_cache((char *)it, (char *)it + 32);
original_image_verify = (int (*)(void *, void *))it;
void *ip = (void *)((uintptr_t)iv & ~((uintptr_t)page - 1));
if (mprotect(ip, (size_t)page, PROT_READ | PROT_WRITE | PROT_EXEC)) return 0;
jump(iv, (uintptr_t)verify_image);
__builtin___clear_cache((char *)iv, (char *)iv + 16);
mprotect(ip, (size_t)page, PROT_READ | PROT_EXEC);
LOG("Legacy image placeholder compatibility active; TLS unchanged");
return 0;
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
(void)vm; (void)reserved;
pthread_t thread;
if (!pthread_create(&thread, 0, install, 0)) pthread_detach(thread);
return JNI_VERSION_1_6;
}
+19
View File
@@ -0,0 +1,19 @@
// Exact legacy Radicade image placeholder; not a cryptographic signature.
static const unsigned char image_placeholder[256] = {
0x21,0x6c,0x1e,0xfe,0x96,0x79,0xbd,0x65,0xaa,0x35,0x29,0x12,0x33,0xfe,0x78,0x8a,
0xc8,0x03,0xc6,0x56,0x64,0x76,0xb3,0xf4,0xb1,0x4a,0xe9,0xa4,0xa0,0x9b,0x36,0x4b,
0x4e,0xdb,0x20,0x8e,0x4e,0x3a,0xbb,0xe3,0xac,0x56,0x8a,0x22,0xec,0xb9,0xe1,0xae,
0x55,0xc0,0x06,0x42,0xa9,0x23,0x79,0x14,0xe2,0x9b,0x1a,0x25,0x1d,0xc9,0x12,0xfd,
0x85,0xd2,0x62,0x41,0x09,0xc6,0xd8,0x82,0x5d,0xb2,0xe5,0xb1,0x18,0x9a,0xd8,0x3c,
0x80,0xaa,0xd5,0x95,0x90,0x65,0xac,0x7d,0xcc,0xf3,0x16,0x1a,0x7f,0xe7,0xa5,0x3a,
0xe8,0x55,0x73,0xa2,0x54,0xec,0xa4,0xf0,0xaf,0x8d,0x27,0x0e,0x56,0x51,0x8e,0xed,
0xd0,0xc8,0x2b,0xbb,0x09,0xfa,0x77,0x5c,0x45,0xab,0x96,0x8e,0xc4,0x2c,0x0b,0xa8,
0x37,0x7b,0xba,0xd4,0x73,0xab,0xb0,0x63,0x4f,0x7d,0x6b,0xa8,0x32,0xee,0xcc,0x3a,
0xc6,0xbe,0x41,0x15,0x94,0x12,0xcf,0xcc,0x20,0xf9,0xfa,0xb2,0x1d,0x80,0xff,0x49,
0xce,0x27,0x41,0x05,0x4e,0x4c,0x56,0x4d,0x34,0x04,0xaa,0x6c,0x96,0x21,0xa4,0xd8,
0x44,0x31,0x74,0x67,0x69,0xf0,0x8c,0xb8,0xb9,0x7c,0x6b,0x79,0xa4,0x1b,0x0b,0xf2,
0xde,0xde,0x2a,0xd8,0x3f,0xfb,0x48,0xce,0x3c,0xc6,0xb1,0x99,0x8c,0x45,0x78,0xee,
0xa3,0x71,0xd4,0x0f,0x75,0x28,0x27,0xb6,0xf1,0x75,0x53,0xa0,0x58,0x78,0x07,0x3c,
0x04,0x51,0x4f,0x27,0x67,0x47,0x7e,0x2b,0x05,0x7b,0xf4,0xc1,0x9a,0xac,0xba,0x35,
0x71,0x8f,0x22,0x81,0xcf,0x05,0x72,0x84,0xe1,0x29,0xca,0x2f,0x23,0x80,0xa5,0x3a
};
+124
View File
@@ -0,0 +1,124 @@
// Read-only runtime metadata inspection; no account values or method calls.
#include <android/log.h>
#include <dlfcn.h>
#include <jni.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "RadicadePlatform", __VA_ARGS__)
#define API(name, ret, args) ret (*name) args = (ret (*) args)dlsym(lib, #name); if (!name) { LOG("Missing API: %s", #name); return 0; }
static void *inspect(void *unused) {
(void)unused;
// Let Unity finish its single-threaded runtime initialization first.
sleep(30);
void *lib = 0;
for (int i = 0; i < 120 && !lib; ++i) {
lib = dlopen("libil2cpp.so", RTLD_NOW | RTLD_NOLOAD);
if (!lib) sleep(1);
}
if (!lib) { LOG("IL2CPP not loaded within 120 seconds"); return 0; }
API(il2cpp_domain_get, void *, (void));
API(il2cpp_domain_get_assemblies, const void **, (void *, size_t *));
API(il2cpp_thread_attach, void *, (void *));
API(il2cpp_thread_detach, void, (void *));
API(il2cpp_assembly_get_image, void *, (const void *));
API(il2cpp_image_get_name, const char *, (void *));
API(il2cpp_image_get_class_count, size_t, (void *));
API(il2cpp_image_get_class, void *, (void *, size_t));
API(il2cpp_class_get_name, const char *, (void *));
API(il2cpp_class_get_namespace, const char *, (void *));
API(il2cpp_class_from_name, void *, (void *, const char *, const char *));
API(il2cpp_class_get_methods, const void *, (void *, void **));
API(il2cpp_method_get_name, const char *, (const void *));
API(il2cpp_method_get_param_count, uint32_t, (const void *));
API(il2cpp_class_get_fields, void *, (void *, void **));
API(il2cpp_field_get_name, const char *, (void *));
API(il2cpp_field_get_offset, size_t, (void *));
API(il2cpp_field_get_type, void *, (void *));
API(il2cpp_type_get_name, char *, (void *));
API(il2cpp_free, void, (void *));
void *domain = 0;
size_t count = 0;
const void **assemblies = 0;
for (int i = 0; i < 120; ++i) {
domain = il2cpp_domain_get();
if (domain) assemblies = il2cpp_domain_get_assemblies(domain, &count);
if (count) break;
sleep(1);
}
if (!domain || !count) { LOG("IL2CPP domain unavailable"); return 0; }
void *thread = il2cpp_thread_attach(domain);
if (!thread) { LOG("Thread attach failed"); return 0; }
char fields_path[192], map_path[192];
unsigned user = (unsigned)getuid() / 100000;
snprintf(fields_path, sizeof(fields_path), "/data/user/%u/com.radicade.questtls/files/quest-recnet-fields.tsv", user);
snprintf(map_path, sizeof(map_path), "/data/user/%u/com.radicade.questtls/files/quest-method-map.tsv", user);
FILE *fields = fopen(fields_path, "w");
FILE *map = fopen(map_path, "w");
if (map) {
for (size_t a = 0; a < count; ++a) {
void *img = il2cpp_assembly_get_image(assemblies[a]);
size_t classes = il2cpp_image_get_class_count(img);
for (size_t c = 0; c < classes; ++c) {
void *klass = il2cpp_image_get_class(img, c);
if (!klass) continue;
if (fields && !strncmp(il2cpp_image_get_name(img), "RecNet.", 7)) {
void *fi = 0, *f;
while ((f = il2cpp_class_get_fields(klass, &fi))) {
char *tn = il2cpp_type_get_name(il2cpp_field_get_type(f));
fprintf(fields, "%s\t%s\t%zx\t%s\n", il2cpp_class_get_name(klass), il2cpp_field_get_name(f), il2cpp_field_get_offset(f), tn ? tn : "?");
if (tn) il2cpp_free(tn);
}
}
void *iter = 0;
const void *method;
while ((method = il2cpp_class_get_methods(klass, &iter))) {
void *code = *(void *const *)method;
Dl_info info = {0};
if (code && dladdr(code, &info))
fprintf(map, "%lx\t%s\t%s\t%s\t%s\t%u\n",
(unsigned long)((uintptr_t)code - (uintptr_t)info.dli_fbase),
il2cpp_image_get_name(img), il2cpp_class_get_namespace(klass),
il2cpp_class_get_name(klass), il2cpp_method_get_name(method),
il2cpp_method_get_param_count(method));
}
}
}
fclose(map);
LOG("Full method map saved (metadata only)");
} else LOG("Cannot open method map output");
const char *names[] = {"PlatformManager", "PlatformManagerBootstrapper", "OculusPlatformManager", "StandalonePlatformManager", "GooglePlayPlatformManager", "BootSequence"};
for (size_t a = 0; a < count; ++a) {
void *img = il2cpp_assembly_get_image(assemblies[a]);
if (strcmp(il2cpp_image_get_name(img), "Assembly-CSharp.dll")) continue;
for (size_t n = 0; n < sizeof(names)/sizeof(names[0]); ++n) {
void *klass = il2cpp_class_from_name(img, "", names[n]);
LOG("Class %s found=%d", names[n], klass != 0);
if (!klass) continue;
void *iter = 0;
const void *method;
while ((method = il2cpp_class_get_methods(klass, &iter))) {
void *code = *(void *const *)method;
Dl_info info = {0};
uintptr_t rva = dladdr(code, &info) ? (uintptr_t)code - (uintptr_t)info.dli_fbase : 0;
LOG("Method %s.%s argc=%u rva=0x%lx", names[n], il2cpp_method_get_name(method), il2cpp_method_get_param_count(method), (unsigned long)rva);
}
iter = 0;
void *field;
while ((field = il2cpp_class_get_fields(klass, &iter)))
LOG("Field %s.%s offset=0x%lx", names[n], il2cpp_field_get_name(field), (unsigned long)il2cpp_field_get_offset(field));
}
}
if (fields) fclose(fields);
il2cpp_thread_detach(thread);
LOG("Inspection complete");
return 0;
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
(void)vm; (void)reserved;
pthread_t thread;
if (!pthread_create(&thread, 0, inspect, 0)) pthread_detach(thread);
return JNI_VERSION_1_6;
}
+56
View File
@@ -0,0 +1,56 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../artifacts/recroom-network-mirror');
const origin = 'https://recroom.network';
const allowed = new Set(['recroom.network', 'cdn.recroom.network']);
const queue = ['/', '/rooms', '/events', '/news', '/creator', '/shop', '/download'].map(p => origin + p);
const seen = new Set();
const manifest = [];
await fs.mkdir(root, { recursive: true });
function localPath(url) {
return (url.hostname === 'cdn.recroom.network' ? '/mirror-cdn' : '') + url.pathname;
}
function rewrite(text) {
return text.replaceAll('https://cdn.recroom.network', '/mirror-cdn')
.replaceAll('https://recroom.network', '');
}
function discover(text, base) {
const add = raw => {
try {
const value = raw.replaceAll('&amp;', '&').replaceAll('\\/', '/');
const url = new URL(value, base);
if (!allowed.has(url.hostname) || url.protocol !== 'https:') return;
if (!/\.(?:js|css|woff2?|ttf|png|jpe?g|webp|svg|ico|avif)(?:$|\?)/i.test(url.pathname + url.search)) return;
url.hash = ''; if (!seen.has(url.href)) queue.push(url.href);
} catch {}
};
for (const m of text.matchAll(/(?:src|href|poster)=["']([^"']+)["']/g)) add(m[1]);
for (const m of text.matchAll(/url\(["']?([^\s)'";]+)["']?\)/g)) add(m[1]);
for (const m of text.matchAll(/["'`](https:\/\/[^"'`\s<>]+|\/(?:_next|static|logos|images)\/[^"'`\s<>]+)["'`]/g)) add(m[1]);
for (const m of text.matchAll(/["'](static\/(?:chunks|media)\/[^"']+)["']/g)) add('/_next/' + m[1]);
}
while (queue.length && seen.size < 350) {
const source = queue.shift(); if (seen.has(source)) continue; seen.add(source);
try {
const response = await fetch(source, { signal: AbortSignal.timeout(25000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const type = response.headers.get('content-type') || '';
const url = new URL(source);
let output = localPath(url);
if (type.includes('text/html')) output = output.replace(/\/$/, '') + '/index.html';
const filename = path.resolve(root, '.' + output);
if (!filename.startsWith(root + path.sep)) throw new Error('Invalid output path');
const bytes = Buffer.from(await response.arrayBuffer());
await fs.mkdir(path.dirname(filename), { recursive: true });
if (/javascript|text\/css|text\/html/.test(type)) {
const sourceText = bytes.toString('utf8'); discover(sourceText, source);
await fs.writeFile(filename, rewrite(sourceText));
} else await fs.writeFile(filename, bytes);
manifest.push({ source, file: output, type, bytes: bytes.length });
console.log(`${manifest.length}: ${output} (${bytes.length})`);
} catch (error) { manifest.push({ source, error: error.message }); console.log(`FAILED ${source}: ${error.message}`); }
}
await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ capturedAt: new Date().toISOString(), source: origin, files: manifest }, null, 2));
console.log(`Saved ${manifest.filter(x => !x.error).length} files; ${manifest.filter(x => x.error).length} failed; ${queue.length} queued. ${root}`);
+75
View File
@@ -0,0 +1,75 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
export function upstreamFor(url) {
const routes = {
'/_public/rooms/featuredrooms/current': 'https://rooms.recroom.network/featuredrooms/current',
'/_public/rooms/rooms/bulk': 'https://rooms.recroom.network/rooms/bulk',
'/_public/accounts/account/bulk': 'https://accounts.recroom.network/account/bulk',
'/_public/apim/apis/api/images/v3/feed/global': 'https://apim.recroom.network/apis/api/images/v3/feed/global',
};
if (routes[url.pathname]) {
const target = new URL(routes[url.pathname]);
for (const [key,value] of url.searchParams) {
if (!['id','ids','skip','take','include'].includes(key) || !/^[0-9,]{1,2000}$/.test(value)) return null;
target.searchParams.append(key,value);
}
return target;
}
const image = url.pathname.match(/^\/_public\/img\/([a-zA-Z0-9_.%-]{1,180})$/);
if (image) {
return new URL('https://img.recroom.network/' + image[1]);
}
return null;
}
export async function servePublicCache(url, req, res, root) {
if (!url.pathname.startsWith('/_public/')) return false;
const upstream = upstreamFor(url);
if (!upstream) { res.writeHead(404); res.end(); return true; }
const key = createHash('sha256').update(upstream.href).digest('hex');
const dir = path.join(root, 'public-cache');
try {
let bytes, type;
try {
bytes = await fs.readFile(path.join(dir,key));
type = await fs.readFile(path.join(dir,key+'.type'),'utf8');
} catch {
const response = await fetch(upstream, {redirect:'error', signal:AbortSignal.timeout(20000)});
if (response.status === 404 && upstream.hostname === 'img.recroom.network') {
res.writeHead(200, {'Content-Type':'image/svg+xml','X-Mirror-Placeholder':'source-image-missing'});
res.end('<svg xmlns="http://www.w3.org/2000/svg" width="480" height="300"><rect width="480" height="300" fill="#303030"/><text x="240" y="155" fill="#aaa" font-family="Arial" font-size="20" text-anchor="middle">Image unavailable in source archive</text></svg>');
return true;
}
if (!response.ok) throw new Error(`Public source returned ${response.status}`);
type = response.headers.get('content-type') || 'application/octet-stream';
bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > 15 * 1024 * 1024) throw new Error('Response too large');
await fs.mkdir(dir,{recursive:true});
await fs.writeFile(path.join(dir,key),bytes);
await fs.writeFile(path.join(dir,key+'.type'),type);
}
res.writeHead(200, {'Content-Type':type,'Cache-Control':'public, max-age=3600'});
res.end(req.method === 'HEAD' ? undefined : bytes);
} catch(error) {
console.error('Public cache:', upstream.href, error.message);
res.writeHead(502, {'Content-Type':'application/json'});
res.end(JSON.stringify({error:'public_snapshot_unavailable'}));
}
return true;
}
export const bridgeScript = `<script>
const mirrorFetch=window.fetch.bind(window);
window.fetch=function(input,init){
const u=new URL(input instanceof Request?input.url:input,location.href);
const routes={'rooms.recroom.network':'rooms','apim.recroom.network':'apim','accounts.recroom.network':'accounts'};
if(routes[u.hostname]){
const method=(init?.method||(input instanceof Request?input.method:'GET')).toUpperCase();
if(method!=='GET'&&method!=='HEAD')return Promise.resolve(new Response('{"error":"offline_preview"}',{status:501}));
return mirrorFetch('/_public/'+routes[u.hostname]+u.pathname+u.search,{method,signal:init?.signal,credentials:'omit',cache:'no-store'});
}
return mirrorFetch(input,init);
};
</script>`;
+80
View File
@@ -0,0 +1,80 @@
const server = new URL(process.env.RADICADE_WEB_SERVER || 'https://ns.radicade.app');
if (!['https:', 'http:'].includes(server.protocol) || server.username || server.password || server.search || server.hash || server.pathname !== '/') throw new Error('RADICADE_WEB_SERVER must be a server origin');
export const backendOrigin = server.origin;
const galleryIds = (process.env.RADICADE_GALLERY_IDS || '2').split(',').filter(x => /^\d+$/.test(x));
const memory = new Map();
const inflight = new Map();
async function json(route) {
if (memory.get(route)?.until > Date.now()) return memory.get(route).data;
if (inflight.has(route)) return inflight.get(route);
const pending = (async () => {
const r = await fetch(backendOrigin + route, {redirect:'error',signal:AbortSignal.timeout(20000)});
if (!r.ok) throw new Error(`Radicade ${route.split('?')[0]} returned ${r.status}`);
const data = await r.json(); memory.set(route,{data,until:Date.now()+15000}); return data;
})();
inflight.set(route,pending);
try { return await pending; } finally { inflight.delete(route); }
}
export function publicRooms(rows) { return rows.filter(x => x.Accessibility === 1 && x.State === 0 && !x.IsDorm); }
export function roomCard(x) {
return {RoomId:String(x.RoomId),Name:x.Name,Description:x.Description ?? null,ImageName:x.ImageName || 'default',
CreatorAccountId:x.CreatorAccountId == null ? null : String(x.CreatorAccountId),Accessibility:1,IsRRO:!!x.IsRRO,IsDorm:false,
Stats:{CheerCount:x.Stats?.CheerCount ?? 0,VisitCount:x.Stats?.VisitCount ?? 0}};
}
export function accountCard(x) {
return {accountId:String(x.accountId),username:x.username,displayName:x.displayName,profileImage:x.profileImage || 'default',
isJunior:!!x.isJunior,platforms:x.platforms ?? 0,createdAt:iso(x.createdAt),identityFlags:x.identityFlags ?? 0,
personalPronouns:x.personalPronouns ?? 0,isMetaPlatformBlocked:false};
}
function iso(date) { return /(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : date+'Z'; }
export function photoCard(x) {
return {...x,Id:String(x.Id),PlayerId:String(x.PlayerId),RoomId:x.RoomId == null ? null : String(x.RoomId),
PlayerEventId:x.PlayerEventId == null ? null : String(x.PlayerEventId),Description:x.Description ?? null,
CreatedAt:iso(x.CreatedAt),TaggedPlayerIds:(x.TaggedPlayerIds || []).map(String)};
}
async function rooms() { const data=await json('/rooms/rooms/hot'); return publicRooms(data.Results || []); }
function ids(url) {
const values=url.searchParams.getAll('id');
if (values.length>100 || values.some(x=>!/^\d{1,18}$/.test(x))) throw new Error('Invalid IDs');
return values;
}
function idQuery(values) { return new URLSearchParams(values.map(x=>['id',String(x)])).toString(); }
export async function serveRadicade(url,req,res) {
if (!url.pathname.startsWith('/_public/') && url.pathname !== '/_adapter/status') return false;
try {
let data;
if (url.pathname === '/_adapter/status') data={backend:backendOrigin,mode:'radicade',photoFeed:'public galleries of room creators and configured accounts',galleryIds};
else if (url.pathname === '/_public/rooms/featuredrooms/current') {
const list=await rooms(); list.sort((a,b)=>(b.Stats?.VisitCount||0)-(a.Stats?.VisitCount||0));
data={Rooms:list.slice(0,6).map(x=>({RoomId:String(x.RoomId)}))};
} else if (url.pathname === '/_public/rooms/rooms/bulk') {
const wanted=new Set(ids(url)); data=(await rooms()).filter(x=>wanted.has(String(x.RoomId))).map(roomCard);
} else if (url.pathname === '/_public/accounts/account/bulk') {
const wanted=ids(url); data=wanted.length ? (await json('/accounts/account/bulk?'+idQuery(wanted))).map(accountCard) : [];
} else if (url.pathname === '/_public/apim/apis/api/images/v3/feed/global') {
const skip=Number(url.searchParams.get('skip') || 0),take=Number(url.searchParams.get('take') || 10);
if (!Number.isInteger(skip)||!Number.isInteger(take)||skip<0||skip>10000||take<1||take>100) throw new Error('Invalid pagination');
const creators=[...new Set([...galleryIds,...(await rooms()).map(x=>String(x.CreatorAccountId))])].slice(0,50);
const images=[];
for(const id of creators) images.push(...await json('/api/images/v5/player/'+id));
const unique=[...new Map(images.filter(x=>x.Accessibility===1 && x.Type===1).map(x=>[String(x.Id),x])).values()];
unique.sort((a,b)=>new Date(b.CreatedAt)-new Date(a.CreatedAt));
data=unique.slice(skip,skip+take).map(photoCard);
} else if (/^\/_public\/img\/[a-zA-Z0-9_.%-]{1,180}$/.test(url.pathname)) {
const name=url.pathname.split('/').pop();
if (decodeURIComponent(name).includes('/') || decodeURIComponent(name).includes('\\') || name === '..') throw new Error('Invalid image');
const r=await fetch(backendOrigin+'/images/img/'+name,{redirect:'error',signal:AbortSignal.timeout(20000)});
if (!r.ok) throw new Error('Radicade image returned '+r.status);
const type=r.headers.get('content-type') || '';
if (!type.startsWith('image/')) throw new Error('Unexpected image type');
res.writeHead(200,{'Content-Type':type,'Cache-Control':'public, max-age=60'});
res.end(req.method==='HEAD'?undefined:Buffer.from(await r.arrayBuffer())); return true;
} else {res.writeHead(404,{'Content-Type':'application/json'});res.end('{"error":"adapter_route_not_connected"}');return true;}
res.writeHead(200,{'Content-Type':'application/json','Cache-Control':'no-store','X-Radicade-Backend':backendOrigin});
res.end(req.method==='HEAD'?undefined:JSON.stringify(data));
} catch(error) {
console.error('Radicade adapter:',error.message);
res.writeHead(502,{'Content-Type':'application/json'});res.end(JSON.stringify({error:'radicade_unavailable',message:error.message}));
}
return true;
}
+46
View File
@@ -0,0 +1,46 @@
import http from 'node:http';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { servePublicCache, bridgeScript } from './public-cache.mjs';
import { serveRadicade, backendOrigin } from './radicade-adapter.mjs';
const archiveMode = process.env.RECNET_DATA_MODE === 'archive';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../artifacts/recroom-network-mirror');
const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.avif':'image/avif','.ico':'image/x-icon','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'};
http.createServer(async (req, res) => {
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; media-src 'self' blob:; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'no-store');
if (!['GET','HEAD'].includes(req.method)) { res.writeHead(501, {'Content-Type':'application/json'}); res.end('{"error":"offline_preview","message":"Login and server actions are not connected in this local copy."}'); return; }
try {
const url = new URL(req.url, 'http://127.0.0.1');
if (!archiveMode && url.pathname.startsWith('/_radicade/v2/')) {
url.pathname = url.pathname.replace('/_radicade/v2/', '/_public/');
}
if (archiveMode ? await servePublicCache(url, req, res, root) : await serveRadicade(url, req, res)) return;
if (url.pathname === '/static/img/NoSearchResult.png') {
res.writeHead(200, {'Content-Type':'image/svg+xml'});
res.end('<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120"><text x="80" y="70" fill="#aaa" font-size="42" text-anchor="middle">[ = )</text></svg>');
return;
}
const filename = path.resolve(root, '.' + url.pathname);
if (filename !== root && !filename.startsWith(root + path.sep)) throw new Error('Invalid path');
let actual = filename;
const stat = await fs.stat(actual);
if (stat.isDirectory()) actual = path.join(actual, 'index.html');
let content = await fs.readFile(actual);
const ext = path.extname(actual);
const dataPrefix = archiveMode ? '/_public/' : '/_radicade/v2/';
if (ext === '.js' || ext === '.html') content = Buffer.from(content.toString()
.replaceAll('https://img.recroom.network/', dataPrefix + 'img/')
.replaceAll('/_public/img/', dataPrefix + 'img/'));
if (ext === '.html') {
const previewScript = `<script>document.addEventListener('click',function(e){const a=e.target.closest('a');const b=e.target.closest('button');if(b&&/^log\\s?in$/i.test(b.textContent.trim())){e.preventDefault();e.stopImmediatePropagation();alert('Login is not connected in this local preview.');return;}if(a){const u=new URL(a.href,location.href);if(u.origin===location.origin&&!u.hash){e.preventDefault();e.stopImmediatePropagation();location.assign(u.href);}}},true);</script>`;
const sourceLabel=archiveMode?'Cached recroom.network data':'Radicade public data · '+backendOrigin.replace(/[&<>"']/g,'');
const activeBridge = bridgeScript.replaceAll('/_public/', dataPrefix);
content = Buffer.from(content.toString().replace('<head>', '<head>' + activeBridge + previewScript).replace('</body>', '<div style="position:fixed;bottom:12px;left:12px;z-index:2147483647;background:#202020;color:#fff;padding:10px 16px;border:1px solid #888;border-radius:8px;font:13px Arial;max-width:90vw">Local copy · '+sourceLabel+' · Login not connected</div></body>'));
}
res.writeHead(200, {'Content-Type':types[ext] || 'application/octet-stream'});
res.end(req.method === 'HEAD' ? undefined : content);
} catch { res.writeHead(404, {'Content-Type':'application/json'}); res.end('{"error":"not_archived","message":"This file or API is not available in the local copy."}'); }
}).listen(3040, '127.0.0.1', () => console.log('Local website copy: http://127.0.0.1:3040'));
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Groups the server into feature folders. Run with --plan to print, --apply to move.
#
# Only files move. Namespaces stay exactly as they are, because a namespace rewrite would touch
# every using in the repo for no functional gain - C# does not require a file's folder to match
# its namespace - and this way the whole change is a rename that `git log --follow` tracks.
# The project is SDK-style (globbed), so moved sources compile without editing Server.csproj,
# except for the two JSON resources the csproj names by path (starter avatar items, webhooks).
set -u
mode="${1:---plan}"
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
moved=0
skipped=0
move_file() {
local src="$1" dest="$2"
[ -e "$src" ] || { return 0; }
local name; name="$(basename "$src")"
if [ -e "$dest/$name" ]; then
echo "SKIP (exists): $src -> $dest/$name"
skipped=$((skipped + 1))
return 0
fi
echo "$src -> $dest/$name"
moved=$((moved + 1))
if [ "$mode" = "--apply" ]; then
mkdir -p "$dest"
git mv "$src" "$dest/$name"
fi
}
# Every *.cs under a directory moves to a feature folder.
move_dir() {
local src="$1" dest="$2"
[ -d "$src" ] || { return 0; }
while IFS= read -r file; do
move_file "$file" "$dest"
done < <(find "$src" -name '*.cs' -not -path '*/obj/*' -not -path '*/bin/*' | sort)
}
move_glob() {
local pattern="$1" dest="$2"
for file in $pattern; do
[ -e "$file" ] || continue
move_file "$file" "$dest"
done
}
# ---------------------------------------------------------------- players, accounts, auth
move_dir "Server/API/Accounts" "Server/Players"
move_dir "Server/API/Players" "Server/Players"
move_dir "Server/API/Link" "Server/Players"
move_glob "Server/API/Public/PublicPlayerAPI.cs" "Server/Players"
move_dir "Server/RecNet/Accounts" "Server/Players"
move_glob "Server/Utils/PlayerUtils.cs Server/Utils/UsernameRules.cs Server/Utils/RegistrationGuard.cs Server/Utils/AuthSecurityMonitor.cs Server/Utils/FirstTimeUtils.cs" "Server/Players"
move_dir "Server/Auth" "Server/Auth"
move_dir "Server/RecNet/Auth" "Server/Auth"
move_glob "Server/Utils/Attributes/RecAuth.cs" "Server/Auth"
# ---------------------------------------------------------------- avatars, custom items, locker
move_glob "Server/API/Avatars/CustomAvatarItemsAPI.cs" "Server/CustomAvatarItems"
move_glob "Server/RecNet/Avatar/CustomAvatarItemDB.cs" "Server/CustomAvatarItems"
move_dir "Server/API/Avatars" "Server/Avatars"
move_glob "Server/RecNet/Avatar/*.cs" "Server/Avatars"
move_glob "Server/RecNet/Avatar/*.json" "Server/Avatars"
# ---------------------------------------------------------------- gifts and reward boxes
move_glob "Server/RecNet/Avatar/Gift.cs Server/RecNet/CardBoxWord.cs" "Server/Gift"
move_glob "Server/Utils/FriendotronService.cs Server/Utils/GameRewardRedemption.cs Server/Utils/RroRewardPools.cs Server/Utils/RoomJoinGiftUtils.cs Server/Utils/QuestRewardDelivery.cs" "Server/Gift"
move_dir "Server/Radicade/Gifts" "Server/Gift"
# ---------------------------------------------------------------- images
move_glob "Server/API/ImagesAPI.cs Server/API/Public/PublicPhotosAPI.cs" "Server/Images"
move_dir "Server/Images" "Server/Images"
move_dir "Server/RecNet/Images" "Server/Images"
move_glob "Server/Radicade/Public/PublicPhotoData.cs" "Server/Images"
# ---------------------------------------------------------------- moderation family
move_glob "Server/API/Moderation/AppealAPI.cs" "Server/BanAppeal"
move_glob "Server/API/Moderation/PlayerReportingAPI.cs Server/API/Moderation/ContentReportAPI.cs Server/API/Moderation/HileAPI.cs" "Server/PlayerReporting"
move_glob "Server/RecNet/Moderation/Hile.cs Server/RecNet/Moderation/BlockDetails.cs Server/RecNet/Moderation/SanitizeStringRequest.cs Server/RecNet/Moderation/IsPureResponse.cs" "Server/PlayerReporting"
move_glob "Server/API/Moderation/ReputationAPI.cs Server/RecNet/Moderation/Reputation.cs" "Server/PlayerReputation"
move_glob "Server/RecNet/Moderation/CheerPlayerRequest.cs" "Server/PlayerCheer"
move_glob "Server/RecNet/Moderation/PlayerWarning.cs" "Server/PlayerWarnings"
move_glob "Server/RecNet/Moderation/ModerationDB.cs Server/RecNet/Moderation/IpBan.cs Server/RecNet/Moderation/HardwareBan.cs Server/RecNet/Moderation/DeviceId.cs Server/RecNet/Moderation/GameBlock.cs Server/RecNet/Moderation/VoteKickLog.cs Server/RecNet/Moderation/VoteKickReason.cs Server/RecNet/Moderation/AccountCreationBlock.cs" "Server/PlayersBanned"
move_glob "Server/Utils/AccountLock.cs Server/Utils/BanStatusMonitor.cs Server/Utils/GameBlockMonitor.cs Server/Utils/TokenRevocation.cs" "Server/PlayersBanned"
move_glob "Server/RecNet/Moderation/VoiceChatRestriction.cs Server/Utils/ModerationPolicy.cs Server/API/SanitizeAPI.cs" "Server/Moderation"
move_glob "Server/RecNet/Moderation/OkResponse.cs" "Server/Moderation"
# ---------------------------------------------------------------- events, challenges, progression
move_dir "Server/API/Events" "Server/PlayerEvents"
move_dir "Server/RecNet/Events" "Server/PlayerEvents"
move_dir "Server/Utils/Objectives" "Server/PlayerEvents"
move_dir "Server/API/Progression" "Server/ProgressionEvents"
move_dir "Server/API/GameRewards" "Server/ProgressionEvents"
move_dir "Server/RecNet/Progression" "Server/ProgressionEvents"
move_dir "Server/RecNet/Enums/Challenges" "Server/ProgressionEvents/Enums"
move_glob "Server/Utils/LevelRewardTable.cs Server/Utils/LevelUpGiftService.cs Server/Utils/LevelUpRewardPool.cs Server/Utils/ProgressionAwardService.cs Server/Utils/RewardDay.cs Server/Utils/RewardTrace.cs Server/Utils/WeeklyChallenges.cs" "Server/ProgressionEvents"
# ---------------------------------------------------------------- chat, clubs, relationships
move_glob "Server/Chat/Thread.cs Server/Chat/ChatPhotoMessages.cs Server/Chat/RecentImageReferences.cs" "Server/Chat"
move_dir "Server/API/Messages" "Server/Messages"
move_dir "Server/RecNet/Messages" "Server/Messages"
move_dir "Server/Clubs" "Server/Clubs"
move_dir "Server/API/Relationships" "Server/Relationships"
move_dir "Server/RecNet/Relationships" "Server/Relationships"
move_glob "Server/API/AnnouncementAPI.cs" "Server/Clubs/Announcements"
# ---------------------------------------------------------------- economy
move_dir "Server/API/CampusCard" "Server/Commerce"
move_dir "Server/RecNet/Store/CampusCard" "Server/Commerce"
move_glob "Server/Commerce/CommercePlaceholderAPI.cs" "Server/Commerce"
move_dir "Server/API/Storefront" "Server/Storefront"
move_dir "Server/RecNet/Storefront" "Server/Storefront"
move_glob "Server/RecNet/Store/*.cs" "Server/Storefront"
move_dir "Server/API/IncentivizedReferrals" "Server/Promotions"
move_dir "Server/API/InfluencerPartnerProgram" "Server/Promotions"
move_dir "Server/RecNet/Store/Referrals" "Server/Promotions"
move_dir "Server/API/Wishlists" "Server/Storefront"
# ---------------------------------------------------------------- rooms and play
move_dir "Server/API/Rooms" "Server/Rooms"
move_dir "Server/RoomServer" "Server/Rooms"
move_dir "Server/RecNet/Rooms" "Server/Rooms"
move_glob "Server/Utils/RoomUtils.cs Server/Utils/RoomProgressionService.cs Server/Utils/DefaultRooms.cs" "Server/Rooms"
move_glob "Server/CDN/RoomsCDN.cs" "Server/Rooms"
move_glob "Server/Radicade/Public/PublicRoomData.cs" "Server/Rooms"
move_dir "Server/Lists" "Server/Rooms/Lists"
move_dir "Server/API/Matchmaking" "Server/Matchmaking"
move_dir "Server/RecNet/Matchmaking" "Server/Matchmaking"
move_glob "Server/Utils/MatchmakingUtils.cs Server/Utils/MatchmakingRuntimeState.cs" "Server/Matchmaking"
move_dir "Server/API/Activities" "Server/Activities"
move_dir "Server/API/Royale" "Server/Royale"
move_dir "Server/RecNet/Royale" "Server/Royale"
move_dir "Server/API/Board" "Server/Board"
move_dir "Server/RecNet/Board" "Server/Board"
move_dir "Server/RecNet/Enums/Board" "Server/Board/Enums"
move_dir "Server/API/Keepsakes" "Server/Keepsakes"
move_dir "Server/RecNet/Keepsakes" "Server/Keepsakes"
move_dir "Server/API/Inventions" "Server/Inventions"
move_dir "Server/RecNet/Inventions" "Server/Inventions"
move_dir "Server/RecNet/Enums/Inventions" "Server/Inventions/Enums"
move_glob "Server/CDN/InventionCDN.cs" "Server/Inventions"
# ---------------------------------------------------------------- platform, tooling, publishing
move_dir "Server/API/Notify" "Server/Notifications"
move_dir "Server/API/Notifications" "Server/Notifications"
move_dir "Server/RecNet/Notifications" "Server/Notifications"
move_dir "Server/Leaderboard" "Server/Leaderboards"
move_dir "Server/RecNet/Leaderboards" "Server/Leaderboards"
move_glob "Server/CDN/*.cs" "Server/CDN"
move_dir "Server/RecNet/CDN" "Server/CDN"
move_dir "Server/Storage/StudioAssetStorage.cs" "Server/Studio"
move_glob "Server/Storage/StorageAPI.cs" "Server/Storage"
move_dir "Server/API/Studio" "Server/Studio"
move_glob "Server/CDN/StudioAssetsCDN.cs" "Server/Studio"
move_dir "Server/API/Launcher" "Server/Launcher"
move_glob "Server/API/VersionCheckAPI.cs" "Server/Launcher"
move_dir "Server/RecNet/VersionCheck" "Server/Launcher"
move_glob "Server/API/ServiceRootHealthAPI.cs" "Server/Health"
move_dir "Server/NS" "Server/NameServer"
move_glob "Server/RecNet/NameServerResponse.cs" "Server/NameServer"
move_dir "Server/API/Analytics" "Server/Analytics"
move_dir "Server/API/Logs" "Server/Analytics"
move_dir "Server/RecNet/GameSight" "Server/Analytics"
move_dir "Server/API/Config" "Server/Config"
move_dir "Server/RecNet/Config" "Server/Config"
move_glob "Server/Utils/GameConfigSeeder.cs" "Server/Config"
move_dir "Server/API/BugReporting" "Server/BugReporting"
move_dir "Server/Admin" "Server/Admin"
move_glob "Server/Utils/AdminRuntime.cs Server/Utils/AdminUtils.cs" "Server/Admin"
move_dir "Server/Discord" "Server/Discord"
move_dir "Server/Radicade/Discord" "Server/Discord"
move_glob "Server/Radicade/Mail/*.cs" "Server/Mail"
move_glob "Server/Utils/LoadingScreenImages.cs Server/RecNet/LoadingScreenTip.cs" "Server/LoadingScreens"
move_glob "Server/API/TestCaseManagement/*.cs" "Server/Tooling"
move_glob "Server/Debug/*.cs" "Server/Tooling"
move_dir "Server/Custom" "Server/Tooling/Custom"
move_dir "Server/Web" "Server/Web"
move_glob "Server/Utils/StringTools.cs Server/Utils/BuildInfo.cs" "Server/Utils"
echo
echo "planned: $moved file(s), skipped: $skipped (mode $mode)"
if [ "$mode" != "--apply" ]; then
echo "re-run with --apply to move them"
fi
+238
View File
@@ -0,0 +1,238 @@
<#
.SYNOPSIS
Puts back the Roslyn script compiler that Windows Defender quarantines out of the Unity
package cache, so the editor can compile the project at all.
.DESCRIPTION
Unity 2020.3 compiles C# by launching an *external* compiler, and because this project
installs `com.unity.roslyn` (a transitive dependency of `com.unity.entities`) that compiler
is the one shipped inside the package:
<ProjectRoot>\Library\PackageCache\com.unity.roslyn@<hash>\Compiler~\win64\csc.exe
Defender quarantines unsigned executables out of Unity package folders on this machine - the
recorded casualties are `com.unity.burst\.Runtime\hostwin\llvm-lipo.exe` and `dsymutil.exe`
(heuristic PUA signatures, ThreatID 2147972435) and, for this project, that `csc.exe`. It has
to be deleted rather than absent from the package: the registry tarball does ship it
(`package/Compiler~/win64/csc.exe`).
With it gone, Unity logs the same failure for every assembly it was about to build:
Starting external compile of Unity.Jobs.CodeGen.dll: ...\Compiler~\win64\csc.exe ...
Exception when running external compiler: System.ComponentModel.Win32Exception
(0x80004005): Native error= The system cannot find the file specified.
and then, because nothing compiled at all, reports the misleading
Unable to resolve reference 'Unity.Mathematics' / 'Unity.Collections' / ...
for every `com.recroom.*` assembly, while `Library\ScriptAssemblies` stays empty apart from
`BuiltinAssemblies.stamp`. So: an empty ScriptAssemblies folder plus a wall of unresolvable
references means "the compiler never ran", not "the packages are broken".
This script restores the file from `-FromPath`, or from the Unity editor install's own
`Editor\Data\Tools\Roslyn\csc.exe` (a different build of the same 3.8 apphost, verified to
drive the package's newer `csc.dll`), and then proves it runs by asking it for its version.
The Unity-install copy is 3.8.0-dev.20527.1 as well, so the compiler behaviour is unchanged;
only the ~170 KB launcher differs. Prefer `-FromPath` with the authentic file if you have the
package tarball to hand:
https://cdn.packages.unity.com/tarballs/com.unity.roslyn/com.unity.roslyn-0.2.2-preview/8b5b337db92cbdec37b54235ec8b826c92c6ddef.tgz
(`tar -xzf <tgz> package/Compiler~/win64/csc.exe`, md5 8c4361cc37113f8467feb1d22b4e83da)
This is a repair, not a cure: Defender will do it again on the next full scan, and a Unity
package re-resolve copies the damaged file back out of the shared cache. Add the exclusions
this script prints at the end, and re-run with -IncludeGlobalCache, to close both loops.
.PARAMETER ProjectRoot
Candidate project roots, tried in order; the first one that exists and holds the roslyn
package cache is used.
.PARAMETER FromPath
Use this csc.exe instead of the Unity editor install's copy.
.PARAMETER IncludeGlobalCache
Also repair the shared package cache under %LOCALAPPDATA%\Unity\cache\packages, which is
where a re-resolve would otherwise copy the missing file back from.
.PARAMETER Force
Overwrite csc.exe even when one is already present and working.
.EXAMPLE
pwsh -File tools/repair-unity-script-compiler.ps1
pwsh -File tools/repair-unity-script-compiler.ps1 -FromPath C:\tmp\csc.exe -IncludeGlobalCache -Force
#>
[CmdletBinding()]
param(
[string[]] $ProjectRoot = @(
'C:\RecRoom\Documents\EggFiles\ProjectTemplate',
'C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate'
),
[string] $FromPath,
[switch] $IncludeGlobalCache,
[switch] $Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$expectedMd5 = '8c4361cc37113f8467feb1d22b4e83da'
function Write-Status {
param([string] $Level, [string] $Message)
Write-Host ("[{0}] {1}" -f $Level, $Message)
}
function Find-RoslynCompilerDir {
<# The roslyn package folder inside a project, or $null. #>
param([string] $Root)
$pkgRoot = Join-Path $Root 'Library\PackageCache'
if (-not (Test-Path -LiteralPath $pkgRoot)) { return $null }
$dirs = Get-ChildItem -LiteralPath $pkgRoot -Directory -Filter 'com.unity.roslyn@*' -ErrorAction SilentlyContinue |
ForEach-Object { Join-Path $_.FullName 'Compiler~\win64' } |
Where-Object { Test-Path -LiteralPath $_ }
if (-not $dirs) { return $null }
return @($dirs)[0]
}
function Find-UnityInstallCompiler {
<# Tools\Roslyn\csc.exe from the editor version this project is pinned to. #>
param([string] $Root)
$versionFile = Join-Path $Root 'ProjectSettings\ProjectVersion.txt'
$version = $null
if (Test-Path -LiteralPath $versionFile) {
$line = Get-Content -LiteralPath $versionFile | Where-Object { $_ -match '^m_EditorVersion:' } | Select-Object -First 1
if ($line) { $version = ($line -split ':', 2)[1].Trim() }
}
$roots = @()
if ($version) {
$roots += "C:\Program Files\Unity $version"
$roots += "C:\Program Files\Unity\Hub\Editor\$version"
}
$roots += @(Get-ChildItem 'C:\Program Files\Unity*' -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName })
foreach ($r in $roots) {
$candidate = Join-Path $r 'Editor\Data\Tools\Roslyn\csc.exe'
if (Test-Path -LiteralPath $candidate) { return $candidate }
}
return $null
}
function Test-Compiler {
<# Runs the compiler's own version probe: the launcher only works if it can reach its csc.dll. #>
param([string] $Path)
try {
$output = & $Path -version 2>&1
} catch {
return [pscustomobject]@{ Ok = $false; Detail = $_.Exception.Message }
}
$text = ($output | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $text -notmatch 'Visual C# Compiler version|^\d+\.\d+') {
$detail = if ($text) { $text } else { "exited with code $LASTEXITCODE and no output" }
return [pscustomobject]@{ Ok = $false; Detail = $detail }
}
return [pscustomobject]@{ Ok = $true; Detail = $text }
}
function Repair-CompilerDir {
param([string] $Dir, [string] $Source)
$target = Join-Path $Dir 'csc.exe'
if ((Test-Path -LiteralPath $target) -and -not $Force) {
$probe = Test-Compiler -Path $target
if ($probe.Ok) {
Write-Status 'PASS' "already present and working: $target ($($probe.Detail))"
return $false
}
Write-Status 'WARN' "present but not usable ($($probe.Detail)) - replacing it"
}
if (-not (Test-Path -LiteralPath $Dir)) {
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
}
Copy-Item -LiteralPath $Source -Destination $target -Force
$probe = Test-Compiler -Path $target
if (-not $probe.Ok) {
throw "Restored $target but it still does not run: $($probe.Detail)"
}
$md5 = (Get-FileHash -LiteralPath $target -Algorithm MD5).Hash.ToLowerInvariant()
if ($md5 -eq $expectedMd5) {
Write-Status 'PASS' "restored the authentic $target ($md5)"
} else {
Write-Status 'PASS' "restored $target (md5 $md5; the Unity install build, not the package one)"
}
return $true
}
# ---- main -------------------------------------------------------------------------
Write-Host ''
$project = $null
foreach ($candidate in $ProjectRoot) {
if (-not (Test-Path -LiteralPath $candidate)) { continue }
if (Find-RoslynCompilerDir -Root $candidate) { $project = $candidate; break }
}
if (-not $project) {
throw "No project with an extracted com.unity.roslyn package found. Tried:`n $($ProjectRoot -join "`n ")"
}
$compilerDir = Find-RoslynCompilerDir -Root $project
Write-Status 'INFO' "project : $project"
Write-Status 'INFO' "compiler : $compilerDir"
$source = $FromPath
if ($source) {
if (-not (Test-Path -LiteralPath $source)) { throw "-FromPath '$source' does not exist." }
Write-Status 'INFO' "source : $source (-FromPath)"
} else {
$source = Find-UnityInstallCompiler -Root $project
if (-not $source) {
throw @"
No csc.exe available to restore from. Either install the editor version named in
ProjectSettings\ProjectVersion.txt, or pass -FromPath pointing at a copy of the package's
win64\csc.exe taken from the registry tarball.
"@
}
Write-Status 'INFO' "source : $source (Unity editor install)"
}
$repaired = Repair-CompilerDir -Dir $compilerDir -Source $source
if (-not $repaired) {
Write-Status 'INFO' 'nothing to do for this project'
}
if ($IncludeGlobalCache) {
$globalRoot = Join-Path $env:LOCALAPPDATA 'Unity\cache\packages\packages.unity.com'
$globalDirs = Get-ChildItem -LiteralPath $globalRoot -Directory -Filter 'com.unity.roslyn@*' -ErrorAction SilentlyContinue |
ForEach-Object { Join-Path $_.FullName 'Compiler~\win64' }
if (-not $globalDirs) {
Write-Status 'WARN' "no com.unity.roslyn under $globalRoot"
}
foreach ($d in $globalDirs) { Repair-CompilerDir -Dir $d -Source $source | Out-Null }
}
# Unity will not pick the compiler up until it recompiles; ScriptAssemblies should then fill in.
$scriptAssemblies = Join-Path $project 'Library\ScriptAssemblies'
if (Test-Path -LiteralPath $scriptAssemblies) {
$built = @(Get-ChildItem -LiteralPath $scriptAssemblies -Filter '*.dll' -ErrorAction SilentlyContinue).Count
if ($built -eq 0) {
Write-Status 'INFO' 'Library\ScriptAssemblies is still empty - reopen the project (Unity recompiles on focus)'
} else {
Write-Status 'INFO' "Library\ScriptAssemblies holds $built assemblies"
}
}
Write-Host ''
Write-Status 'INFO' 'Windows Defender is what removes this file. In an ELEVATED PowerShell:'
Write-Host " Add-MpPreference -ExclusionPath '$project'"
Write-Host " Add-MpPreference -ExclusionPath `"$env:LOCALAPPDATA\Unity\cache`""
Write-Status 'INFO' 'the same rule also emptied com.unity.burst\.Runtime\hostwin (llvm-lipo/dsymutil, needed for Apple targets only)'
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Inspect the il2cpp string-literal table of the 20230831 client.
global-metadata.dat ships lightly obfuscated (header magic is not 0xFAB11BAF),
but the interop copy BepInEx writes (global-metadata.interop.dat) is plain v27,
which is the file the plugin itself reads. String literals live at header
offsets 8..24:
int32 stringLiteralOffset // table of (length, dataOffset) pairs
int32 stringLiteralCount // BYTES in that table (8 per literal)
int32 stringLiteralDataOffset // concatenated utf8 bytes
int32 stringLiteralDataCount // BYTES of literal data
Usage:
python tools/sep-literals.py find <needle> [-i] [--limit N]
python tools/sep-literals.py around <index> [radius]
python tools/sep-literals.py token <index>
Environment:
RADICADE_METADATA override the metadata path
"""
import os
import re
import struct
import sys
DEFAULT_METADATA = (
r"C:\RecRoom\2023-09-06T06-53-47Z\RecRoom_Data\il2cpp_data"
r"\Metadata\global-metadata.interop.dat"
)
def load(path):
with open(path, "rb") as handle:
blob = handle.read()
magic, version = struct.unpack_from("<Ii", blob, 0)
if magic != 0xFAB11BAF:
raise SystemExit(
f"not a plain il2cpp metadata file (magic {magic:#x}); "
"point RADICADE_METADATA at global-metadata.interop.dat"
)
table, table_bytes, data, data_bytes = struct.unpack_from("<iiii", blob, 8)
count = table_bytes // 8
if table_bytes % 8 or table < 24 or table + table_bytes > len(blob):
raise SystemExit(f"implausible literal table ({table}, {table_bytes})")
literals = []
for index in range(count):
length, offset = struct.unpack_from("<ii", blob, table + 8 * index)
if length < 0 or offset < 0 or offset + length > data_bytes:
raise SystemExit(f"literal #{index} escapes the data segment")
literals.append(blob[data + offset : data + offset + length].decode("utf-8", "replace"))
return version, literals
def main(argv):
if len(argv) < 2:
raise SystemExit(__doc__)
path = os.environ.get("RADICADE_METADATA", DEFAULT_METADATA)
version, literals = load(path)
command, rest = argv[1], argv[2:]
if command == "find":
if not rest:
raise SystemExit("find needs a needle")
ignore_case = "-i" in rest
limit = None
positional = []
index = 0
while index < len(rest):
if rest[index] == "--limit":
limit = int(rest[index + 1])
index += 2
continue
if rest[index] != "-i":
positional.append(rest[index])
index += 1
if not positional:
raise SystemExit("find needs a needle")
needle = positional[0]
target = needle.lower() if ignore_case else needle
hits = 0
for index, text in enumerate(literals):
haystack = text.lower() if ignore_case else text
if target not in haystack:
continue
print(f"#{index}\t{text!r}")
hits += 1
if limit and hits >= limit:
break
print(f"-- {hits} match(es); {len(literals)} literals; metadata v{version}")
elif command == "around":
index = int(rest[0])
radius = int(rest[1]) if len(rest) > 1 else 25
for other in range(max(0, index - radius), min(len(literals), index + radius + 1)):
mark = ">>" if other == index else " "
print(f"{mark} #{other}\t{literals[other]!r}")
elif command == "token":
for raw in rest:
index = int(raw)
print(f"#{index}\t0x{(5 << 29) | (index << 1):X}\t{literals[index]!r}")
else:
raise SystemExit(__doc__)
if __name__ == "__main__":
main(sys.argv)
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Locate metadata-usage tokens / pointers inside an il2cpp binary.
On metadata v27 the compiled code does not carry literal indices: a string
literal is reached through a metadata-usage value `(type << 29) | (index << 1) | 1`
(type 5 = string literal). That value shows up in the image either as an
immediate operand in code or as the pre-loader content of a usage slot, so a
plain byte scan is enough to find the referencing function - parse #29705's
token with tools/sep-literals.py, scan here, then read the enclosing method
from the runtime address map.
Usage:
python tools/sep-scan.py --binary <GameAssembly.dll> --token 0xA000E813[,..]
python tools/sep-scan.py --binary <GameAssembly.dll> --pointer 0x1891B46E8[,..]
python tools/sep-scan.py --binary <GameAssembly.dll> --map <radicade-addr-map.txt> --token ...
Options:
--section .text restrict the scan (repeatable; default: every section)
--context N extra bytes of hex context to print (default 16)
"""
import argparse
import os
import struct
import sys
METADATA_USAGE_STRING_LITERAL = 5
def literal_token(index):
return (METADATA_USAGE_STRING_LITERAL << 29) | (index << 1) | 1
def parse_number(text):
return int(text, 0)
def read_sections(blob):
pe_offset = struct.unpack_from("<I", blob, 0x3C)[0]
if blob[pe_offset : pe_offset + 4] != b"PE\0\0":
raise SystemExit("not a PE image")
machine, section_count = struct.unpack_from("<HH", blob, pe_offset + 4 + 2)
optional_size = struct.unpack_from("<H", blob, pe_offset + 4 + 16)[0]
optional = pe_offset + 4 + 20
magic = struct.unpack_from("<H", blob, optional)[0]
if magic == 0x20B:
image_base = struct.unpack_from("<Q", blob, optional + 24)[0]
elif magic == 0x10B:
image_base = struct.unpack_from("<I", blob, optional + 28)[0]
else:
raise SystemExit(f"unknown optional header magic {magic:#x}")
sections = []
header = optional + optional_size
for i in range(section_count):
entry = header + 40 * i
name = blob[entry : entry + 8].split(b"\0")[0].decode("ascii", "replace")
virtual_size, virtual_address, raw_size, raw_offset = struct.unpack_from("<IIII", blob, entry + 8)
sections.append((name, virtual_address, virtual_size, raw_offset, raw_size))
return image_base, machine, sections
def load_map(path, image_base):
entries = []
with open(path, "r", encoding="utf-8", errors="replace") as handle:
for line in handle:
if line.startswith("#"):
continue
parts = line.rstrip("\r\n").split("\t")
if len(parts) < 3:
continue
try:
rva = int(parts[0], 16)
except ValueError:
continue
entries.append((rva, parts[1], parts[2]))
entries.sort()
return entries
def enclosing(entries, rva, image_base):
lo, hi = 0, len(entries) - 1
best = None
while lo <= hi:
mid = (lo + hi) // 2
if entries[mid][0] <= rva:
best = entries[mid]
lo = mid + 1
else:
hi = mid - 1
if best is None:
return None
return best, rva - best[0]
def main(argv):
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("--binary", required=True)
parser.add_argument("--map")
parser.add_argument("--token", action="append", default=[])
parser.add_argument("--pointer", action="append", default=[])
parser.add_argument("--literal", action="append", type=int, default=[])
parser.add_argument("--section", action="append", default=[])
parser.add_argument("--context", type=int, default=16)
args = parser.parse_args(argv[1:])
patterns = []
labels = []
for value in args.literal:
patterns.append(struct.pack("<I", literal_token(value)))
labels.append(f"literal #{value} token 0x{literal_token(value):X} (4-byte)")
for group in args.token:
for value in group.split(","):
number = parse_number(value)
patterns.append(struct.pack("<I", number))
patterns.append(struct.pack("<Q", number))
labels.append(f"token {number:#x}")
labels.append(f"token {number:#x} (8-byte)")
for group in args.pointer:
for value in group.split(","):
number = parse_number(value)
patterns.append(struct.pack("<Q", number))
labels.append(f"pointer {number:#x}")
if not patterns:
raise SystemExit("nothing to search for: pass --literal/--token/--pointer")
blob = open(args.binary, "rb").read()
image_base, machine, sections = read_sections(blob)
print(f"binary {os.path.basename(args.binary)}: machine {machine:#x}, image base {image_base:#x}, {len(sections)} sections")
entries = load_map(args.map, image_base) if args.map else None
total = 0
for index, pattern in enumerate(patterns):
wanted = [s for s in sections if not args.section or s[0] in args.section]
for name, virtual_address, virtual_size, raw_offset, raw_size in wanted:
size = min(raw_size, max(0, len(blob) - raw_offset))
start = raw_offset
end = raw_offset + size
offset = start
while True:
offset = blob.find(pattern, offset, end)
if offset < 0:
break
va = image_base + virtual_address + (offset - raw_offset)
line = f" {labels[index]} @ RVA {virtual_address + (offset - raw_offset):#x} VA {va:#x} ({name})"
if entries:
found = enclosing(entries, virtual_address + (offset - raw_offset), image_base)
if found:
(rva, module, method), delta = found
line += f" -> {module} {method} +{delta:#x}"
print(line)
context = blob[max(offset - 4, start) : offset + len(pattern) + args.context]
print(f" ctx {context.hex(' ')}")
total += 1
offset += 1
print(f"{total} occurrence(s)")
if entries:
print("map loaded: %d methods" % len(entries))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Map obfuscated Il2CppInterop names between two game builds.
Il2CppInterop emits, for every il2cpp member, a stub line
NativeMethodInfoPtr_<Name>_<Signature>_0 = IL2CPP.GetIl2CppMethodByToken(
Il2CppClassPointerStore<<DeclaringType>>.NativeClassPtr, <token>);
The token encodes the global method index (0x06000000 + index), which shifts by
only a few units between builds, and <Signature> is a structural type spelling
(parameter kinds, arity, primitives) that survives renames -- only obfuscated
type identifiers inside it change. So: (normalized signature, token within a
window) identifies a member across builds without knowing the obfuscated names.
Usage:
python tools/sept-map.py show <aug|sep> <Type> [MemberFilter]
python tools/sept-map.py find <aug|sep> <token>
python tools/sept-map.py map <AugType> <AugMember> [window]
python tools/sept-map.py shape <aug|sep> <Type>
python tools/sept-map.py types <aug|sep> <regex>
"""
import os
import re
import sys
from collections import defaultdict
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROOTS = {
"aug": [
os.path.join(ROOT, ".external", "aug-decomp", "acs"),
os.path.join(ROOT, ".external", "aug-decomp", "rcn"),
os.path.join(ROOT, ".external", "aug-decomp", "pfdoldleiba.cs"),
os.path.join(ROOT, ".external", "aug-decomp", "aug-bootsequence.cs"),
os.path.join(ROOT, ".external", "interop-decomp"),
],
"sep": [
os.path.join(ROOT, ".external", "sep-decomp", "acs"),
os.path.join(ROOT, ".external", "sep-decomp", "rcn"),
os.path.join(ROOT, ".external", "sep-decomp", "rp"),
os.path.join(ROOT, ".external", "sep-decomp", "recr"),
os.path.join(ROOT, ".external", "sep-decomp", "easy"),
os.path.join(ROOT, ".external", "sep-decomp", "code"),
],
}
METHOD_RE = re.compile(
r"NativeMethodInfoPtr_([A-Za-z0-9_]+?)_(.*?)_0 = IL2CPP\.GetIl2CppMethodByToken\("
r"Il2CppClassPointerStore<([A-Za-z0-9_]+)>\.NativeClassPtr, (\d+)\)"
)
FIELD_RE = re.compile(r"NativeFieldInfoPtr_([A-Za-z0-9_]+?);")
TYPE_RE = re.compile(r"^\t*(?:\[[^\]]*\]\s*)*(?:public|internal|private|protected)?\s*"
r"(?:sealed\s+|abstract\s+|static\s+|unsafe\s+|partial\s+)*"
r"(class|struct|enum)\s+([A-Za-z0-9_]+)")
IDENT_RE = re.compile(r"[A-Z]{8,17}")
def norm(sig):
"""Blank out obfuscated identifiers, keep structural spelling."""
out = IDENT_RE.sub("#", sig)
return out
class TypeInfo:
__slots__ = ("name", "file", "methods", "fields", "line")
def __init__(self, name, file, line):
self.name = name
self.file = file
self.line = line
self.methods = [] # (memberName, sig, token, lineno)
self.fields = [] # (fieldName, lineno)
def shape(self):
return tuple(norm(s) for _, s, _, _ in self.methods)
def load(root):
types = {}
by_token = defaultdict(list)
files_to_parse = []
for base in ROOTS[root]:
if os.path.isfile(base) and base.endswith(".cs"):
files_to_parse.append(base)
continue
if not os.path.isdir(base):
continue
for dirpath, _dirs, files in os.walk(base):
for fn in files:
if fn.endswith(".cs"):
files_to_parse.append(os.path.join(dirpath, fn))
if True:
for path in files_to_parse:
if True:
current = None
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for lineno, line in enumerate(fh, 1):
if current is None or "\tclass " in line or "\tstruct " in line or \
" enum " in line or line.startswith(("public ", "internal ")):
m = TYPE_RE.match(line)
if m:
name = m.group(2)
current = types.get(name)
if current is None:
current = TypeInfo(name, os.path.relpath(path, ROOT), lineno)
types[name] = current
m = METHOD_RE.search(line)
if m:
owner = m.group(3)
if owner not in types:
types[owner] = TypeInfo(owner, os.path.relpath(path, ROOT), 0)
entry = (m.group(1), m.group(2), int(m.group(4)), lineno)
types[owner].methods.append(entry)
by_token[int(m.group(4))].append((owner, entry))
continue
m = FIELD_RE.search(line)
if m and current is not None:
current.fields.append((m.group(1), lineno))
return types, by_token
def show(types, name, filt=""):
t = types.get(name)
if t is None:
print(f"type {name}: NOT FOUND")
return
print(f"=== {name} ({t.file}) methods={len(t.methods)} fields={len(t.fields)}")
for member, sig, token, line in t.methods:
if filt and filt not in member:
continue
print(f" {token} {token - 0x6000000:>7} {member} [{norm(sig)}] {sig}")
for field, line in t.fields:
if filt and filt not in field:
continue
print(f" field {field}")
def map_member(types, sep_tokens, aug_type, aug_member, window=60):
t = types.get(aug_type)
if t is None:
print(f"august type {aug_type} not found")
return
targets = [(m, s, k) for m, s, k, _ in t.methods if m == aug_member]
if not targets:
targets = [(m, s, k) for m, s, k, _ in t.methods if aug_member in m]
if not targets:
print(f"member {aug_member} not found on {aug_type}")
return
for member, sig, token in targets:
print(f"\nAUG {aug_type}.{member} token={token} (idx {token - 0x6000000}) sig=[{norm(sig)}]")
hits = []
for tk in range(token - window, token + window + 1):
for owner, entry in sep_tokens.get(tk, []):
m2, s2, tok, line = entry
if norm(s2) == norm(sig):
hits.append((abs(tk - token), tk, owner, m2, s2))
hits.sort()
for dist, tk, owner, m2, s2 in hits[:8]:
print(f" SEP d={dist:+3d} {tk} {owner}.{m2} [{norm(s2)}] {s2}")
if not hits:
print(" no signature match in window")
def shape(types, name, verbose=True):
t = types.get(name)
if t is None:
print(f"type {name}: NOT FOUND")
return
if verbose:
print(f"=== shape {name} ({t.file}) methods={len(t.methods)} fields={len(t.fields)}")
for member, sig, token, _ in t.methods:
print(f" {token - 0x6000000:>7} {member} [{norm(sig)}]")
for field, _ in t.fields:
print(f" field:{field}")
return t
def similarity(a, b):
"""Match two ordered (sig,) sequences; return (score, token pairs)."""
import difflib
sa = [x[0] for x in a]
sb = [x[0] for x in b]
sm = difflib.SequenceMatcher(None, sa, sb, autojunk=False)
pairs = []
for block in sm.get_matching_blocks():
for i in range(block.size):
pairs.append((a[block.a + i][1], b[block.b + i][1]))
score = 2.0 * len(pairs) / max(1, len(sa) + len(sb))
return score, pairs
def match_type(aug_types, sep_types, aug_name, top=6):
t = aug_types.get(aug_name)
if t is None:
print(f"august type {aug_name}: NOT FOUND")
return
a = [(norm(s), tk) for _, s, tk, _ in t.methods]
scored = []
for name, st in sep_types.items():
b = [(norm(s), tk) for _, s, tk, _ in st.methods]
if not b and not a:
continue
score, pairs = similarity(a, b)
if not pairs:
continue
deltas = [tk2 - tk1 for tk1, tk2 in pairs]
median = sorted(deltas)[len(deltas) // 2]
spread = max(deltas) - min(deltas)
scored.append((score, -abs(median), name, st, median, spread, len(pairs)))
scored.sort(key=lambda x: (-x[0], x[1]))
print(f"=== {aug_name} ({t.file}) {len(t.methods)} methods; {len(t.fields)} fields")
for score, _n, name, st, median, spread, pairs in scored[:top]:
print(f" {score:5.2f} {name:<16} d={median:+5d} spread={spread:<5d} pairs={pairs:<4d} "
f"m={len(st.methods)} f={len(st.fields)} {st.file}")
def main():
if len(sys.argv) < 3:
print(__doc__)
return 1
cmd = sys.argv[1]
if cmd == "map":
aug_types, _ = load("aug")
_sep_types, sep_tokens = _load_tokens("sep")
map_member(aug_types, sep_tokens, sys.argv[2], sys.argv[3],
int(sys.argv[4]) if len(sys.argv) > 4 else 60)
return 0
if cmd in ("match", "verify"):
aug_types, _ = load("aug")
sep_types, _ = load("sep")
names = [sys.argv[2]] if cmd == "match" else sys.argv[2].split(",")
top = int(sys.argv[3]) if len(sys.argv) > 3 else 6
for name in names:
match_type(aug_types, sep_types, name, top)
return 0
root = sys.argv[2]
types, by_token = load(root)
if cmd == "show":
show(types, sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else "")
elif cmd == "shape":
shape(types, sys.argv[3])
elif cmd in ("find", "findidx", "window"):
token = int(sys.argv[3], 0)
if token < 0x6000000:
token += 0x6000000
span = int(sys.argv[4]) if len(sys.argv) > 4 else 0
for tk in range(token - span, token + span + 1):
for owner, entry in sorted(by_token.get(tk, [])):
member, sig, tok, line = entry
mark = "<<<" if tk == token else " "
print(f" {mark} {tk} {owner}.{member} [{norm(sig)}]")
elif cmd == "types":
rx = re.compile(sys.argv[3])
for name in sorted(t for t in types if rx.search(t)):
print(f" {name} ({types[name].file})")
else:
print(__doc__)
return 1
return 0
_CACHE = {}
def _load_tokens(root):
if root not in _CACHE:
_CACHE[root] = load(root)
return _CACHE[root]
if __name__ == "__main__":
sys.exit(main())
+608
View File
@@ -0,0 +1,608 @@
<#
Read-only triage for a Radicade-patched September 2023 Rec Room install
(game build 20230831, folder 2023-09-06T06-53-47Z, Unity 2020.3.33f1).
It also answers the two questions a boot that never reaches the login screen
raises on a machine somebody else cannot look at:
* WHICH server does this client's own data point at? The metadata literal
table is read directly, because a byte search cannot tell the live host
from the dead copy the patch leaves behind. A client whose literals say
api.recflare.net is running another project's patch, whatever the plugin
config says.
* WHAT has this Windows user talked to before? BepInEx, config, caches and
the last log are read, because %LOCALAPPDATA%Low\Against Gravity\Rec Room
belongs to the user rather than to a game copy and is shared by every
install on the PC.
Usage:
powershell -ExecutionPolicy Bypass -File september-client-triage.ps1
powershell -ExecutionPolicy Bypass -File september-client-triage.ps1 -GameDir "C:\path\to\2023-09-06T06-53-47Z"
It only reads files. It writes september-client-report.txt next to this
script (override with -OutFile). Send that file back verbatim; it names
exactly which piece of the stack is missing.
The single failure this script exists for:
[Error :InteropManager] Failed to generate Il2Cpp interop assemblies:
... System.FormatException: Invalid or corrupt metadata (magic number check failed)
Rec Room does not ship a standard IL2CPP metadata header. Its global-metadata.dat
begins with a per-release scrambled 256-byte block, so LibCpp2IL's magic check
(0xFAB11BAF) rejects it. The working install keeps a normalized copy
(global-metadata.interop.dat, magic af 1b b1 fa) and points BepInEx at that copy
instead - either through a patched loader (BepInEx 6.0.0-be.697) or through the
[IL2CPP] GlobalMetadataPath setting (newer BepInEx builds).
A stock BepInEx core has neither, so interop generation dies, no interop
assemblies are written, and the chainloader then fails with
"Could not load file or assembly 'UnityEngine.CoreModule'".
#>
[CmdletBinding()]
param(
[string]$GameDir,
[string]$OutFile
)
$ErrorActionPreference = 'Continue'
$script:Report = New-Object System.Collections.Generic.List[string]
$script:Failures = 0
$script:Warnings = 0
function Add-Line {
param([string]$Text = '')
$script:Report.Add($Text)
}
function Write-Both {
param([string]$Text = '')
Write-Host $Text
Add-Line $Text
}
function Add-Verdict {
param(
[ValidateSet('OK', 'WARN', 'MISSING')][string]$State,
[string]$Text
)
if ($State -eq 'MISSING') { $script:Failures++ }
if ($State -eq 'WARN') { $script:Warnings++ }
Add-Line ("[{0,-7}] {1}" -f $State, $Text)
if ($State -ne 'OK') { Write-Host ("[{0}] {1}" -f $State, $Text) }
}
function Get-Md5 {
param([string]$Path)
try { return (Get-FileHash -LiteralPath $Path -Algorithm MD5).Hash.ToLower() }
catch { return 'unreadable' }
}
function Get-Bytes {
param([string]$Path)
# The leading comma matters: without it PowerShell unrolls the array into the pipeline
# and the caller receives one object per byte. On the 33 MB global-metadata.dat that is
# 33 million boxed objects and the script appears to hang for minutes.
try { return ,([System.IO.File]::ReadAllBytes($Path)) }
catch { return $null }
}
# True when the file holds the literal as UTF-16 or as UTF-8/ASCII.
function Test-Literal {
param([byte[]]$Bytes, [string]$Literal)
if (-not $Bytes) { return $false }
foreach ($needle in @([System.Text.Encoding]::Unicode.GetBytes($Literal),
[System.Text.Encoding]::UTF8.GetBytes($Literal))) {
$limit = $Bytes.Length - $needle.Length
for ($i = 0; $i -le $limit; $i++) {
if ($Bytes[$i] -ne $needle[0]) { continue }
$hit = $true
for ($j = 1; $j -lt $needle.Length; $j++) {
if ($Bytes[$i + $j] -ne $needle[$j]) { $hit = $false; break }
}
if ($hit) { return $true }
}
}
return $false
}
function Get-FirstBytes {
param([string]$Path, [int]$Count = 4)
$bytes = Get-Bytes $Path
if (-not $bytes) { return 'unreadable' }
return (($bytes[0..([Math]::Min($Count, $bytes.Length) - 1)] | ForEach-Object { $_.ToString('x2') }) -join ' ')
}
function Get-TextIfExists {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try { return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop) } catch { return $null }
}
function Get-MatchCount {
param([string]$Text, [string]$Pattern)
if (-not $Text) { return 0 }
return ([regex]::Matches($Text, $Pattern)).Count
}
# The patch keeps the original literal blob verbatim and appends the rewritten literals, so
# a byte search finds BOTH the dead and the live copy of every host (the reference file still
# contains "ns.rec.net" as dead bytes) and cannot tell them apart. Only the literal table
# says which string the runtime actually resolves, so read it: for 20230831 its four header
# fields sit at byte 0x74 (table offset), 0x24 (table size), 0xA0 (data offset), 0x3C (data
# size) - the same positions tools/patch-0831-client-metadata.py rewrites.
function Get-MetadataHostCounts {
param([string]$Path, [int]$Top = 25)
$bytes = Get-Bytes $Path
if (-not $bytes -or $bytes.Length -lt 0x100) { return $null }
$tableOff = [BitConverter]::ToUInt32($bytes, 0x74)
$tableSize = [BitConverter]::ToUInt32($bytes, 0x24)
$dataOff = [BitConverter]::ToUInt32($bytes, 0xA0)
$dataSize = [BitConverter]::ToUInt32($bytes, 0x3C)
if ($tableSize -eq 0 -or ($tableOff + $tableSize) -gt $bytes.Length) { return $null }
if (($dataOff + $dataSize) -gt $bytes.Length) { return $null }
$counts = @{}
$records = [int]($tableSize / 8)
for ($i = 0; $i -lt $records; $i++) {
$pos = [int]$tableOff + ($i * 8)
$len = [BitConverter]::ToUInt32($bytes, $pos)
$idx = [BitConverter]::ToInt32($bytes, $pos + 4)
if ($len -eq 0 -or $len -gt 512 -or $idx -lt 0) { continue }
$start = [int]$dataOff + $idx
if (($start + $len) -gt $bytes.Length) { continue }
# Only http(s) literals are interesting, and UTF-8 will not start one with any
# other byte - skipping the rest keeps this loop off the slow path.
if ($bytes[$start] -ne 0x68) { continue }
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, [int]$len)
$m = [regex]::Match($text, '^https?://(?<host>[^/?#]+)')
if (-not $m.Success) { continue }
$hostName = $m.Groups['host'].Value.ToLowerInvariant()
if ($counts.ContainsKey($hostName)) { $counts[$hostName]++ } else { $counts[$hostName] = 1 }
}
return @($counts.GetEnumerator() |
ForEach-Object { [pscustomobject]@{ Host = $_.Key; Count = $_.Value } } |
Sort-Object -Property Count, Host -Descending |
Select-Object -First $Top)
}
# --- locate the install -------------------------------------------------------
if (-not $GameDir) {
$candidates = @(
(Join-Path $env:USERPROFILE 'Downloads\2023-09-06T06-53-47Z'),
(Join-Path $env:USERPROFILE 'Desktop\2023-09-06T06-53-47Z'),
'C:\2023-09-06T06-53-47Z'
)
$downloads = Join-Path $env:USERPROFILE 'Downloads'
if (Test-Path -LiteralPath $downloads) {
$candidates += (Get-ChildItem -LiteralPath $downloads -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'GameAssembly.dll') } |
ForEach-Object { $_.FullName })
}
foreach ($c in $candidates) {
if ($c -and (Test-Path -LiteralPath (Join-Path $c 'GameAssembly.dll'))) { $GameDir = $c; break }
}
}
if (-not $GameDir) { $GameDir = Read-Host 'Path to the Rec Room folder that contains GameAssembly.dll' }
$GameDir = $GameDir.TrimEnd('\')
if (-not $OutFile) {
if ($PSScriptRoot) { $OutFile = Join-Path $PSScriptRoot 'september-client-report.txt' }
else { $OutFile = Join-Path (Get-Location) 'september-client-report.txt' }
}
# The data folder is renamed in some distributions (RecRoom_Data -> Radicade_Data),
# so find it instead of assuming. BepInEx resolves {GameDataPath} at runtime and does
# not care about the name.
$dataDir = $null
foreach ($name in @('RecRoom_Data', 'Radicade_Data', 'Recroom_Data')) {
$p = Join-Path $GameDir $name
if (Test-Path -LiteralPath (Join-Path $p 'il2cpp_data')) { $dataDir = $p; break }
}
if (-not $dataDir) {
$dataDir = Get-ChildItem -LiteralPath $GameDir -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'il2cpp_data') } |
Select-Object -First 1 -ExpandProperty FullName
}
$metadataDir = if ($dataDir) { Join-Path $dataDir 'il2cpp_data\Metadata' } else { $null }
$metadata = if ($metadataDir) { Join-Path $metadataDir 'global-metadata.dat' } else { $null }
$interopDat = if ($metadataDir) { Join-Path $metadataDir 'global-metadata.interop.dat' } else { $null }
$pristineDat = if ($metadataDir) { Join-Path $metadataDir 'global-metadata.dat.orig' } else { $null }
$bepInEx = Join-Path $GameDir 'BepInEx'
$coreDir = Join-Path $bepInEx 'core'
$loader = Join-Path $coreDir 'BepInEx.Unity.IL2CPP.dll'
$configFile = Join-Path $bepInEx 'config\BepInEx.cfg'
$interopDir = Join-Path $bepInEx 'interop'
$unityLibs = Join-Path $bepInEx 'unity-libs'
$plugin = Join-Path $bepInEx 'plugins\RadicadePatch.dll'
$bepLog = Join-Path $bepInEx 'LogOutput.log'
$changelog = Join-Path $GameDir 'changelog.txt'
$assembly = Join-Path $GameDir 'GameAssembly.dll'
$winhttp = Join-Path $GameDir 'winhttp.dll'
$doorstop = Join-Path $GameDir 'doorstop_config.ini'
$markerFile = if ($dataDir) { Join-Path $dataDir 'radicade-patch.json' } else { $null }
# Per Windows user, NOT per game copy: every install on this PC shares it.
$profileDir = Join-Path $env:USERPROFILE 'AppData\LocalLow\Against Gravity\Rec Room'
# Reference values from the install that logs in and loads rooms.
$expected = @{
AssemblySize = 167873024
AssemblyMd5 = '54f26d061797ab615688d62108195fa7'
MetadataSize = 33774004
MetadataMd5 = 'ce5097af6072c4e474f02704f020e94f'
InteropSize = 32115816
InteropMd5 = '4c6548745a2855fc9ab1c5fbaea639d2'
LoaderSize = 60928
LoaderMd5 = '9430bb84349d52b753a181788a86c5e2'
WinhttpMd5 = 'b2a4d0cb04bf8f5a27ccab237ecc2586'
PluginSize = 96768
PluginMd5 = '64e4045f09107f5525764b83c6f4ba2b'
InteropCount = 332
InteropHash = 'f4651f741ef8c3f61cd261bfb1e02e72'
MagicScrambled = 'e0 eb 02 00'
MagicStandard = 'af 1b b1 fa'
}
# --- header ------------------------------------------------------------------
Write-Both 'Radicade September 2023 client triage (game build 20230831)'
Write-Both ("generated : {0}" -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))
Write-Both ("game folder : {0}" -f $GameDir)
Write-Both ("data folder : {0}" -f $(if ($dataDir) { Split-Path $dataDir -Leaf } else { 'NOT FOUND' }))
Write-Both ("powershell : {0}" -f $PSVersionTable.PSVersion)
Write-Both ''
# --- game build ---------------------------------------------------------------
Write-Both '== game build =='
if (Test-Path -LiteralPath $assembly) {
$info = Get-Item -LiteralPath $assembly
$md5 = Get-Md5 $assembly
Write-Both ("GameAssembly.dll : {0} b md5 {1}" -f $info.Length, $md5)
if ($info.Length -eq $expected.AssemblySize -and $md5 -eq $expected.AssemblyMd5) {
Add-Verdict 'OK' 'GameAssembly.dll is the reference September build'
} elseif ($info.Length -eq $expected.AssemblySize) {
Add-Verdict 'WARN' 'GameAssembly.dll has the reference size but a different hash - verify the build'
} else {
Add-Verdict 'MISSING' ("GameAssembly.dll is {0} b, the reference build is {1} b - this patch stack only works on that exact build" -f $info.Length, $expected.AssemblySize)
}
} else {
Add-Verdict 'MISSING' 'GameAssembly.dll - this is not a Rec Room install folder?'
}
Write-Both ''
# --- IL2CPP metadata ----------------------------------------------------------
Write-Both '== IL2CPP metadata =='
if ($metadata -and (Test-Path -LiteralPath $metadata)) {
$info = Get-Item -LiteralPath $metadata
$md5 = Get-Md5 $metadata
$magic = Get-FirstBytes $metadata
Write-Both ("global-metadata.dat: {0} b md5 {1} magic {2}" -f $info.Length, $md5, $magic)
if ($magic -ne $expected.MagicScrambled) {
Add-Verdict 'WARN' ("global-metadata.dat magic is {0}, expected the scrambled Rec Room header {1} - unusual for this build" -f $magic, $expected.MagicScrambled)
}
if ($info.Length -eq $expected.MetadataSize -and $md5 -eq $expected.MetadataMd5) {
Add-Verdict 'OK' 'global-metadata.dat matches the reference host-literal patch'
} else {
Add-Verdict 'WARN' 'global-metadata.dat is not the reference file - the live hosts below say what it is really patched for'
}
# A byte search cannot answer "which server does this client use": the patch keeps the
# original blob verbatim and appends the rewritten literals, so the dead copy of every
# host is still in the file (the reference metadata still contains "ns.rec.net"). Read
# the literal table instead - that is what the runtime resolves.
$hosts = Get-MetadataHostCounts -Path $metadata
if ($hosts) {
Write-Both ' live service hosts this client resolves (from the literal table, dead copies ignored):'
foreach ($h in $hosts) { Write-Both (" {0,4}x {1}" -f $h.Count, $h.Host) }
$foreign = @($hosts | Where-Object { $_.Host -match 'recflare|viverec|trollroom|meownet' })
# Only a PORTED loopback host is the baked-server tell: this build carries a bare
# "localhost" literal of its own, so that one must not raise anything.
$loopback = @($hosts | Where-Object { $_.Host -match '^(localhost|127\.0\.0\.1):[0-9]+$' })
$stock = @($hosts | Where-Object { $_.Host -match '(^|\.)rec\.net$' })
if ($foreign.Count -gt 0) {
$names = ($foreign | ForEach-Object { $_.Host }) -join ', '
Add-Verdict 'MISSING' ("this client's own literals resolve {0} - that is another private server's patch. No plugin setting can redirect a host the client resolves itself, so install this bundle's Metadata folder over <Data>\il2cpp_data\Metadata." -f $names)
} elseif ($loopback.Count -gt 0) {
Add-Verdict 'WARN' 'the metadata is patched for a machine-local address: the server has to run on THIS machine, or take the Metadata folder from the bundle instead'
} elseif ($stock.Count -gt 0 -and $md5 -ne $expected.MetadataMd5) {
Add-Verdict 'MISSING' 'the client still resolves the official RecNet hosts: this metadata was never patched for a private server'
}
} else {
Add-Verdict 'WARN' 'could not read the metadata literal table - unexpected layout for this build'
}
# The marker is what the patch tool wrote; the plugin reads the same file at boot.
if ($markerFile -and (Test-Path -LiteralPath $markerFile)) {
$markerText = Get-TextIfExists $markerFile
$patchedUrl = if ($markerText -match '"serverUrl"\s*:\s*"([^"]+)"') { $Matches[1] } else { '?' }
Write-Both ("radicade-patch.json: serverUrl {0}" -f $patchedUrl)
if ($patchedUrl -match 'localhost|127\.0\.0\.1') {
Add-Verdict 'MISSING' ("this install was metadata-patched for {0} (an earlier pack), so its bootstrap host is a machine-local address and nothing is listening there. Take global-metadata.dat and global-metadata.interop.dat from the bundle." -f $patchedUrl)
}
} else {
Add-Verdict 'WARN' 'no radicade-patch.json in the data folder: nothing records which server this install was patched for'
}
} else {
Add-Verdict 'MISSING' ("global-metadata.dat not found under {0}" -f $metadataDir)
}
if ($pristineDat -and (Test-Path -LiteralPath $pristineDat)) {
$info = Get-Item -LiteralPath $pristineDat
Write-Both ("global-metadata.dat.orig: {0} b magic {1}" -f $info.Length, (Get-FirstBytes $pristineDat))
}
# The normalized copy is what interop generation must be pointed at.
if ($interopDat -and (Test-Path -LiteralPath $interopDat)) {
$info = Get-Item -LiteralPath $interopDat
$md5 = Get-Md5 $interopDat
$magic = Get-FirstBytes $interopDat
Write-Both ("global-metadata.interop.dat: {0} b md5 {1} magic {2}" -f $info.Length, $md5, $magic)
if ($magic -eq $expected.MagicStandard) {
Add-Verdict 'OK' 'global-metadata.interop.dat carries the standard header LibCpp2IL requires'
} else {
Add-Verdict 'MISSING' ("global-metadata.interop.dat magic is {0}, expected {1} - interop generation cannot read this file" -f $magic, $expected.MagicStandard)
}
if ($info.Length -ne $expected.InteropSize -or $md5 -ne $expected.InteropMd5) {
Add-Verdict 'WARN' 'normalized metadata is not the reference file - harmless if interop assemblies are already built, otherwise regenerate it'
}
} else {
Add-Verdict 'MISSING' 'global-metadata.interop.dat is absent: nothing can normalize the header for interop generation'
}
Write-Both ''
# --- BepInEx loader -----------------------------------------------------------
Write-Both '== BepInEx loader =='
$beVersion = $null
foreach ($candidate in @($bepLog, $changelog)) {
$text = Get-TextIfExists $candidate
if ($text -and $text -match 'BepInEx (\d+\.\d+\.\d+-be\.\d+)') { $beVersion = $Matches[1]; break }
}
if ($beVersion) {
Write-Both ("BepInEx version : {0} (from {1})" -f $beVersion, (Split-Path $candidate -Leaf))
} else {
Write-Both 'BepInEx version : unknown (no LogOutput.log or changelog.txt yet)'
}
$loaderExists = Test-Path -LiteralPath $loader
$loaderPatched = $false
if ($loaderExists) {
$info = Get-Item -LiteralPath $loader
$md5 = Get-Md5 $loader
$bytes = Get-Bytes $loader
$loaderPatched = Test-Literal $bytes 'global-metadata.interop.dat'
Write-Both ("BepInEx.Unity.IL2CPP.dll: {0} b md5 {1} reads {2}" -f $info.Length, $md5,
$(if ($loaderPatched) { 'global-metadata.interop.dat (patched)' } else { 'global-metadata.dat (stock)' }))
if ($md5 -eq $expected.LoaderMd5) {
Add-Verdict 'OK' 'patched be.697 loader installed'
} elseif ($loaderPatched) {
Add-Verdict 'OK' 'loader reads the normalized metadata, but it is not the reference loader - a different BepInEx build'
} else {
Add-Verdict 'MISSING' 'loader reads global-metadata.dat: this is a stock BepInEx core and it cannot parse this game metadata'
}
} else {
Add-Verdict 'MISSING' 'BepInEx/core/BepInEx.Unity.IL2CPP.dll - BepInEx is not installed'
}
# Newer BepInEx builds expose the same fix as a config setting instead of a patched binary.
$configText = Get-TextIfExists $configFile
$metadataOverride = $null
if ($configText -and $configText -match '(?m)^\s*GlobalMetadataPath\s*=\s*(.+?)\s*$') { $metadataOverride = $Matches[1] }
if ($metadataOverride) {
$overrideMatches = $metadataOverride -match 'interop\.dat'
Write-Both ("[IL2CPP] GlobalMetadataPath = {0}" -f $metadataOverride)
if ($metadataOverride -match '\{GameRootPath\}|\{GameDataPath\}') {
# BepInEx joins this onto the game root; only the {GameDataPath} form is right.
if ($overrideMatches) {
Add-Verdict 'OK' 'BepInEx is configured to normalize metadata through the config setting'
} else {
Add-Verdict 'MISSING' 'GlobalMetadataPath points at the un-normalized global-metadata.dat'
}
} else {
Add-Verdict 'WARN' 'GlobalMetadataPath is set without a {GameDataPath} placeholder - confirm the path resolves'
}
} else {
$needsIt = $loaderExists -and -not $loaderPatched
if ($needsIt) {
Add-Verdict 'MISSING' 'no [IL2CPP] GlobalMetadataPath override in BepInEx/config/BepInEx.cfg - this is the fix for a stock loader'
} elseif (-not $loaderExists) {
Add-Verdict 'WARN' 'BepInEx/config/BepInEx.cfg has no GlobalMetadataPath (fine on the patched be.697 loader)'
} else {
Write-Both '[IL2CPP] GlobalMetadataPath: not set (not required: the loader is patched)'
}
}
Write-Both ''
# --- interop assemblies -------------------------------------------------------
Write-Both '== interop assemblies =='
if (Test-Path -LiteralPath $interopDir) {
$dlls = Get-ChildItem -LiteralPath $interopDir -Filter '*.dll' -File -ErrorAction SilentlyContinue
$count = @($dlls).Count
$hashFile = Join-Path $interopDir 'assembly-hash.txt'
$hashText = Get-TextIfExists $hashFile
Write-Both ("BepInEx/interop : {0} .dll files" -f $count)
if ($hashText) { Write-Both ("assembly-hash.txt : {0}{1}" -f $hashText.Trim(), $(if ($hashText.Trim() -eq $expected.InteropHash) { ' (matches the reference install)' } else { ' (different from the reference install - interop will be regenerated)' })) }
else { Write-Both 'assembly-hash.txt : missing' }
if ($count -ge $expected.InteropCount) {
Add-Verdict 'OK' ("interop assemblies are present ({0} .dll >= {1})" -f $count, $expected.InteropCount)
} elseif ($count -gt 0) {
Add-Verdict 'MISSING' ("only {0} interop assemblies (expected {1}) - a failed generation left the folder half-built" -f $count, $expected.InteropCount)
} else {
Add-Verdict 'MISSING' 'BepInEx/interop is empty - interop generation never completed'
}
} else {
Add-Verdict 'MISSING' 'BepInEx/interop does not exist - on a fresh start BepInEx tries to generate it, which needs the normalized metadata'
}
if (Test-Path -LiteralPath $unityLibs) {
$libCount = @(Get-ChildItem -LiteralPath $unityLibs -Filter '*.dll' -File).Count
Write-Both ("BepInEx/unity-libs : {0} .dll files" -f $libCount)
} else {
Add-Verdict 'WARN' 'BepInEx/unity-libs is missing - BepInEx will download it (needs internet) before generating interop'
}
Write-Both ''
# --- plugin and host pieces ---------------------------------------------------
Write-Both '== Radicade pieces =='
if (Test-Path -LiteralPath $plugin) {
$info = Get-Item -LiteralPath $plugin
$md5 = Get-Md5 $plugin
Write-Both ("RadicadePatch.dll: {0} b md5 {1}" -f $info.Length, $md5)
if ($md5 -eq $expected.PluginMd5) {
Add-Verdict 'OK' 'reference September client plugin'
} else {
Add-Verdict 'WARN' 'RadicadePatch.dll is a different build - fine if it is one you built yourself'
}
} else {
Add-Verdict 'MISSING' 'BepInEx/plugins/RadicadePatch.dll - nothing will patch the client'
}
if (Test-Path -LiteralPath $winhttp) {
$md5 = Get-Md5 $winhttp
if ($md5 -eq $expected.WinhttpMd5) { Add-Verdict 'OK' 'winhttp.dll is Doorstop 4.3.0 (reference)' }
else { Add-Verdict 'WARN' 'winhttp.dll is not the reference Doorstop - confirm nothing else owns the proxy dll' }
} else {
Add-Verdict 'MISSING' 'winhttp.dll - without it BepInEx never starts'
}
if (Test-Path -LiteralPath $doorstop) {
$text = Get-TextIfExists $doorstop
$target = if ($text -match '(?m)^\s*target_assembly\s*=\s*(.+?)\s*$') { $Matches[1] } else { '?' }
Write-Both ("doorstop target_assembly: {0}" -f $target)
if ($target -notmatch 'BepInEx\\core\\BepInEx\.Unity\.IL2CPP\.dll|BepInEx/core/BepInEx\.Unity\.IL2CPP\.dll') {
Add-Verdict 'MISSING' 'doorstop_config.ini does not target the IL2CPP chainloader'
}
} else {
Add-Verdict 'MISSING' 'doorstop_config.ini is missing'
}
Write-Both ''
# --- per-user client data -----------------------------------------------------
# %LOCALAPPDATA%Low\Against Gravity\Rec Room belongs to the Windows user, not to a game
# copy: every install on the PC shares it. A nameserver answer or a config cached there by
# another private server therefore outlives the folder it came from, which is how a client
# whose own metadata says one server starts asking a completely different one for its
# gameconfigs.
Write-Both '== per-user client data (shared by every install of this game) =='
if (Test-Path -LiteralPath $profileDir) {
Write-Both (" folder: {0}" -f $profileDir)
foreach ($name in @('Library', 'appstate.raw', 'Player.log', 'Player-prev.log')) {
$p = Join-Path $profileDir $name
if (Test-Path -LiteralPath $p) {
$info = Get-Item -LiteralPath $p
Write-Both (" {0,-16} {1,12} b last write {2}" -f $name, $info.Length, $info.LastWriteTime.ToString('yyyy-MM-dd HH:mm'))
}
}
foreach ($name in @('HTTPCache', 'Cookies', 'AnalyticsCache')) {
$p = Join-Path $profileDir $name
if (Test-Path -LiteralPath $p) {
$n = @(Get-ChildItem -LiteralPath $p -Recurse -File -ErrorAction SilentlyContinue).Count
Write-Both (" {0,-16} {1} file(s)" -f $name, $n)
}
}
$talkedTo = @{}
foreach ($name in @('Library', 'Player.log')) {
$text = Get-TextIfExists (Join-Path $profileDir $name)
if (-not $text) { continue }
foreach ($m in [regex]::Matches($text, 'https?://(?<host>[a-z0-9.-]+)')) {
$h = $m.Groups['host'].Value.ToLowerInvariant()
if ($talkedTo.ContainsKey($h)) { $talkedTo[$h]++ } else { $talkedTo[$h] = 1 }
}
}
if ($talkedTo.Count -gt 0) {
Write-Both ' hosts this profile has seen (cached responses and the last run):'
foreach ($h in @($talkedTo.GetEnumerator() |
ForEach-Object { [pscustomobject]@{ Host = $_.Key; Count = $_.Value } } |
Sort-Object -Property Count, Host -Descending |
Select-Object -First 25)) {
Write-Both (" {0,4}x {1}" -f $h.Count, $h.Host)
}
$stale = @($talkedTo.Keys | Where-Object { $_ -match 'recflare|viverec|trollroom|meownet' } | Sort-Object)
if ($stale.Count -gt 0) {
Write-Both (" note: this profile's cache still names another private server ({0}). Harmless while the client dials Radicade, but rename this folder before a fresh attempt so nothing stale can be replayed." -f ($stale -join ', '))
}
}
} else {
Write-Both (" {0} not found - nothing cached for this Windows user yet" -f $profileDir)
}
Write-Both ''
# --- what the last run said ---------------------------------------------------
Write-Both '== last BepInEx log =='
$logText = Get-TextIfExists $bepLog
if ($logText) {
$markers = [ordered]@{
'magic number check failed' = 'LibCpp2IL rejected the metadata header'
'Failed to generate Il2Cpp interop' = 'interop generation aborted'
'UnityEngine.CoreModule' = 'chainloader could not load Unity assemblies'
'Chainloader startup complete' = 'plugin loaded'
'Done!' = 'Il2CppInteropGen finished'
'TlsFatalAlert' = 'a TLS handshake was aborted (certificate refused)'
'user_canceled' = 'the embedded verifyer refused the certificate - host not on TLS:AllowedHosts'
'wantsToQuit answered false' = 'the anti-cheat shutdown was vetoed: the boot is stuck, it did not crash'
'Exception: Aborted' = 'the anti-cheat shutdown aborted the session'
'metadata was patched for' = 'the [Meta] warning fired (metadata and Connection:ServerUrl disagree)'
}
foreach ($k in $markers.Keys) {
$n = Get-MatchCount $logText ([regex]::Escape($k))
if ($n -gt 0) { Write-Both ("{0,4}x {1,-34} ({2})" -f $n, $k, $markers[$k]) }
}
if ((Get-MatchCount $logText 'magic number check failed') -gt 0 -and
(Get-MatchCount $logText 'Chainloader startup complete') -eq 0) {
Add-Verdict 'MISSING' 'the last run died in interop generation: BepInEx is pointed at the un-normalized metadata'
} elseif ((Get-MatchCount $logText 'Chainloader startup complete') -gt 0) {
Add-Verdict 'OK' 'the last run completed the chainloader'
}
# Reaching another private server is fatal, not cosmetic: nothing this client asks for
# exists there, so the boot never gets its configs and the anti-cheat path takes the
# session down.
$dialled = @()
foreach ($pat in @('recflare\.net', 'viverec\.com', 'trollroom', 'meownet')) {
if ((Get-MatchCount $logText $pat) -gt 0) { $dialled += ($pat -replace '\\', '') }
}
if ($dialled.Count -gt 0) {
Add-Verdict 'MISSING' ("the last run dialled another private server ({0}): this client's service bases are not Radicade's. Copy the bundle's Metadata folder over this install and rename %LOCALAPPDATA%Low\Against Gravity\Rec Room before the next launch." -f ($dialled -join ', '))
}
if ((Get-MatchCount $logText 'metadata was patched for') -gt 0) {
Add-Verdict 'WARN' 'the metadata marker and Connection:ServerUrl disagree - see the [Meta] line in the log'
}
$versionLine = ($logText -split "`n" | Where-Object { $_ -match 'BepInEx \d+\.\d+\.\d+-be\.\d+' } | Select-Object -First 1)
if ($versionLine) { Write-Both ("header: {0}" -f $versionLine.Trim()) }
} else {
Add-Verdict 'WARN' 'BepInEx/LogOutput.log not found - launch the game once so the log exists'
}
Write-Both ''
# --- verdict ------------------------------------------------------------------
Write-Both '== verdict =='
Write-Both ("missing: {0} warnings: {1}" -f $script:Failures, $script:Warnings)
if ($script:Failures -eq 0) {
Write-Both 'Every required piece is in place.'
} else {
Write-Both 'The MISSING lines above are the whole problem; fix them in that order.'
}
try {
Set-Content -LiteralPath $OutFile -Value ($script:Report -join "`r`n") -Encoding UTF8
Write-Host ''
Write-Host ("Report written to {0}" -f $OutFile)
} catch {
Write-Host ("Could not write {0}: {1}" -f $OutFile, $_)
}
exit $(if ($script:Failures -gt 0) { 1 } else { 0 })