mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
using Google.Protobuf;
|
||||
using RecRoom.Protobuf2020;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Normalises an uploaded <c>.room</c> protobuf blob so the 2020.12
|
||||
/// watch can actually render it.
|
||||
///
|
||||
/// Two passes:
|
||||
///
|
||||
/// 1. <b>Quaternion → Euler projection</b>. Modern Rec Room (2024+)
|
||||
/// writes every <c>TransformData</c>'s rotation as a quaternion
|
||||
/// at field 6 (<c>quaternion_rotation</c>, a
|
||||
/// <c>core.QuaternionData</c> sub-message), leaving the legacy
|
||||
/// Euler Vector3 at field 2 empty. The 2020.12 watch reads only
|
||||
/// field 2 — so every shape, gizmo, and child view loads at
|
||||
/// <c>rotation=(0,0,0)</c>. The result is a room whose pieces
|
||||
/// are at the right positions but pointing the wrong way: trains
|
||||
/// floating off tracks, walls turned 90° wrong, panels facing
|
||||
/// the floor, etc. (Verified empirically against several Studio-
|
||||
/// imported scenes vs. the working Rockulator.)
|
||||
///
|
||||
/// We walk <c>PersistedRoomData.persistence_views</c>, recurse
|
||||
/// into each <c>PersistenceViewData.transform</c> and
|
||||
/// <c>child_views[].data.transform</c>, and when we find a
|
||||
/// <c>quaternion_rotation</c> field with no
|
||||
/// <c>rotation</c> Euler set, convert the quaternion to Euler
|
||||
/// degrees (Unity convention, Tait-Bryan ZXY) and emit it as
|
||||
/// field 2.
|
||||
///
|
||||
/// 2. <b>Round-trip through the 2020 schema</b>. After the
|
||||
/// projection pass, parse the bytes with
|
||||
/// <c>RecRoom.Protobuf2020.PersistedRoomData.Parser</c> and
|
||||
/// re-serialise. Strips non-canonical encodings that the
|
||||
/// stricter 2020 parser sometimes rejected and ensures the
|
||||
/// output is exactly what the watch expects on the wire.
|
||||
///
|
||||
/// On parse failure we hand the original bytes back unchanged —
|
||||
/// better to upload a blob that MAY work than to drop the user's
|
||||
/// content. Callers (the admin import endpoint) log the outcome.
|
||||
/// </summary>
|
||||
public sealed class RoomBlobNormalizerService(ILogger<RoomBlobNormalizerService> logger)
|
||||
{
|
||||
public sealed record Result(byte[] Bytes, bool Normalized, int InputBytes, int OutputBytes, string? Error, int QuaternionsProjected);
|
||||
|
||||
public Result Normalize(byte[] input)
|
||||
{
|
||||
if (input is null || input.Length == 0)
|
||||
return new Result(Array.Empty<byte>(), false, 0, 0, "empty_input", 0);
|
||||
|
||||
try
|
||||
{
|
||||
// Pass 1: project quaternion rotations to Euler so the
|
||||
// 2020.12 watch's TransformData.rotation deserialiser
|
||||
// sees something at field 2.
|
||||
var (projected, quatsProjected) = ProjectQuaternionsToEulerOnTransforms(input);
|
||||
|
||||
// Pass 2: canonical re-encode via the 2020 schema.
|
||||
var msg = PersistedRoomData.Parser.ParseFrom(projected);
|
||||
var output = msg.ToByteArray();
|
||||
logger.LogInformation(
|
||||
"[normalize] OK quats_projected={Quats} in={In:N0} bytes, after_projection={Mid:N0} bytes, out={Out:N0} bytes ({Pct:F2}%)",
|
||||
quatsProjected, input.Length, projected.Length, output.Length, (double)output.Length / input.Length * 100);
|
||||
return new Result(output, true, input.Length, output.Length, null, quatsProjected);
|
||||
}
|
||||
catch (InvalidProtocolBufferException ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[normalize] parse FAIL ({Bytes:N0} bytes): {Message} — passing original bytes through",
|
||||
input.Length, ex.Message);
|
||||
return new Result(input, false, input.Length, input.Length, ex.Message, 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"[normalize] unexpected exception ({Bytes:N0} bytes) — passing original bytes through",
|
||||
input.Length);
|
||||
return new Result(input, false, input.Length, input.Length, ex.Message, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire-format rewriter ──────────────────────────────────────────
|
||||
//
|
||||
// Doing this through google.protobuf would require either compiling
|
||||
// the modern proto schema as a parallel C# namespace OR using
|
||||
// reflection + UnknownFieldSet hacks. Neither is appealing —
|
||||
// straight wire-format manipulation is ~100 lines and surgical.
|
||||
//
|
||||
// Protobuf wire format primer:
|
||||
// tag = (field_number << 3) | wire_type, encoded as varint.
|
||||
// wire_type 0 = varint, 1 = fixed64, 2 = length-delimited,
|
||||
// 5 = fixed32.
|
||||
// Length-delimited values are prefixed with a varint length. To
|
||||
// edit children we have to recompute the length after editing,
|
||||
// so we accumulate children into a per-level MemoryStream and
|
||||
// emit `tag + length + payload` once we know the final byte count.
|
||||
|
||||
private const int FIELD_PERSISTED_ROOM_DATA_PERSISTENCE_VIEWS = 2;
|
||||
private const int FIELD_PERSISTENCE_VIEW_DATA_CHILD_VIEWS = 3;
|
||||
private const int FIELD_PERSISTENCE_VIEW_DATA_TRANSFORM = 10;
|
||||
private const int FIELD_CHILD_PERSISTENCE_VIEW_DATA_DATA = 2;
|
||||
private const int FIELD_TRANSFORM_ROTATION_EULER = 2;
|
||||
private const int FIELD_TRANSFORM_QUATERNION_ROTATION = 6;
|
||||
private const int FIELD_QUATERNION_W = 1;
|
||||
private const int FIELD_QUATERNION_X = 2;
|
||||
private const int FIELD_QUATERNION_Y = 3;
|
||||
private const int FIELD_QUATERNION_Z = 4;
|
||||
|
||||
private static (byte[] Bytes, int QuatsProjected) ProjectQuaternionsToEulerOnTransforms(byte[] input)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
int quatsProjected = 0;
|
||||
RewriteRoot(input, 0, input.Length, ms, ref quatsProjected);
|
||||
return (ms.ToArray(), quatsProjected);
|
||||
}
|
||||
|
||||
private static void RewriteRoot(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
|
||||
{
|
||||
while (pos < end)
|
||||
{
|
||||
ulong tag = ReadVarint(buf, ref pos);
|
||||
int field = (int)(tag >> 3);
|
||||
int wire = (int)(tag & 0x07);
|
||||
|
||||
if (wire == 2 && field == FIELD_PERSISTED_ROOM_DATA_PERSISTENCE_VIEWS)
|
||||
{
|
||||
int len = (int)ReadVarint(buf, ref pos);
|
||||
using var childMs = new MemoryStream();
|
||||
RewritePersistenceView(buf, pos, pos + len, childMs, ref quatsProjected);
|
||||
EmitLengthDelimited(output, field, childMs.ToArray());
|
||||
pos += len;
|
||||
}
|
||||
else
|
||||
{
|
||||
CopyField(buf, ref pos, wire, output, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewritePersistenceView(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
|
||||
{
|
||||
while (pos < end)
|
||||
{
|
||||
ulong tag = ReadVarint(buf, ref pos);
|
||||
int field = (int)(tag >> 3);
|
||||
int wire = (int)(tag & 0x07);
|
||||
|
||||
if (wire == 2 && field == FIELD_PERSISTENCE_VIEW_DATA_TRANSFORM)
|
||||
{
|
||||
int len = (int)ReadVarint(buf, ref pos);
|
||||
using var childMs = new MemoryStream();
|
||||
RewriteTransform(buf, pos, pos + len, childMs, ref quatsProjected);
|
||||
EmitLengthDelimited(output, field, childMs.ToArray());
|
||||
pos += len;
|
||||
}
|
||||
else if (wire == 2 && field == FIELD_PERSISTENCE_VIEW_DATA_CHILD_VIEWS)
|
||||
{
|
||||
int len = (int)ReadVarint(buf, ref pos);
|
||||
using var childMs = new MemoryStream();
|
||||
RewriteChildPersistenceView(buf, pos, pos + len, childMs, ref quatsProjected);
|
||||
EmitLengthDelimited(output, field, childMs.ToArray());
|
||||
pos += len;
|
||||
}
|
||||
else
|
||||
{
|
||||
CopyField(buf, ref pos, wire, output, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteChildPersistenceView(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
|
||||
{
|
||||
// ChildPersistenceViewData has field 1 (child_id, varint) and
|
||||
// field 2 (data, a nested PersistenceViewData).
|
||||
while (pos < end)
|
||||
{
|
||||
ulong tag = ReadVarint(buf, ref pos);
|
||||
int field = (int)(tag >> 3);
|
||||
int wire = (int)(tag & 0x07);
|
||||
|
||||
if (wire == 2 && field == FIELD_CHILD_PERSISTENCE_VIEW_DATA_DATA)
|
||||
{
|
||||
int len = (int)ReadVarint(buf, ref pos);
|
||||
using var childMs = new MemoryStream();
|
||||
RewritePersistenceView(buf, pos, pos + len, childMs, ref quatsProjected);
|
||||
EmitLengthDelimited(output, field, childMs.ToArray());
|
||||
pos += len;
|
||||
}
|
||||
else
|
||||
{
|
||||
CopyField(buf, ref pos, wire, output, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteTransform(byte[] buf, int pos, int end, Stream output, ref int quatsProjected)
|
||||
{
|
||||
// Pass 1 over the TransformData: detect whether field 2 (Euler)
|
||||
// is set and whether field 6 (quaternion) is set. If we have
|
||||
// a quaternion but no Euler, we'll synthesise an Euler at the
|
||||
// end and skip the quaternion field on the second pass.
|
||||
bool hasEuler = false;
|
||||
bool hasQuat = false;
|
||||
float qw = 1, qx = 0, qy = 0, qz = 0;
|
||||
|
||||
int scanPos = pos;
|
||||
while (scanPos < end)
|
||||
{
|
||||
ulong tag = ReadVarint(buf, ref scanPos);
|
||||
int field = (int)(tag >> 3);
|
||||
int wire = (int)(tag & 0x07);
|
||||
if (wire == 2)
|
||||
{
|
||||
int len = (int)ReadVarint(buf, ref scanPos);
|
||||
if (field == FIELD_TRANSFORM_ROTATION_EULER) hasEuler = true;
|
||||
else if (field == FIELD_TRANSFORM_QUATERNION_ROTATION)
|
||||
{
|
||||
hasQuat = true;
|
||||
ReadQuaternion(buf, scanPos, scanPos + len, out qw, out qx, out qy, out qz);
|
||||
}
|
||||
scanPos += len;
|
||||
}
|
||||
else
|
||||
{
|
||||
SkipField(buf, ref scanPos, wire);
|
||||
}
|
||||
}
|
||||
|
||||
bool shouldProject = hasQuat && !hasEuler;
|
||||
|
||||
// Pass 2: re-emit the TransformData. Copy every field through
|
||||
// unchanged EXCEPT the quaternion (field 6) which we drop when
|
||||
// we're going to synthesise an Euler from it.
|
||||
int rewritePos = pos;
|
||||
while (rewritePos < end)
|
||||
{
|
||||
int fieldStart = rewritePos;
|
||||
ulong tag = ReadVarint(buf, ref rewritePos);
|
||||
int field = (int)(tag >> 3);
|
||||
int wire = (int)(tag & 0x07);
|
||||
|
||||
if (shouldProject && field == FIELD_TRANSFORM_QUATERNION_ROTATION)
|
||||
{
|
||||
// Skip — we're replacing it.
|
||||
SkipField(buf, ref rewritePos, wire);
|
||||
continue;
|
||||
}
|
||||
// Re-emit field unchanged (tag + value bytes).
|
||||
rewritePos = fieldStart;
|
||||
CopyFieldWithTag(buf, ref rewritePos, output);
|
||||
}
|
||||
|
||||
if (shouldProject)
|
||||
{
|
||||
var (ex, ey, ez) = QuaternionToUnityEulerDegrees(qx, qy, qz, qw);
|
||||
using var eulerMs = new MemoryStream();
|
||||
// Vector3Data: float x = 1, y = 2, z = 3 (all fixed32 wire type 5).
|
||||
WriteFloat(eulerMs, 1, ex);
|
||||
WriteFloat(eulerMs, 2, ey);
|
||||
WriteFloat(eulerMs, 3, ez);
|
||||
EmitLengthDelimited(output, FIELD_TRANSFORM_ROTATION_EULER, eulerMs.ToArray());
|
||||
quatsProjected++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadQuaternion(byte[] buf, int pos, int end, out float w, out float x, out float y, out float z)
|
||||
{
|
||||
w = 1; x = 0; y = 0; z = 0;
|
||||
while (pos < end)
|
||||
{
|
||||
ulong tag = ReadVarint(buf, ref pos);
|
||||
int field = (int)(tag >> 3);
|
||||
int wire = (int)(tag & 0x07);
|
||||
if (wire == 5) // fixed32 = float
|
||||
{
|
||||
float v = BitConverter.ToSingle(buf, pos);
|
||||
pos += 4;
|
||||
switch (field)
|
||||
{
|
||||
case FIELD_QUATERNION_W: w = v; break;
|
||||
case FIELD_QUATERNION_X: x = v; break;
|
||||
case FIELD_QUATERNION_Y: y = v; break;
|
||||
case FIELD_QUATERNION_Z: z = v; break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SkipField(buf, ref pos, wire);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unity uses the ZXY Tait-Bryan convention for Quaternion.eulerAngles.
|
||||
// Formula adapted from Unity's source (Quaternion → euler in
|
||||
// degrees), with a clamped asin to guard against floating-point
|
||||
// values just outside [-1, 1] from non-unit quaternions.
|
||||
private static (float x, float y, float z) QuaternionToUnityEulerDegrees(float qx, float qy, float qz, float qw)
|
||||
{
|
||||
const double rad2deg = 180.0 / Math.PI;
|
||||
|
||||
double sinrCosp = 2.0 * (qw * qx + qy * qz);
|
||||
double cosrCosp = 1.0 - 2.0 * (qx * qx + qy * qy);
|
||||
double xRad = Math.Atan2(sinrCosp, cosrCosp);
|
||||
|
||||
double sinp = 2.0 * (qw * qy - qz * qx);
|
||||
if (sinp > 1.0) sinp = 1.0; else if (sinp < -1.0) sinp = -1.0;
|
||||
double yRad = Math.Asin(sinp);
|
||||
|
||||
double sinyCosp = 2.0 * (qw * qz + qx * qy);
|
||||
double cosyCosp = 1.0 - 2.0 * (qy * qy + qz * qz);
|
||||
double zRad = Math.Atan2(sinyCosp, cosyCosp);
|
||||
|
||||
return ((float)(xRad * rad2deg), (float)(yRad * rad2deg), (float)(zRad * rad2deg));
|
||||
}
|
||||
|
||||
// ── Varint + field helpers ────────────────────────────────────────
|
||||
|
||||
private static ulong ReadVarint(byte[] buf, ref int pos)
|
||||
{
|
||||
ulong val = 0;
|
||||
int shift = 0;
|
||||
while (true)
|
||||
{
|
||||
byte b = buf[pos++];
|
||||
val |= (ulong)(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return val;
|
||||
shift += 7;
|
||||
if (shift >= 64) throw new InvalidDataException("varint too long");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteVarint(Stream s, ulong v)
|
||||
{
|
||||
while (v >= 0x80)
|
||||
{
|
||||
s.WriteByte((byte)(v | 0x80));
|
||||
v >>= 7;
|
||||
}
|
||||
s.WriteByte((byte)v);
|
||||
}
|
||||
|
||||
private static void EmitLengthDelimited(Stream s, int field, byte[] payload)
|
||||
{
|
||||
ulong tag = ((ulong)field << 3) | 2;
|
||||
WriteVarint(s, tag);
|
||||
WriteVarint(s, (ulong)payload.Length);
|
||||
s.Write(payload, 0, payload.Length);
|
||||
}
|
||||
|
||||
private static void WriteFloat(Stream s, int field, float value)
|
||||
{
|
||||
ulong tag = ((ulong)field << 3) | 5;
|
||||
WriteVarint(s, tag);
|
||||
var bytes = BitConverter.GetBytes(value);
|
||||
s.Write(bytes, 0, 4);
|
||||
}
|
||||
|
||||
private static void SkipField(byte[] buf, ref int pos, int wire)
|
||||
{
|
||||
switch (wire)
|
||||
{
|
||||
case 0: ReadVarint(buf, ref pos); break;
|
||||
case 1: pos += 8; break;
|
||||
case 2: { int n = (int)ReadVarint(buf, ref pos); pos += n; break; }
|
||||
case 5: pos += 4; break;
|
||||
default: throw new InvalidDataException($"unsupported wire type {wire}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copy a single field's value (post-tag) verbatim from
|
||||
/// <paramref name="buf"/> to <paramref name="output"/>, advancing
|
||||
/// <paramref name="pos"/> past the value. <paramref name="tag"/>
|
||||
/// is the already-decoded wire tag the caller already consumed
|
||||
/// from the input — we re-emit it to <paramref name="output"/>
|
||||
/// before the value bytes.</summary>
|
||||
private static void CopyField(byte[] buf, ref int pos, int wire, Stream output, ulong tag)
|
||||
{
|
||||
WriteVarint(output, tag);
|
||||
switch (wire)
|
||||
{
|
||||
case 0: // varint
|
||||
{
|
||||
int start = pos;
|
||||
ReadVarint(buf, ref pos);
|
||||
output.Write(buf, start, pos - start);
|
||||
break;
|
||||
}
|
||||
case 1: // fixed64
|
||||
output.Write(buf, pos, 8); pos += 8; break;
|
||||
case 2: // length-delimited
|
||||
{
|
||||
int lengthStart = pos;
|
||||
int len = (int)ReadVarint(buf, ref pos);
|
||||
output.Write(buf, lengthStart, pos - lengthStart); // length varint
|
||||
output.Write(buf, pos, len);
|
||||
pos += len;
|
||||
break;
|
||||
}
|
||||
case 5: // fixed32
|
||||
output.Write(buf, pos, 4); pos += 4; break;
|
||||
default:
|
||||
throw new InvalidDataException($"unsupported wire type {wire}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copy an entire field (tag + value) verbatim to
|
||||
/// <paramref name="output"/>. Used when re-emitting fields we
|
||||
/// didn't decide to rewrite — we re-read the tag from
|
||||
/// <paramref name="buf"/> rather than pass it through, which makes
|
||||
/// the call site cleaner.</summary>
|
||||
private static void CopyFieldWithTag(byte[] buf, ref int pos, Stream output)
|
||||
{
|
||||
int tagStart = pos;
|
||||
ulong tag = ReadVarint(buf, ref pos);
|
||||
int wire = (int)(tag & 0x07);
|
||||
// Write tag verbatim from original bytes (cheaper than re-encoding).
|
||||
output.Write(buf, tagStart, pos - tagStart);
|
||||
switch (wire)
|
||||
{
|
||||
case 0:
|
||||
{
|
||||
int start = pos;
|
||||
ReadVarint(buf, ref pos);
|
||||
output.Write(buf, start, pos - start);
|
||||
break;
|
||||
}
|
||||
case 1: output.Write(buf, pos, 8); pos += 8; break;
|
||||
case 2:
|
||||
{
|
||||
int lengthStart = pos;
|
||||
int len = (int)ReadVarint(buf, ref pos);
|
||||
output.Write(buf, lengthStart, pos - lengthStart);
|
||||
output.Write(buf, pos, len);
|
||||
pos += len;
|
||||
break;
|
||||
}
|
||||
case 5: output.Write(buf, pos, 4); pos += 4; break;
|
||||
default:
|
||||
throw new InvalidDataException($"unsupported wire type {wire}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user