mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/bin/
|
||||
/obj/
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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] : "");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.`);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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] <path> [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>
|
||||
@@ -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();
|
||||
}
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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.'
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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`""
|
||||
@@ -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))
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('&', '&').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}`);
|
||||
@@ -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>`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
@@ -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)'
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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 })
|
||||
Reference in New Issue
Block a user