From 4c35236084690d5fa92cd9076606dc918ab7475a 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; });