From 5af3fa8fc584442b3dd31efe7dd2ea7f8cce1bd0 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:21:56 +0300 Subject: [PATCH] feat: Add ProtoDumper for runtime Google.Protobuf schema extraction and decoding --- .../DorkNet.ProtoDumper.csproj | 76 +++++ DorkNet.ProtoDumper/ProtoDumper.cs | 259 ++++++++++++++++++ DorkNet.ProtoDumper/README.md | 81 ++++++ DorkNet.ProtoDumper/decode_dump.py | 165 +++++++++++ .../API/Rooms/V2/RoomsController.cs | 2 + 5 files changed, 583 insertions(+) create mode 100644 DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj create mode 100644 DorkNet.ProtoDumper/ProtoDumper.cs create mode 100644 DorkNet.ProtoDumper/README.md create mode 100644 DorkNet.ProtoDumper/decode_dump.py diff --git a/DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj b/DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj new file mode 100644 index 0000000..0c3f2bf --- /dev/null +++ b/DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj @@ -0,0 +1,76 @@ + + + + + + net6.0 + latest + enable + DorkNet.ProtoDumper + DorkNet.ProtoDumper + false + false + $(NoWarn);CS8602;CS8604;CS0436 + + + + + v0.6.6 + $(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader + $(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip + $(MelonLoaderCacheRoot)\MelonLoader + net8 + net6 + + + + + + + + + + + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll + false + + + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll + false + + + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll + false + + + $(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll + false + + + + diff --git a/DorkNet.ProtoDumper/ProtoDumper.cs b/DorkNet.ProtoDumper/ProtoDumper.cs new file mode 100644 index 0000000..941e19f --- /dev/null +++ b/DorkNet.ProtoDumper/ProtoDumper.cs @@ -0,0 +1,259 @@ +// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor. +// +// Rec Room compiles its room-save / creation / circuits data model from +// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build +// those classes are name-obfuscated and the descriptor base64 is split into +// scattered string-literal chunks, so static reconstruction from +// global-metadata.dat is unreliable. +// +// At RUNTIME every FileDescriptor still carries its original, un-obfuscated +// FileDescriptorProto (message names, field names, numbers, types intact). +// The catch: these descriptors are built LAZILY and in a specific dependency +// order by the game. Force-building them ourselves (by reading a message +// type's static descriptor property out of order) throws inside the type's +// static constructor and the failure is cached — poisoning the type. So we do +// NOT enumerate types. Instead we Harmony-postfix the one library method every +// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor +// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game +// builds it. Completely passive: just play the client through the dorm, a +// created room, the Maker Pen and a circuits board, and every descriptor that +// loads is recorded. +// +// Output (under \DorkNet-ProtoDump\): +// manifest.tsv — one "\t" line per file +// files\.b64 — same base64, one file each +// dump.log — capture log +// +// Decode offline with decode_dump.py (see README.md). + +using HarmonyLib; +using MelonLoader; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; + +[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")] +[assembly: MelonGame(null, null)] + +namespace DorkNet.ProtoDumper; + +public class ProtoDumper : MelonMod +{ + private static MelonLogger.Instance? _log; + + // proto file name -> descriptor base64 (accumulated as the game builds them) + private static readonly Dictionary Files = new(StringComparer.Ordinal); + private static readonly object Gate = new(); + private static string _outDir = ""; + private static string _filesDir = ""; + private static volatile bool _dirty; + private int _frame; + + public override void OnInitializeMelon() + { + _log = LoggerInstance; + try + { + string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory(); + _outDir = Path.Combine(baseDir, "DorkNet-ProtoDump"); + _filesDir = Path.Combine(_outDir, "files"); + Directory.CreateDirectory(_filesDir); + _log.Msg($"ProtoDumper ready. Output dir: {_outDir}"); + } + catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); } + + InstallHooks(); + } + + private void InstallHooks() + { + try + { + // Google.Protobuf library type — NOT obfuscated (it's the library, + // not game code), reached by its original name via every loaded + // assembly so we never reference the interop DLL at compile time. + Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor"); + if (fd == null) + { + _log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook."); + return; + } + + var postfix = new HarmonyMethod( + typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix), + BindingFlags.Public | BindingFlags.Static)); + + int patched = 0; + // BuildFrom is the lower-level builder every descriptor passes + // through; FromGeneratedCode is the generated-code entry point. + // Patch both (deduped downstream) so nothing is missed. + foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" }) + { + foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != name) continue; + try { HarmonyInstance.Patch(m, postfix: postfix); patched++; } + catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); } + } + } + _log?.Msg($"ProtoDumper: installed {patched} descriptor-build hooks. Play through your content; descriptors are captured as they load."); + } + catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); } + } + + // Resolve a type by its ORIGINAL (game-side) name, accounting for the + // Il2Cpp prefix Il2CppInterop puts on the namespace (so + // "Google.Protobuf.Reflection.FileDescriptor" -> + // "Il2CppGoogle.Protobuf.Reflection.FileDescriptor"). + private static Type? ResolveType(string name) + { + foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false); + if (t != null) return t; + int dot = name.IndexOf('.'); + if (dot > 0) + { + var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false); + if (ns != null) return ns; + } + } + catch { } + } + return null; + } + + // Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode. + // __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as + // object — same pattern the ClientMod uses for il2cpp reference returns). + public static void Captured_Postfix(object? __result) + { + if (__result == null) return; + try { CollectFile(__result); } + catch { /* never let our hook disturb the game */ } + } + + private static void CollectFile(object file) + { + string? name = GetProp(file, "Name") as string; + if (string.IsNullOrEmpty(name)) return; + + lock (Gate) + { + if (Files.ContainsKey(name!)) return; + + string b64 = DescriptorBase64(file); + Files[name!] = b64; + _dirty = true; + } + + // Capture import dependencies too (closure of well-known + leaf protos). + try + { + object? deps = GetProp(file, "Dependencies"); + if (deps != null) + { + var dt = deps.GetType(); + var countP = dt.GetProperty("Count"); + var itemM = dt.GetMethod("get_Item", new[] { typeof(int) }); + if (countP != null && itemM != null) + { + int n = (int)(countP.GetValue(deps) ?? 0); + for (int i = 0; i < n; i++) + { + object? dep = itemM.Invoke(deps, new object[] { i }); + if (dep != null) CollectFile(dep); + } + } + } + } + catch { } + } + + private static string DescriptorBase64(object file) + { + try + { + // FileDescriptor exposes no public SerializedData property — only + // the Il2CppInterop backing field (a ByteString with the original + // descriptor bytes). Fall back to re-serialising the parsed Proto. + object? sd = GetProp(file, "_SerializedData_k__BackingField") + ?? GetProp(file, "SerializedData"); + if (sd != null) + { + string b = ByteStringToBase64(sd); + if (b.Length > 0) return b; + } + object? proto = GetProp(file, "Proto"); + if (proto != null) + { + MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes); + object? bs = toBs?.Invoke(proto, null); + if (bs != null) return ByteStringToBase64(bs); + } + } + catch { } + return ""; + } + + private static string ByteStringToBase64(object byteString) + { + MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes); + return toB64?.Invoke(byteString, null) as string ?? ""; + } + + private static object? GetProp(object obj, string propName) + { + var p = obj.GetType().GetProperty(propName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + return p?.GetValue(obj); + } + + public override void OnUpdate() + { + // Flush to disk shortly after new descriptors are captured (every ~1s). + if (++_frame % 60 == 0 && _dirty) + Flush(); + } + + public override void OnApplicationQuit() => Flush(); + + private static void Flush() + { + lock (Gate) + { + if (!_dirty) return; + _dirty = false; + try + { + var sb = new StringBuilder(); + int withData = 0; + foreach (var kv in Files) + { + if (kv.Value.Length == 0) continue; + withData++; + sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n'); + string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64"); + if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value); + } + File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString()); + string line = $"captured files={Files.Count} (with-data={withData})"; + File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n"); + _log?.Msg($"ProtoDumper: {line} -> {_outDir}"); + } + catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); } + } + } + + private static string Sanitize(string protoPath) + { + var sb = new StringBuilder(protoPath.Length); + foreach (char c in protoPath) + sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_'); + return sb.ToString(); + } +} diff --git a/DorkNet.ProtoDumper/README.md b/DorkNet.ProtoDumper/README.md new file mode 100644 index 0000000..39bdda6 --- /dev/null +++ b/DorkNet.ProtoDumper/README.md @@ -0,0 +1,81 @@ +# DorkNet ProtoDumper + +Runtime extractor for Rec Room's Google.Protobuf schema (room save / +creation / circuits data model). Produces clean `.proto` files for any +client build — including obfuscated ones like 2023.03.21 where static +reconstruction from `global-metadata.dat` is unreliable (the descriptor +base64 is split into scattered string-literal chunks whose concat order +only lives in IL). + +At runtime every `FileDescriptor` still carries its original, +un-obfuscated `FileDescriptorProto` (real message names, field names, +numbers and types intact). The catch: Rec Room builds these descriptors +**lazily and in a specific dependency order** as content loads. Trying to +force-build them by reading a message type's static descriptor out of +order throws inside the type's static constructor, and the failure is +cached — *poisoning* the type (and crashing the client). So this mod does +**not** enumerate types. + +Instead it Harmony-postfixes the one library method every generated cctor +funnels through — +`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` / +`.FromGeneratedCode` — and records each descriptor as the game builds it. +Completely passive: **play the client through the content you care about** +and every descriptor that loads is captured. + +## Pipeline + +1. **Build the mod** + + ```sh + dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release + ``` + + Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`. + +2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)** + + > The `GameAssembly.dll` + `il2cpp_data`-only copy under + > `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an + > **offline-analysis payload, not a runnable build**. Use a full build + > with the exe + UnityPlayer + MelonLoader (e.g. the devdork package). + + - Drop `DorkNet.ProtoDumper.dll` into `\Mods\`. On launch it + installs the descriptor-build hooks (see `dump.log` / MelonLoader + console for "installed N descriptor-build hooks"). + - **Log in and play through the content whose schema you want:** your + dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each + subsystem builds its protos the first time it loads. Descriptors are + captured the instant they build — no scene/timer scanning, nothing is + force-built, so the client stays stable. + - Output appears under `\DorkNet-ProtoDump\` and is flushed to + disk ~1 s after new descriptors load, and again on quit: + - `manifest.tsv` — `\t` per file + - `files\*.b64` — same, one file each + - `dump.log` — running capture count + +3. **Decode to .proto** + + ```sh + pip install protobuf + python DorkNet.ProtoDumper/decode_dump.py /manifest.tsv proto_out + ``` + + Emits one `.proto` per descriptor under `proto_out/`, plus a combined + `proto_out/recroom_2023.proto` (every rec_room/circuits message in one + file) for an easy diff against + `DorkNet.Server/Protos/recroom_2020.proto`. + +## Notes + +- No game DLLs are referenced at compile time. `FileDescriptor` is the + Google.Protobuf *library* type (not obfuscated); it's reached by its + original name with the Il2Cpp namespace-prefix fallback, and its members + are read via reflection. The same DLL works across builds and stays clean + of derivative game code. +- The only Harmony patches are the two read-only descriptor-build + postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`. +- The descriptor base64 is the same `FileDescriptorProto` wire format + Google's own tooling emits, so `decode_dump.py` (or `protoc + --decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it + directly. diff --git a/DorkNet.ProtoDumper/decode_dump.py b/DorkNet.ProtoDumper/decode_dump.py new file mode 100644 index 0000000..b14281d --- /dev/null +++ b/DorkNet.ProtoDumper/decode_dump.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Decode a DorkNet ProtoDumper manifest into .proto text. + +Usage: + python decode_dump.py manifest.tsv [out_dir] + +manifest.tsv has one "\\t" line per +file (as written by the ProtoDumper MelonLoader mod). This emits one .proto +file per descriptor under out_dir/ (default: ./proto_out), plus a combined +recroom_2023.proto with every rec_room/circuits message in one file for an +easy diff against DorkNet.Server/Protos/recroom_2020.proto. + +Requires: pip install protobuf +""" +import sys, os, base64, re +from google.protobuf import descriptor_pb2 as D + +FDP = D.FileDescriptorProto + +# proto3 scalar type names keyed by FieldDescriptorProto.Type +SCALAR = { + 1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32", + 6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes", + 13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64", +} +LABEL_REPEATED = 3 + + +def type_name(f): + if f.type in (11, 14): # message / enum -> use type_name (strip leading dot) + return f.type_name.lstrip(".") + return SCALAR.get(f.type, f"type{f.type}") + + +def emit_enum(e, indent): + pad = " " * indent + out = [f"{pad}enum {e.name} {{"] + for v in e.value: + out.append(f"{pad} {v.name} = {v.number};") + out.append(f"{pad}}}") + return out + + +def emit_message(m, indent=0): + pad = " " * indent + out = [f"{pad}message {m.name} {{"] + + # map<> detection: synthetic nested *Entry types with map_entry option + map_entries = {} + for nt in m.nested_type: + if nt.options.map_entry: + kt = type_name(nt.field[0]) + vt = type_name(nt.field[1]) + map_entries[nt.name] = (kt, vt) + + # which oneofs are real (not proto3 optional synthetic) + for nt in m.nested_type: + if nt.name in map_entries: + continue + out += emit_message(nt, indent + 1) + for en in m.enum_type: + out += emit_enum(en, indent + 1) + + # group fields by oneof + printed_oneof = set() + for f in m.field: + # map field? + if f.type == 11 and f.type_name.split(".")[-1] in map_entries: + kt, vt = map_entries[f.type_name.split(".")[-1]] + out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};") + continue + + if f.HasField("oneof_index") and not f.proto3_optional: + idx = f.oneof_index + if idx not in printed_oneof: + printed_oneof.add(idx) + out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{") + for g in m.field: + if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional: + out.append(f"{pad} {type_name(g)} {g.name} = {g.number};") + out.append(f"{pad} }}") + continue + + label = "repeated " if f.label == LABEL_REPEATED else "" + opt = "optional " if f.proto3_optional else "" + out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};") + + out.append(f"{pad}}}") + return out + + +def render_file(fdp): + out = [] + syntax = fdp.syntax or "proto2" + out.append(f'syntax = "{syntax}";') + out.append("") + if fdp.package: + out.append(f"package {fdp.package};") + for dep in fdp.dependency: + out.append(f'import "{dep}";') + if fdp.package or fdp.dependency: + out.append("") + for en in fdp.enum_type: + out += emit_enum(en, 0) + out.append("") + for m in fdp.message_type: + out += emit_message(m, 0) + out.append("") + return "\n".join(out).rstrip() + "\n" + + +def main(): + if len(sys.argv) < 2: + print(__doc__); sys.exit(1) + manifest = sys.argv[1] + out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out" + os.makedirs(out_dir, exist_ok=True) + + files = [] + with open(manifest, "r", encoding="utf-8") as fh: + for ln in fh: + ln = ln.rstrip("\n") + if not ln or "\t" not in ln: + continue + name, b64 = ln.split("\t", 1) + try: + raw = base64.b64decode(b64) + fdp = FDP(); fdp.ParseFromString(raw) + except Exception as e: + print(f" ! failed to parse {name}: {e}") + continue + files.append((name, fdp)) + + print(f"parsed {len(files)} descriptors") + + # per-file .proto + for name, fdp in files: + safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name) + with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w: + w.write(render_file(fdp)) + + # combined rec_room/circuits view (skip google/* well-known imports) + rr = [ (n,f) for n,f in files if not n.startswith("google/") ] + seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""] + total_msgs = 0 + for name, fdp in sorted(rr, key=lambda x: x[0]): + combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====") + for m in fdp.message_type: + if m.name in seen: + continue + seen.add(m.name) + combo += emit_message(m, 0) + combo.append("") + total_msgs += 1 + for en in fdp.enum_type: + combo += emit_enum(en, 0) + combo.append("") + with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w: + w.write("\n".join(combo).rstrip() + "\n") + print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages " + f"from {len(rr)} rec_room/circuits descriptors") + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 0dab111..7b4c19c 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -3204,6 +3204,8 @@ public class RoomsController( [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] + [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [Authorize] public Task SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) => MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });