mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
feat: lift the CircuitsV2 limits, Rec Royale night mode, RRO room blobs
CircuitsV2 limits are served as UGC.CircuitsV2.Limit.* gameconfig keys and are editable from admin Settings. Retail drops any send over 5 RPCs/frame or 1024 bytes/60 frames; both network budgets now default to 1,000,000,000. The creation limits are raised too, each against the hazard the client binary shows for it: - Chips 3000 -> 30000: the chip-culling manager preallocates four List<Chip>(Chips), so the 1M clamp stays. - Compute 50000 -> 500000: a per-frame budget with no allocation; the 10M cap keeps its counters from wrapping. - CloudValues 100 -> 1000, InkPerChip 15 -> 0: InkPerChip is an ink COST, multiplied by min(chips, CloudValues), so lowering it is the increase. The clamps tighten to 10000 / 30000 to keep that product from overflowing (the room ink cap is a hard-coded 30000). - UnculledChips / VisibleChips 500 -> 3000: render-cost counts only. Room save uploads may now be up to 100 MB (Kestrel's 30 MB default would 413 a big circuits room). Contract and evidence: docs/recroom-2023-circuitsv2-limits.md. Also includes the Rec Royale night mode client feature (docs/recroom-2023-scene-moods.md) and the admin blob enable/reset for baked RRO sub-rooms.
This commit is contained in:
@@ -19,7 +19,6 @@ public class RoomDataBlobService
|
||||
PersistedRoomVersion.LatestVersion;
|
||||
|
||||
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
|
||||
private readonly byte[] _rroEditableBlob = BuildRroEditableBlob();
|
||||
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
|
||||
|
||||
/// <summary>The blob served when a room_<id>_v1.dat misses S3
|
||||
@@ -37,7 +36,20 @@ public class RoomDataBlobService
|
||||
}
|
||||
|
||||
public byte[] GetDefaultBlob() => _defaultBlob;
|
||||
public byte[] GetRroEditableBlob() => _rroEditableBlob;
|
||||
public byte[] GetRroEditableBlob() => GetRroEditableBlob(string.Empty);
|
||||
|
||||
/// <summary>The blob served for a baked Rec Room Original whose blob name
|
||||
/// misses S3 (nothing saved yet). <paramref name="activityId"/> is the
|
||||
/// room's <c>LocationReplicationId</c> — a real save carries it as
|
||||
/// <c>activity_id</c> (Rec Center's saved blob: activity_id ==
|
||||
/// cbad71af-…, its location id).</summary>
|
||||
public byte[] GetRroEditableBlob(string activityId) =>
|
||||
BuildRroEditableBlob(_rroEditableRoleData, activityId, "Room");
|
||||
|
||||
/// <param name="roomName">Name of the room's default game role (Rec
|
||||
/// Center's is "Rec Center", Stunt Runner's "StuntRunner").</param>
|
||||
public byte[] GetRroEditableBlob(string activityId, string roomName) =>
|
||||
BuildRroEditableBlob(_rroEditableRoleData, activityId, roomName);
|
||||
|
||||
private static byte[] LoadDefaultBlob()
|
||||
{
|
||||
@@ -267,19 +279,367 @@ public class RoomDataBlobService
|
||||
return BuildRroEditableRoleData();
|
||||
}
|
||||
|
||||
private static byte[] BuildRroEditableBlob()
|
||||
/// <summary>Shape of the stub matters: the March-2023 client fetches this
|
||||
/// through <c>RecNet.Rooms.GetRoomData</c> and REJECTS a blob it deems
|
||||
/// unusable (seen as the bare "Failed to copy room" on clones, and as a
|
||||
/// baked room that silently loads without any room roles / Maker Pen).
|
||||
/// Two things were wrong with the old six-field stub, measured against
|
||||
/// Rec Center's real saved blob (room_100_v1.dat, which the same client
|
||||
/// loads with Maker Pen):
|
||||
///
|
||||
/// • <c>version</c> was stamped <c>PersistedRoomVersion.LatestVersion</c>
|
||||
/// (19) — past the client's ceiling of <see cref="Client2023MaxPersistedRoomVersion"/>
|
||||
/// (16). The S3-hit path clamps that; the miss path never did.
|
||||
/// • it lacked the skeleton every real save carries: <c>last_save_time</c>,
|
||||
/// <c>activity_id</c> (= the room's LocationReplicationId), an empty
|
||||
/// <c>connectable_graph_data</c>, <c>scene_settings_data</c>,
|
||||
/// <c>object_model_data</c> and the four <c>room_mood_*</c> blocks.
|
||||
///
|
||||
/// Our server proto only types the role/tag fields, so the rest is
|
||||
/// appended as raw wire-format fields (protobuf field order is free).
|
||||
/// The byte payloads below are Rec Center's own defaults, verbatim.</summary>
|
||||
private static byte[] BuildRroEditableBlob(RoomRoleCollectionData roleData, string activityId, string roomName)
|
||||
{
|
||||
var msg = new PersistedRoomData
|
||||
{
|
||||
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
|
||||
Version = LatestPersistenceVersion,
|
||||
Version = (PersistedRoomVersion)Client2023MaxPersistedRoomVersion,
|
||||
CreativeRolesEnabled = true,
|
||||
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
|
||||
GameRoleData = BuildDefaultGameRoleData(),
|
||||
RoomRoleData = BuildRroEditableRoleData(),
|
||||
RoomRoleData = roleData.Clone(),
|
||||
};
|
||||
|
||||
return msg.ToByteArray();
|
||||
using var output = new MemoryStream(4096);
|
||||
var typed = msg.ToByteArray();
|
||||
output.Write(typed, 0, typed.Length);
|
||||
|
||||
// 3: last_save_time (google.protobuf.Timestamp{seconds})
|
||||
var seconds = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
using (var ts = new MemoryStream(12))
|
||||
{
|
||||
WriteVarint(ts, 0x08);
|
||||
WriteVarint(ts, seconds);
|
||||
AppendLengthDelimited(output, 3, ts.ToArray());
|
||||
}
|
||||
// 4: activity_id
|
||||
if (!string.IsNullOrWhiteSpace(activityId))
|
||||
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
|
||||
|
||||
// 2: persistence_views — the room's own GAME-ROLE objects. Every real
|
||||
// room save carries three GameRoleNode chips (default role named after
|
||||
// the room, "In-Game", "Eliminated"; Rec Center and Stunt Runner both
|
||||
// do, with the same well-known role GUIDs), and the 2023 client reads
|
||||
// Maker Pen / save / spawn rights off the player's GameRole
|
||||
// (ICreatorRole.get_CreatorRoleCanUseMakerPen on
|
||||
// RecRoom.Systems.PlayerRoles.GameRole). A blob with no role objects
|
||||
// gives a co-owner nothing to hold, so MakerPen.CheckMakerPen-
|
||||
// PermissionAndUpdateMode fails even though the room-role collection
|
||||
// says yes. View ids are derived from the activity id so repeated
|
||||
// downloads describe the same objects.
|
||||
var seed = string.IsNullOrWhiteSpace(activityId) ? "rro" : activityId;
|
||||
AppendLengthDelimited(output, 2, BuildGameRoleView(seed, roomName, DefaultGameRoleGuid, nodeId: 7,
|
||||
transform: "0a0f0db7968041152c798b3f1da05e8dc02d0000803f32050d0000803f",
|
||||
roleId: 0, roleRank: 0, isDefaultRole: true));
|
||||
AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "In-Game", InGameGameRoleGuid, nodeId: 9,
|
||||
transform: "0a0f0d51308241152c798b3f1da05e8dc02d0000803f32050d0000803f",
|
||||
roleId: 2_097_152, roleRank: 1, isDefaultRole: false));
|
||||
AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "Eliminated", EliminatedGameRoleGuid, nodeId: 11,
|
||||
transform: "0a0f0deac98341152c798b3f1da05e8dc02d0000803f32050d0000803f",
|
||||
roleId: 4_194_304, roleRank: 1015, isDefaultRole: false));
|
||||
|
||||
// 2: the room's GAME-RULE chip, for baked originals whose rule is not
|
||||
// in the scene. Rec Royale's Frontier scene has no baked
|
||||
// *_GameRuleWrapperCircuit (the paintball scenes do); real Rec Room
|
||||
// delivered it in the blob. A saved RecRoyaleSquads chip (custom
|
||||
// Rec Royale room export) is just header + prefab id + palette tag +
|
||||
// node id + an EMPTY GameRuleWrapperData (field 41), so it can be
|
||||
// generated. Prefab ids come from the client's spawnable-tool table
|
||||
// in resources.assets (SpawnableTool_Runtime_<ENUM> → 16-byte id),
|
||||
// cross-checked against ids seen in real saves.
|
||||
var chip = GameRuleChipFor(roomName);
|
||||
if (chip is not null)
|
||||
AppendLengthDelimited(output, 2, BuildGameRuleChipView(seed, chip.Value.PrefabId, chip.Value.Tag, nodeId: 3));
|
||||
// 21: connectable_graph_data { root_node { slot_index=0-ish, field 7 = 1 } }
|
||||
AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 });
|
||||
// 24: scene_settings_data { enforce_host_only_areas=true, use_new_materials=true }
|
||||
AppendLengthDelimited(output, 24, new byte[] { 0x18, 0x01, 0x30, 0x01 });
|
||||
// 29: object_model_data (opaque bytes; empty model)
|
||||
AppendLengthDelimited(output, 29, new byte[] { 0x08, 0x02, 0x1A, 0x00 });
|
||||
// 32-35: room_fog_data / room_mood_sun_data / room_mood_background_objects / room_mood_skydome
|
||||
AppendLengthDelimited(output, 32, Convert.FromHexString("0a070d00000040100110122500004844"));
|
||||
AppendLengthDelimited(output, 33, Convert.FromHexString("0a070d000000401001580162140dffffff3e15ffffff3e1dffffffbe25ffffff3e"));
|
||||
AppendLengthDelimited(output, 34, Convert.FromHexString("0a070d000000401001121208ffffffffffffffffff01100d250000803f1a1208ffffffffffffffffff01100d250000803f221208ffffffffffffffffff01100d250000803f2a1208ffffffffffffffffff01100d250000803f"));
|
||||
AppendLengthDelimited(output, 35, Convert.FromHexString("0a070d0000004010011032182920252825300d3dae47613e45713d8a3e"));
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static void AppendLengthDelimited(Stream output, int fieldNumber, byte[] payload)
|
||||
{
|
||||
WriteVarint(output, ((ulong)fieldNumber << 3) | 2);
|
||||
WriteVarint(output, (ulong)payload.Length);
|
||||
output.Write(payload, 0, payload.Length);
|
||||
}
|
||||
|
||||
/// <summary>Game-rule wrapper chips for baked originals whose rule is not
|
||||
/// baked into the scene, keyed by (a substring of) the room name. Ids are
|
||||
/// the client's <c>SpawnableTool_Runtime_*</c> prefab ids (2023-03-21
|
||||
/// resources.assets); the Squads id also matches a real newer-client
|
||||
/// save. Tags are what the client generates: prefab name lower-cased,
|
||||
/// cut to 25 characters.</summary>
|
||||
private static (byte[] PrefabId, string Tag)? GameRuleChipFor(string roomName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roomName)) return null;
|
||||
var n = roomName.Replace("-", "").Replace("_", "").Replace(" ", "");
|
||||
if (n.Contains("RecRoyaleSquads", StringComparison.OrdinalIgnoreCase))
|
||||
return (Convert.FromHexString("857f750016ca77408ae9f72f9877748a"), "recroyalesquadsgameruleci");
|
||||
if (n.Contains("RecRoyaleSolos", StringComparison.OrdinalIgnoreCase))
|
||||
return (Convert.FromHexString("983db81a68927f419edb0daf1243e93e"), "recroyalesolosgamerulecir");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PersistenceViewData for a GameRuleCircuitWrapperTool chip,
|
||||
/// laid out like the saved RecRoyaleSquads chip: id, empty
|
||||
/// tool_entity_data, transform, spawnable_tool_data{prefab_id},
|
||||
/// creation_object_data{is_frozen,is_grabbable}, tagged_tool_data{tag},
|
||||
/// empty tool_cleanup_data, circuit_node_data{id}, empty
|
||||
/// game_rule_wrapper_data (field 41).</summary>
|
||||
private static byte[] BuildGameRuleChipView(string seed, byte[] prefabId, string tag, int nodeId)
|
||||
{
|
||||
using var view = new MemoryStream(256);
|
||||
AppendLengthDelimited(view, 1, System.Security.Cryptography.MD5.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes($"dorknet-rro-gamerule:{seed}:{tag}")));
|
||||
AppendLengthDelimited(view, 9, Array.Empty<byte>());
|
||||
// Same spot the saved Squads chip sat at (position/scale/rotation).
|
||||
AppendLengthDelimited(view, 10, Convert.FromHexString("0a0f0d804081c21520b58b431d941d50c42d0000803f32140d7cbd5d3f150034353a1d75deffbe250034353a"));
|
||||
using (var spawnable = new MemoryStream(20))
|
||||
{
|
||||
AppendLengthDelimited(spawnable, 1, prefabId);
|
||||
AppendLengthDelimited(view, 11, spawnable.ToArray());
|
||||
}
|
||||
AppendLengthDelimited(view, 14, new byte[] { 0x18, 0x01, 0x30, 0x01 });
|
||||
using (var tagged = new MemoryStream(40))
|
||||
{
|
||||
using var tagData = new MemoryStream(32);
|
||||
AppendLengthDelimited(tagData, 1, System.Text.Encoding.UTF8.GetBytes(tag));
|
||||
AppendLengthDelimited(tagged, 1, tagData.ToArray());
|
||||
AppendLengthDelimited(view, 15, tagged.ToArray());
|
||||
}
|
||||
AppendLengthDelimited(view, 22, Array.Empty<byte>());
|
||||
using (var node = new MemoryStream(4))
|
||||
{
|
||||
AppendVarint(node, 1, (ulong)nodeId);
|
||||
AppendLengthDelimited(view, 25, node.ToArray());
|
||||
}
|
||||
AppendLengthDelimited(view, 41, Array.Empty<byte>());
|
||||
return view.ToArray();
|
||||
}
|
||||
|
||||
public sealed record UploadedBlobSummary(int ViewsIn, int ViewsKept, int RoleViews, int GameRuleViews,
|
||||
int VersionIn, int VersionOut, bool CircuitsStripped, string ActivityId);
|
||||
|
||||
/// <summary>Turn a real <c>PersistedRoomData</c> save (any client
|
||||
/// version) into something the March-2023 client can load as room
|
||||
/// <paramref name="activityId"/>'s blob:
|
||||
/// • field 4 <c>activity_id</c> is rewritten to the target room's
|
||||
/// location id (real saves carry their own room's);
|
||||
/// • field 5 <c>output_log</c>, field 6 <c>sub_room_id</c> and every
|
||||
/// field the 2023 schema does not know (> 36) are dropped;
|
||||
/// • with <paramref name="basicsOnly"/>, only the persistence views that
|
||||
/// carry a game role (field 50) or a game rule (fields 40/41) are kept
|
||||
/// and the circuit payloads (18 circuit_data, 21 connectable graph,
|
||||
/// 28 circuit_v2_data) are dropped — a newer CircuitsV2 graph is
|
||||
/// rejected by the client's own CV2 version gate no matter what;
|
||||
/// • the two header versions are clamped to the 2023 maxima.
|
||||
/// Every other byte is copied verbatim.</summary>
|
||||
public static (byte[] Bytes, UploadedBlobSummary Summary) PrepareUploadedRoomBlob(
|
||||
byte[] input, string activityId, bool basicsOnly)
|
||||
{
|
||||
using var output = new MemoryStream(input.Length);
|
||||
var pos = 0;
|
||||
int viewsIn = 0, viewsKept = 0, roleViews = 0, ruleViews = 0, versionIn = 0;
|
||||
var wroteActivity = false;
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var fieldStart = pos;
|
||||
var tag = ReadVarint(input, ref pos);
|
||||
var fieldNumber = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 0x07);
|
||||
var payloadStart = pos;
|
||||
if (wireType == 0)
|
||||
{
|
||||
var value = ReadVarint(input, ref pos);
|
||||
if (fieldNumber == 30) versionIn = (int)value;
|
||||
if (fieldNumber is 5 or 6 || fieldNumber > 36) continue;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
SkipField(input, ref pos, wireType);
|
||||
if (wireType != 2)
|
||||
{
|
||||
if (fieldNumber > 36) continue;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lenPos = payloadStart;
|
||||
var len = checked((int)ReadVarint(input, ref lenPos));
|
||||
var payload = new ReadOnlySpan<byte>(input, lenPos, len);
|
||||
|
||||
switch (fieldNumber)
|
||||
{
|
||||
case 4:
|
||||
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
|
||||
wroteActivity = true;
|
||||
continue;
|
||||
case 5:
|
||||
case 18 when basicsOnly:
|
||||
case 21 when basicsOnly:
|
||||
case 28 when basicsOnly:
|
||||
continue;
|
||||
case 2:
|
||||
{
|
||||
viewsIn++;
|
||||
var (isRole, isRule) = ClassifyView(payload);
|
||||
if (isRole) roleViews++;
|
||||
if (isRule) ruleViews++;
|
||||
if (basicsOnly && !isRole && !isRule) continue;
|
||||
viewsKept++;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
default:
|
||||
if (fieldNumber > 36) continue;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!wroteActivity && !string.IsNullOrWhiteSpace(activityId))
|
||||
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
|
||||
|
||||
var (clamped, _) = ClampVersionsFor2023(output.ToArray());
|
||||
return (clamped, new UploadedBlobSummary(viewsIn, viewsKept, roleViews, ruleViews, versionIn,
|
||||
Math.Min(versionIn == 0 ? Client2023MaxPersistedRoomVersion : versionIn, Client2023MaxPersistedRoomVersion),
|
||||
basicsOnly, activityId));
|
||||
}
|
||||
|
||||
/// <summary>Does this PersistenceViewData carry a GameRoleNode (field 50)
|
||||
/// or a game rule (40 game_configuration_data / 41 game_rule_wrapper_data)?</summary>
|
||||
private static (bool IsRole, bool IsRule) ClassifyView(ReadOnlySpan<byte> view)
|
||||
{
|
||||
var bytes = view.ToArray();
|
||||
var pos = 0; bool role = false, rule = false;
|
||||
try
|
||||
{
|
||||
while (pos < bytes.Length)
|
||||
{
|
||||
var tag = ReadVarint(bytes, ref pos);
|
||||
var fieldNumber = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 0x07);
|
||||
if (fieldNumber == 50) role = true;
|
||||
if (fieldNumber is 40 or 41) rule = true;
|
||||
SkipField(bytes, ref pos, wireType);
|
||||
}
|
||||
}
|
||||
catch { /* malformed view — treat as neither */ }
|
||||
return (role, rule);
|
||||
}
|
||||
|
||||
private static void AppendVarint(Stream output, int fieldNumber, ulong value)
|
||||
{
|
||||
WriteVarint(output, ((ulong)fieldNumber << 3) | 0);
|
||||
WriteVarint(output, value);
|
||||
}
|
||||
|
||||
// Well-known game-role GUIDs, identical in every room save inspected
|
||||
// (Rec Center room_100_v1.dat, Stunt Runner room_125_v1.dat).
|
||||
private const string DefaultGameRoleGuid = "c48bac02-a743-4797-94c4-e2d6f0749004";
|
||||
private const string InGameGameRoleGuid = "2c721342-c7ed-49fe-b8f0-93f8d282a6df";
|
||||
private const string EliminatedGameRoleGuid = "0b9ec39e-2cc9-4825-8321-da11561f1c4f";
|
||||
|
||||
// SpawnableToolData.prefab_id of the GameRoleNode chip prefab (16 raw
|
||||
// bytes, same in both reference saves).
|
||||
private static readonly byte[] GameRoleNodePrefabId = Convert.FromHexString("c46e153588a47147835d6cc2f69bfd6c");
|
||||
|
||||
/// <summary>One <c>PersistenceViewData</c> carrying a GameRoleNode chip,
|
||||
/// laid out exactly as the client's own writer emits it: id, empty
|
||||
/// tool_entity_data, transform, spawnable_tool_data{prefab_id},
|
||||
/// creation_object_data{is_frozen, is_grabbable}, empty tool_cleanup_data,
|
||||
/// circuit_node_data{id}, game_role_node_data{PlayerGameRoleData}. The
|
||||
/// PlayerGameRoleData mirrors the reference saves field-for-field,
|
||||
/// including the empty Overridable* submessages the writer always emits
|
||||
/// (the reader expects them present).</summary>
|
||||
private static byte[] BuildGameRoleView(string seed, string roleName, string roleGuid, int nodeId,
|
||||
string transform, int roleId, int roleRank, bool isDefaultRole)
|
||||
{
|
||||
using var view = new MemoryStream(512);
|
||||
// 1: id — stable per (room, role)
|
||||
AppendLengthDelimited(view, 1, System.Security.Cryptography.MD5.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes($"dorknet-rro-role:{seed}:{roleGuid}")));
|
||||
AppendLengthDelimited(view, 9, Array.Empty<byte>()); // tool_entity_data
|
||||
AppendLengthDelimited(view, 10, Convert.FromHexString(transform)); // transform
|
||||
using (var spawnable = new MemoryStream(20)) // spawnable_tool_data
|
||||
{
|
||||
AppendLengthDelimited(spawnable, 1, GameRoleNodePrefabId);
|
||||
AppendLengthDelimited(view, 11, spawnable.ToArray());
|
||||
}
|
||||
AppendLengthDelimited(view, 14, new byte[] { 0x18, 0x01, 0x30, 0x01 }); // creation_object_data
|
||||
AppendLengthDelimited(view, 22, Array.Empty<byte>()); // tool_cleanup_data
|
||||
using (var node = new MemoryStream(4)) // circuit_node_data
|
||||
{
|
||||
AppendVarint(node, 1, (ulong)nodeId);
|
||||
AppendLengthDelimited(view, 25, node.ToArray());
|
||||
}
|
||||
using (var roleNode = new MemoryStream(512)) // game_role_node_data
|
||||
{
|
||||
AppendLengthDelimited(roleNode, 1, BuildPlayerGameRole(roleName, roleGuid, roleId, roleRank, isDefaultRole));
|
||||
AppendLengthDelimited(view, 50, roleNode.ToArray());
|
||||
}
|
||||
return view.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildPlayerGameRole(string roleName, string roleGuid, int roleId, int roleRank, bool isDefaultRole)
|
||||
{
|
||||
// Field order and the set of always-present empty submessages copied
|
||||
// from Rec Center's save (role_version 33 = the 2023 writer).
|
||||
int[] order =
|
||||
{
|
||||
3, 5, 8, 11, 12, 13, 14, 15, 17, 18, 19, 21, 22, 23, 24, 26, 27, 30, 31, 32, 33, 34, 36, 37, 38, 39,
|
||||
40, 41, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 62, 63, 64, 65, 66, 67, 70, 71, 72, 73, 74, 75, 76,
|
||||
77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100,
|
||||
};
|
||||
static byte[] OverrideBool(bool value) => value ? new byte[] { 0x08, 0x01, 0x10, 0x01 } : new byte[] { 0x08, 0x01 };
|
||||
|
||||
using var role = new MemoryStream(320);
|
||||
if (!isDefaultRole)
|
||||
{
|
||||
AppendVarint(role, 1, (ulong)roleId); // role_id (AG bitmask)
|
||||
AppendVarint(role, 2, (ulong)roleRank); // role_rank
|
||||
}
|
||||
foreach (var f in order)
|
||||
{
|
||||
switch (f)
|
||||
{
|
||||
case 3: AppendVarint(role, 3, 1); break; // is_role_active
|
||||
case 5: AppendLengthDelimited(role, 5, System.Text.Encoding.UTF8.GetBytes(roleName)); break;
|
||||
case 17 when roleName == "Eliminated":
|
||||
AppendLengthDelimited(role, 17, new byte[] { 0x08, 0x01, 0x10, 0x01 }); break; // item_pickup_restrictions
|
||||
case 26 when isDefaultRole:
|
||||
AppendLengthDelimited(role, 26, new byte[] { 0x08, 0x01, 0x10, 0x14 }); break; // voice_rolloff_max_distance=20
|
||||
case 27: AppendVarint(role, 27, 33); break; // role_version
|
||||
case 31 when roleName == "Eliminated":
|
||||
AppendLengthDelimited(role, 31, OverrideBool(false)); break; // can_move
|
||||
case 49: AppendLengthDelimited(role, 49, System.Text.Encoding.UTF8.GetBytes(roleGuid)); break;
|
||||
// DorkNet policy for editable RROs: the default role may use
|
||||
// the Maker Pen regardless of room role (matches the
|
||||
// permissive room-role overlay the CDN applies).
|
||||
case 94 when isDefaultRole:
|
||||
AppendLengthDelimited(role, 94, OverrideBool(true)); break; // can_use_maker_pen
|
||||
default: AppendLengthDelimited(role, f, Array.Empty<byte>()); break;
|
||||
}
|
||||
}
|
||||
return role.ToArray();
|
||||
}
|
||||
|
||||
private static RoomRoleCollectionData BuildRroEditableRoleData()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
@@ -175,7 +176,25 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
var row = await GetAsync();
|
||||
return ToGameConfigurations(
|
||||
ToRecCenterDoors(row),
|
||||
ToDiscoveredGameConfigs(row));
|
||||
ToDiscoveredGameConfigs(row),
|
||||
ToCircuitsV2Limits(row));
|
||||
}
|
||||
|
||||
public async Task<CircuitsV2LimitSettings> GetCircuitsV2LimitsAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
return ToCircuitsV2Limits(row);
|
||||
}
|
||||
|
||||
public async Task<CircuitsV2LimitSettings> SetCircuitsV2LimitsAsync(
|
||||
CircuitsV2LimitSettings settings)
|
||||
{
|
||||
var normalized = NormalizeCircuitsV2Limits(settings);
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.CircuitsV2LimitsJson = JsonSerializer.Serialize(normalized, JsonOptions);
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return normalized with { UpdatedAt = existing.UpdatedAt };
|
||||
}
|
||||
|
||||
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
|
||||
@@ -663,9 +682,86 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
SplitTestSegmentProbabilities: "{}",
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Retail shipped one network entry — 5 RPCs per frame, 1024 bytes per
|
||||
/// 60 frames — which is what makes busy CircuitsV2 rooms drop chip
|
||||
/// sends. DorkNet ships the budgets effectively unlimited instead, and
|
||||
/// raises the room-size limits to values the client survives with
|
||||
/// headroom (each is justified in docs/recroom-2023-circuitsv2-limits.md):
|
||||
/// <list type="bullet">
|
||||
/// <item>Compute 50000 → 500000: the per-frame CPU budget shared by
|
||||
/// every circuit in the room. It is the only thing that stops a runaway
|
||||
/// loop, so it stays finite.</item>
|
||||
/// <item>Chips 3000 → 30000: the room's chip cap ("Too many
|
||||
/// chips.").</item>
|
||||
/// <item>CloudValues 100 → 1000: the room's cloud-variable cap.</item>
|
||||
/// <item>UnculledChips / VisibleChips 500 → 3000: chips past these
|
||||
/// counts get culled, so every chip of a retail-sized room stays
|
||||
/// visible.</item>
|
||||
/// <item>InkPerChip 15 → 0: this is an ink COST, charged against the
|
||||
/// client's hard-coded 30000-ink room cap, so lowering it is the
|
||||
/// increase. 0 means chips stop eating room ink.</item>
|
||||
/// </list>
|
||||
/// CloudValuesAutosaveSeconds stays at retail's 300: it is a save
|
||||
/// cadence, not a capacity limit. Admins can dial any of it back from
|
||||
/// the SPA.
|
||||
/// </summary>
|
||||
public static CircuitsV2LimitSettings DefaultCircuitsV2Limits() =>
|
||||
new(
|
||||
Network:
|
||||
[
|
||||
new(
|
||||
NumPlayersForActivation: 0,
|
||||
Bytes: UnlimitedBudget,
|
||||
BytesTimeWindowFrame60s: 60,
|
||||
Rpcs: UnlimitedBudget,
|
||||
RpcTimeWindowFrame60s: 1),
|
||||
],
|
||||
Compute: 500_000,
|
||||
Chips: 30_000,
|
||||
CloudValues: 1_000,
|
||||
CloudValuesAutosaveSeconds: 300f,
|
||||
UnculledChips: 3_000,
|
||||
VisibleChips: 3_000,
|
||||
InkPerChip: 0,
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
/// <summary>The budget value that stands in for "unlimited". The client
|
||||
/// only ever compares a running counter against it, so any large int
|
||||
/// works; this leaves 2x headroom under int.MaxValue.</summary>
|
||||
private const int UnlimitedBudget = 1_000_000_000;
|
||||
|
||||
/// <summary>Time windows size a <c>new int[N]</c> ring buffer — one
|
||||
/// bucket per frame — inside the client's rate limiter, so a huge
|
||||
/// window is an allocation, not a bigger budget. 3600 frames (a minute
|
||||
/// at 60fps) is 14KB and far past anything useful.</summary>
|
||||
private const int MaxTimeWindowFrames = 3600;
|
||||
|
||||
/// <summary>Play-menu carousel order. <c>PlayMenuCarouselModel.TryGetDisplayPriorityMap</c>
|
||||
/// looks this exact key up (literal at Assembly-CSharp/RRUI/Data/PlayMenuCarouselModel.txt:547)
|
||||
/// and, when it is absent, logs "Could not find GameConfig for PlayMenuCarouselOrdering_1"
|
||||
/// (:758) and leaves the carousels in prefab order. The value is split on ';' then '='
|
||||
/// into id → Int32 priority (:603, :632, :655); <c>PlayMenuCarouselController.DisplayOrderPriorityImpl</c>
|
||||
/// then reorders the carousel children with SetSiblingIndex.
|
||||
///
|
||||
/// The ids are the carousel ids serialized on that component in the 2023-03-21 build's
|
||||
/// Addressables bundle (3900770abc33861326d1865e454c0901.bundle, MonoBehaviour
|
||||
/// PlayMenuCarouselController/DisplayOrderPriorityImpl): continueplaying, friendsplaying,
|
||||
/// myclubhouses, myevents, mysubscriptions, recommended, hotrooms, topcreators, featured,
|
||||
/// myfavorites, recroomoriginals, new, limitedtimeevent. Priorities follow the live
|
||||
/// service's 2023 "PlayMenuCarouselOrdering_3" segment (higher first), with the
|
||||
/// time-limited event carousel put on top.</summary>
|
||||
public const string PlayMenuCarouselOrderingKey = "PlayMenuCarouselOrdering_1";
|
||||
|
||||
public const string PlayMenuCarouselOrdering =
|
||||
"limitedtimeevent=15;hotrooms=14;new=13;recroomoriginals=12;continueplaying=11;" +
|
||||
"myfavorites=10;friendsplaying=9;recommended=8;mysubscriptions=7;featured=5;" +
|
||||
"myclubhouses=4;myevents=3;topcreators=2";
|
||||
|
||||
public static IReadOnlyList<GameConfigurationSetting> ToGameConfigurations(
|
||||
RecCenterDoorSettings doors,
|
||||
DiscoveredGameConfigSettings discovered)
|
||||
DiscoveredGameConfigSettings discovered,
|
||||
CircuitsV2LimitSettings circuits)
|
||||
{
|
||||
var rows = new List<GameConfigurationSetting>();
|
||||
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
|
||||
@@ -690,8 +786,26 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides),
|
||||
new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides),
|
||||
new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities),
|
||||
new(PlayMenuCarouselOrderingKey, PlayMenuCarouselOrdering),
|
||||
]);
|
||||
|
||||
var limits = NormalizeCircuitsV2Limits(circuits);
|
||||
rows.AddRange([
|
||||
new("UGC.CircuitsV2.Limit.Network", JsonSerializer.Serialize(limits.Network, JsonOptions)),
|
||||
new("UGC.CircuitsV2.Limit.Compute", Int(limits.Compute)),
|
||||
new("UGC.CircuitsV2.Limit.Chips", Int(limits.Chips)),
|
||||
new("UGC.CircuitsV2.Limit.CloudValues", Int(limits.CloudValues)),
|
||||
new("UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds",
|
||||
limits.CloudValuesAutosaveSeconds.ToString("0.###", CultureInfo.InvariantCulture)),
|
||||
new("UGC.CircuitsV2.Limit.UnculledChips", Int(limits.UnculledChips)),
|
||||
new("UGC.CircuitsV2.Limit.VisibleChips", Int(limits.VisibleChips)),
|
||||
new("UGC.CircuitsV2.Limit.InkPerChip", Int(limits.InkPerChip)),
|
||||
]);
|
||||
return rows;
|
||||
|
||||
// The client parses every scalar with int/float.TryParse under
|
||||
// InvariantCulture, so no thousands separators.
|
||||
static string Int(int value) => value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static PlayMenuTagSettings NormalizePlayMenuTags(
|
||||
@@ -800,6 +914,104 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities),
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamps the limits to what the client can actually survive. Four of
|
||||
/// these are crash guards rather than taste:
|
||||
/// <list type="bullet">
|
||||
/// <item>Rpcs of 0 divides by zero in the CircuitsV2 debug subscreen,
|
||||
/// which renders <c>RpcRecentMax / Rpcs</c>.</item>
|
||||
/// <item>A time window sizes a per-frame ring buffer, so an
|
||||
/// unbounded value is an out-of-memory kill at circuit init.</item>
|
||||
/// <item>Chips sizes four list preallocations in the chip-culling
|
||||
/// manager, and InkPerChip × CloudValues is an unchecked int multiply,
|
||||
/// so both are capped.</item>
|
||||
/// <item>The client rejects the whole network array — falling back to
|
||||
/// the retail 5-RPC budget — unless some entry has
|
||||
/// NumPlayersForActivation 0, so the lowest entry is forced to 0.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static CircuitsV2LimitSettings NormalizeCircuitsV2Limits(
|
||||
CircuitsV2LimitSettings settings)
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
var network = (settings.Network ?? [])
|
||||
.Where(entry => entry is not null)
|
||||
.Select(entry => new CircuitsV2NetworkLimit(
|
||||
NumPlayersForActivation: Math.Clamp(entry.NumPlayersForActivation, 0, 1000),
|
||||
Bytes: Math.Clamp(entry.Bytes, 1, UnlimitedBudget),
|
||||
BytesTimeWindowFrame60s: Math.Clamp(entry.BytesTimeWindowFrame60s, 1, MaxTimeWindowFrames),
|
||||
Rpcs: Math.Clamp(entry.Rpcs, 1, UnlimitedBudget),
|
||||
RpcTimeWindowFrame60s: Math.Clamp(entry.RpcTimeWindowFrame60s, 1, MaxTimeWindowFrames)))
|
||||
.OrderBy(entry => entry.NumPlayersForActivation)
|
||||
.Where(entry => seen.Add(entry.NumPlayersForActivation))
|
||||
.Take(16)
|
||||
.ToList();
|
||||
|
||||
if (network.Count == 0)
|
||||
network = DefaultCircuitsV2Limits().Network;
|
||||
else if (network[0].NumPlayersForActivation != 0)
|
||||
network[0] = network[0] with { NumPlayersForActivation = 0 };
|
||||
|
||||
return new CircuitsV2LimitSettings(
|
||||
Network: network,
|
||||
// Compute is the per-frame CPU budget: CircuitsV2Manager's frame
|
||||
// tick (CircuitsV2Manager.txt OCGDEIJJBAG ISIL 240, :21241)
|
||||
// builds a fresh BENFNKBKHEA(Compute) every frame, which sums
|
||||
// four int counters and compares them <= the budget
|
||||
// (Circuits.V2/BENFNKBKHEA.txt). Nothing is allocated from it,
|
||||
// but it is the only runaway-loop guard: a budget near
|
||||
// int.MaxValue lets the summed counters wrap negative and never
|
||||
// trip. 10M keeps 200x headroom under int.MaxValue.
|
||||
Compute: Math.Clamp(settings.Compute, 1, 10_000_000),
|
||||
// The chip-culling manager's ctor preallocates four
|
||||
// List<Chip>(Chips) (PAFDMBHNMBP.txt .ctor ISIL 053-108), so
|
||||
// 0 or a negative is an ArgumentOutOfRange and 1M is 32MB of
|
||||
// pointer arrays — the ceiling.
|
||||
Chips: Math.Clamp(settings.Chips, 1, 1_000_000),
|
||||
// CalculateActiveResourceCost computes
|
||||
// inkCost += InkPerChip * Math.Min(numChips, CloudValues) in
|
||||
// plain int (CreationManager.txt ISIL 389-415, :6136-6162).
|
||||
// With InkPerChip capped at 30000 below, 10000 keeps that
|
||||
// product at 3e8, well under int.MaxValue.
|
||||
CloudValues: Math.Clamp(settings.CloudValues, 0, 10_000),
|
||||
// A non-finite value would serialize as "NaN"/"Infinity", which
|
||||
// the client's float.TryParse happily accepts.
|
||||
CloudValuesAutosaveSeconds: float.IsFinite(settings.CloudValuesAutosaveSeconds)
|
||||
? Math.Clamp(settings.CloudValuesAutosaveSeconds, 1f, 3600f)
|
||||
: 300f,
|
||||
UnculledChips: Math.Clamp(settings.UnculledChips, 1, 1_000_000),
|
||||
VisibleChips: Math.Clamp(settings.VisibleChips, 1, 1_000_000),
|
||||
// InkPerChip is an ink cost against the room's hard-coded
|
||||
// 30000-ink cap (CostInfo(0x7530, 64, 101, Chips, CloudValues)
|
||||
// in CreationManager.OnCircuitsV2LimitsRefreshed, ISIL 060,
|
||||
// :3374). Past 30000 a single chip is unaffordable, and the
|
||||
// cap also bounds the int multiply noted on CloudValues. No
|
||||
// client code divides by it, so 0 is safe.
|
||||
InkPerChip: Math.Clamp(settings.InkPerChip, 0, 30_000),
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static CircuitsV2LimitSettings ToCircuitsV2Limits(ServerSettingsEntity row)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.CircuitsV2LimitsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<CircuitsV2LimitSettings>(
|
||||
row.CircuitsV2LimitsJson,
|
||||
JsonOptions);
|
||||
if (parsed is not null)
|
||||
return NormalizeCircuitsV2Limits(parsed) with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bad settings should not break gameconfig bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultCircuitsV2Limits() with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WeeklyChallengeSettings(
|
||||
@@ -848,6 +1060,38 @@ public sealed record GameConfigurationSetting(
|
||||
DateTime? StartTime = null,
|
||||
DateTime? EndTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the <c>UGC.CircuitsV2.Limit.Network</c> array. The client
|
||||
/// sorts entries ascending by <see cref="NumPlayersForActivation"/> and
|
||||
/// activates the highest one whose threshold is at or below the room's max
|
||||
/// player capacity, so a single entry with a threshold of 0 applies
|
||||
/// everywhere. Field names are the literal JSON keys the client's
|
||||
/// Newtonsoft reader binds to <c>LimitConfig.NetworkConfig</c>.
|
||||
/// </summary>
|
||||
public sealed record CircuitsV2NetworkLimit(
|
||||
int NumPlayersForActivation,
|
||||
int Bytes,
|
||||
int BytesTimeWindowFrame60s,
|
||||
int Rpcs,
|
||||
int RpcTimeWindowFrame60s);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>UGC.CircuitsV2.Limit.*</c> gameconfig family, read by
|
||||
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>. Every field maps to one
|
||||
/// key; the client falls back to its baked-in defaults for any key the
|
||||
/// server omits or sends unparseably.
|
||||
/// </summary>
|
||||
public sealed record CircuitsV2LimitSettings(
|
||||
List<CircuitsV2NetworkLimit> Network,
|
||||
int Compute,
|
||||
int Chips,
|
||||
int CloudValues,
|
||||
float CloudValuesAutosaveSeconds,
|
||||
int UnculledChips,
|
||||
int VisibleChips,
|
||||
int InkPerChip,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record DiscoveredGameConfigSettings(
|
||||
int FriendsPostGamePromptUnderFriendCount,
|
||||
int FriendsSuggestFriendCodeOnFriendsScreenCount,
|
||||
|
||||
Reference in New Issue
Block a user