mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Add ProtoDumper for runtime Google.Protobuf schema extraction and decoding
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
|
||||
Google.Protobuf FileDescriptor that the running client has loaded.
|
||||
|
||||
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
|
||||
build (2020, 2023, …) without the version-specific ClientMod patches
|
||||
misfiring. Everything game-side is reached through plain
|
||||
System.Reflection over the Il2CppInterop proxy assemblies, so no game
|
||||
DLLs are referenced at compile time (they would be derivative works).
|
||||
|
||||
Build (desktop net6 MelonLoader, same pin as the ClientMod):
|
||||
|
||||
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
|
||||
|
||||
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
|
||||
MelonLoader-patched build and launch. Output lands in
|
||||
<RecRoom>\DorkNet-ProtoDump\.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
|
||||
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
|
||||
<PropertyGroup>
|
||||
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
|
||||
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
|
||||
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
|
||||
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
|
||||
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="EnsureMelonLoaderReferences"
|
||||
BeforeTargets="ResolveAssemblyReferences"
|
||||
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
|
||||
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Unzip SourceFiles="$(MelonLoaderZip)"
|
||||
DestinationFolder="$(MelonLoaderCacheRoot)"
|
||||
OverwriteReadOnlyFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="MelonLoader">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Runtime">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppInterop.Common">
|
||||
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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 <RecRoom>\DorkNet-ProtoDump\):
|
||||
// manifest.tsv — one "<proto path>\t<base64>" line per file
|
||||
// files\<sanitised>.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<string, string> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 `<RecRoom>\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 `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
|
||||
disk ~1 s after new descriptors load, and again on quit:
|
||||
- `manifest.tsv` — `<proto path>\t<base64>` 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 <path>/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.
|
||||
@@ -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 "<proto path>\\t<base64 FileDescriptorProto>" 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()
|
||||
Reference in New Issue
Block a user