Files
DorkNet/DorkNet.Server/Services/ServerSettingsService.cs
T
Alexa 36e64e99ba 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.
2026-10-15 14:07:54 +03:00

1111 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text.Json;
namespace DorkNet.Server.Services;
/// <summary>
/// Single-row settings store. Same Postgres-backed pattern as
/// <see cref="CommunityBoardService"/>: every read is one SELECT,
/// every write is one UPDATE/INSERT, and the row is visible to every
/// replica immediately so admin toggles propagate without a cache
/// invalidation dance.
///
/// Scoped (not singleton) because it holds a DbContext reference. The
/// toggles it backs (signups, etc.) are checked on rare paths —
/// account creation hits this once per signup attempt, which is
/// dwarfed by everything else the request does.
/// </summary>
public class ServerSettingsService(DorkNetDbContext db)
{
private const int RowId = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task<ServerSettingsEntity> GetAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row ?? new ServerSettingsEntity { Id = RowId };
}
public async Task<bool> AreSignupsDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.SignupsDisabled ?? false;
}
public async Task<ServerSettingsEntity> SetSignupsDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.SignupsDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// <summary>When true, every account is treated as a friend of every
/// other account (synthesized at read time — no relationship rows are
/// written). See <see cref="ServerSettingsEntity.GlobalFriendsEnabled"/>
/// and <see cref="RelationshipQueries"/>.</summary>
public async Task<bool> IsGlobalFriendsEnabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.GlobalFriendsEnabled ?? false;
}
public async Task<ServerSettingsEntity> SetGlobalFriendsEnabledAsync(bool enabled)
{
var existing = await GetTrackedRowAsync();
existing.GlobalFriendsEnabled = enabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// <summary>When true, every account is treated as owning every avatar
/// item in the master catalog (synthesized at read time — no inventory
/// rows are written). See <see cref="ServerSettingsEntity.AllAvatarItemsOwned"/>
/// and <see cref="Controllers.API.Avatar.V4.AvatarItemsController"/>.</summary>
public async Task<bool> IsAllAvatarItemsOwnedEnabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.AllAvatarItemsOwned ?? false;
}
public async Task<ServerSettingsEntity> SetAllAvatarItemsOwnedEnabledAsync(bool enabled)
{
var existing = await GetTrackedRowAsync();
existing.AllAvatarItemsOwned = enabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
public async Task<WeeklyChallengeSettings> GetWeeklyChallengesAsync()
{
var row = await GetAsync();
return ToWeeklySettings(row);
}
public async Task<PlayMenuTagSettings> GetPlayMenuTagsAsync()
{
var row = await GetAsync();
return ToPlayMenuTags(row);
}
public async Task<RecCenterDoorSettings> GetRecCenterDoorsAsync()
{
var row = await GetAsync();
return ToRecCenterDoors(row);
}
/// <summary>Built-in default base-room picker (used when an admin hasn't
/// customised the list). RRO originals minus #quest rooms, MakerRoom
/// first as the canonical blank canvas.</summary>
public static readonly IReadOnlyList<string> DefaultBaseRoomNames = new[]
{
"MakerRoom", "RecCenter", "3DCharades", "Dodgeball",
"DiscGolfLake", "DiscGolfPropulsion", "Paddleball",
"Paintball", "Soccer", "LaserTag", "RecRoyaleSquads",
"RecRoyaleSolos", "Park", "BowlingAlley", "RecRally",
};
public async Task<IReadOnlyList<string>> GetBaseRoomNamesAsync()
{
var row = await GetAsync();
return ToBaseRoomNames(row);
}
public async Task<IReadOnlyList<string>> SetBaseRoomNamesAsync(IReadOnlyList<string> names)
{
var normalized = NormalizeBaseRoomNames(names);
var existing = await GetTrackedRowAsync();
existing.BaseRoomNamesJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized;
}
private static IReadOnlyList<string> ToBaseRoomNames(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.BaseRoomNamesJson))
{
try
{
var parsed = JsonSerializer.Deserialize<List<string>>(row.BaseRoomNamesJson, JsonOptions);
if (parsed is { Count: > 0 })
return NormalizeBaseRoomNames(parsed);
}
catch
{
// Bad settings must not break room creation; admins can
// overwrite from the SPA.
}
}
return DefaultBaseRoomNames.ToList();
}
private static IReadOnlyList<string> NormalizeBaseRoomNames(IReadOnlyList<string> names)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>();
foreach (var n in names)
{
var t = (n ?? string.Empty).Trim();
if (t.Length > 0 && seen.Add(t)) result.Add(t);
}
return result.Count > 0 ? result : DefaultBaseRoomNames.ToList();
}
public async Task<DiscoveredGameConfigSettings> GetDiscoveredGameConfigsAsync()
{
var row = await GetAsync();
return ToDiscoveredGameConfigs(row);
}
public async Task<IReadOnlyList<GameConfigurationSetting>> GetGameConfigurationsAsync()
{
var row = await GetAsync();
return ToGameConfigurations(
ToRecCenterDoors(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(
IReadOnlyList<string> pinned,
IReadOnlyList<string> popular,
IReadOnlyList<string>? trending = null)
{
var normalized = NormalizePlayMenuTags(pinned, popular, trending);
var existing = await GetTrackedRowAsync();
existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<RecCenterDoorSettings> SetRecCenterDoorsAsync(
IReadOnlyList<RecCenterDoorConfig> doors)
{
var normalized = NormalizeRecCenterDoors(doors);
var existing = await GetTrackedRowAsync();
existing.RecCenterDoorsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<DiscoveredGameConfigSettings> SetDiscoveredGameConfigsAsync(
DiscoveredGameConfigSettings settings)
{
var normalized = NormalizeDiscoveredGameConfigs(settings);
var existing = await GetTrackedRowAsync();
existing.DiscoveredGameConfigsJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return normalized with { UpdatedAt = existing.UpdatedAt };
}
public async Task<WeeklyChallengeSettings> SetWeeklyChallengesAsync(
bool completedRequired,
IReadOnlyList<WeeklyChallengeTemplate> challenges,
WeeklyChallengeReward? reward)
{
var normalized = NormalizeWeeklyChallenges(challenges);
var normalizedReward = NormalizeWeeklyReward(reward);
var existing = await GetTrackedRowAsync();
existing.WeeklyChallengesCompletedRequired = completedRequired;
existing.WeeklyChallengesJson = JsonSerializer.Serialize(normalized, JsonOptions);
existing.WeeklyChallengeRewardJson = JsonSerializer.Serialize(normalizedReward, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return ToWeeklySettings(existing);
}
/// <summary>Whether the server-side profanity filter behind
/// <c>api/sanitize/*</c> is bypassed. Off by default (filter active).</summary>
public async Task<bool> IsProfanityFilterDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.ProfanityFilterDisabled ?? false;
}
public async Task<ServerSettingsEntity> SetProfanityFilterDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.ProfanityFilterDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// <summary>Whether the CDN's PersistedRoomData version clamp for the
/// March-2023 client is bypassed. Off by default (clamp active) — see
/// <see cref="ServerSettingsEntity.RoomBlobVersionClampDisabled"/> and
/// <see cref="RoomDataBlobService.ClampVersionsFor2023"/>.</summary>
public async Task<bool> IsRoomBlobVersionClampDisabledAsync()
{
var row = await db.ServerSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == RowId);
return row?.RoomBlobVersionClampDisabled ?? false;
}
public async Task<ServerSettingsEntity> SetRoomBlobVersionClampDisabledAsync(bool disabled)
{
var existing = await GetTrackedRowAsync();
existing.RoomBlobVersionClampDisabled = disabled;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return existing;
}
/// <summary>Live charades slot→list bindings. Empty/unparseable JSON
/// resolves to <see cref="CharadesSlotBindings.Empty"/> (every slot
/// falls back to its built-in seed list).</summary>
public async Task<CharadesSlotBindings> GetCharadesSlotBindingsAsync()
{
var row = await GetAsync();
if (string.IsNullOrWhiteSpace(row.CharadesSlotBindingsJson))
return CharadesSlotBindings.Empty;
try
{
return JsonSerializer.Deserialize<CharadesSlotBindings>(
row.CharadesSlotBindingsJson, JsonOptions) ?? CharadesSlotBindings.Empty;
}
catch
{
return CharadesSlotBindings.Empty;
}
}
public async Task<CharadesSlotBindings> SetCharadesSlotBindingsAsync(CharadesSlotBindings bindings)
{
var existing = await GetTrackedRowAsync();
existing.CharadesSlotBindingsJson = JsonSerializer.Serialize(bindings, JsonOptions);
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return bindings;
}
private async Task<ServerSettingsEntity> GetTrackedRowAsync()
{
var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId);
if (existing is not null) return existing;
existing = new ServerSettingsEntity { Id = RowId, UpdatedAt = DateTime.UtcNow };
db.ServerSettings.Add(existing);
return existing;
}
private static WeeklyChallengeSettings ToWeeklySettings(ServerSettingsEntity row)
{
var challenges = DefaultWeeklyChallenges();
var reward = DefaultWeeklyReward();
if (!string.IsNullOrWhiteSpace(row.WeeklyChallengesJson))
{
try
{
var parsed = JsonSerializer.Deserialize<List<WeeklyChallengeTemplate>>(
row.WeeklyChallengesJson,
JsonOptions);
if (parsed?.Count > 0)
challenges = NormalizeWeeklyChallenges(parsed);
}
catch
{
challenges = DefaultWeeklyChallenges();
}
}
if (!string.IsNullOrWhiteSpace(row.WeeklyChallengeRewardJson))
{
try
{
var parsed = JsonSerializer.Deserialize<WeeklyChallengeReward>(
row.WeeklyChallengeRewardJson,
JsonOptions);
reward = NormalizeWeeklyReward(parsed);
}
catch
{
reward = DefaultWeeklyReward();
}
}
return new WeeklyChallengeSettings(
row.WeeklyChallengesCompletedRequired,
challenges,
reward,
row.UpdatedAt);
}
private static PlayMenuTagSettings ToPlayMenuTags(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.PlayMenuTagsJson))
{
try
{
var parsed = JsonSerializer.Deserialize<PlayMenuTagSettings>(
row.PlayMenuTagsJson,
JsonOptions);
if (parsed is not null)
return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags, parsed.TrendingTags)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break the Play menu; admins can
// overwrite them from the SPA.
}
}
return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt };
}
private static RecCenterDoorSettings ToRecCenterDoors(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.RecCenterDoorsJson))
{
try
{
var parsed = JsonSerializer.Deserialize<RecCenterDoorSettings>(
row.RecCenterDoorsJson,
JsonOptions);
if (parsed is not null)
return NormalizeRecCenterDoors(parsed.Doors)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break Rec Center startup; admins
// can overwrite them from the SPA.
}
}
return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt };
}
private static DiscoveredGameConfigSettings ToDiscoveredGameConfigs(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.DiscoveredGameConfigsJson))
{
try
{
var parsed = JsonSerializer.Deserialize<DiscoveredGameConfigSettings>(
row.DiscoveredGameConfigsJson,
JsonOptions);
if (parsed is not null)
return NormalizeDiscoveredGameConfigs(parsed)
with { UpdatedAt = row.UpdatedAt };
}
catch
{
// Bad settings should not break gameconfig bootstrap.
}
}
return DefaultDiscoveredGameConfigs() with { UpdatedAt = row.UpdatedAt };
}
private static List<WeeklyChallengeTemplate> NormalizeWeeklyChallenges(
IReadOnlyList<WeeklyChallengeTemplate> challenges)
{
var normalized = challenges
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.Take(10)
.Select((c, i) => new WeeklyChallengeTemplate(
i,
c.Name.Trim(),
NormalizeWeeklyChallengeConfig(c.Config),
c.Description?.Trim() ?? string.Empty,
c.Tooltip?.Trim() ?? string.Empty))
.ToList();
return normalized.Count > 0 ? normalized : DefaultWeeklyChallenges();
}
private static string NormalizeWeeklyChallengeConfig(string? config)
{
if (TryGetLegacyWeeklyGoal(config, out var legacyGoal))
return CountedAnyChallengeConfig(legacyGoal);
if (IsClientWeeklyChallengeConfig(config))
return config!.Trim();
return CountedAnyChallengeConfig(1);
}
private static bool TryGetLegacyWeeklyGoal(string? config, out int goal)
{
goal = 1;
if (string.IsNullOrWhiteSpace(config)) return false;
try
{
using var doc = JsonDocument.Parse(config);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
if (!doc.RootElement.TryGetProperty("Type", out _))
return false;
if (doc.RootElement.TryGetProperty("Goal", out var goalElement)
&& goalElement.TryGetInt32(out var parsedGoal))
{
goal = Math.Clamp(parsedGoal, 1, 1000);
}
return true;
}
catch
{
return false;
}
}
private static bool IsClientWeeklyChallengeConfig(string? config)
{
if (string.IsNullOrWhiteSpace(config)) return false;
try
{
using var doc = JsonDocument.Parse(config);
return IsClientWeeklyChallengeConfigElement(doc.RootElement);
}
catch
{
return false;
}
}
private static bool IsClientWeeklyChallengeConfigElement(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
return false;
if (!element.TryGetProperty("ct", out var typeElement) || !typeElement.TryGetInt32(out var type))
return false;
if (type == 0 || IsKnownClientChallengeType(type))
return true;
if (type != 1)
return false;
if (!element.TryGetProperty("t", out var targetElement)
|| !targetElement.TryGetInt32(out var target)
|| target <= 0)
{
return false;
}
if (!element.TryGetProperty("ctc", out var children)
|| children.ValueKind != JsonValueKind.Array
|| children.GetArrayLength() == 0)
{
return false;
}
return children.EnumerateArray().All(IsClientWeeklyChallengeConfigElement);
}
private static bool IsKnownClientChallengeType(int type) => type is
2 // TimedBufferChallenge
or 3 // DynamicFloatArithmeticChallenge
or 4 // DynamicIntArithmeticChallenge
or 6 // RequiredEventTypeChallenge
or 7 // RequiredRoomSceneLocationChallenge
or 8 // RequiredEnemyTypeChallenge
or 9 // BoolVarEqualsChallenge
or 11 // DiscGolfFinishUnderParChallenge
or 12 // RequiredGameModeActivityChallenge
or 13 // CompleteGameWithoutChallenge
or 14 // RequiredGestureChallenge
or 15 // HitstreakChallenge
or 16; // HitstreakCountChallenge
private static string CountedAnyChallengeConfig(int target) =>
"{\"ct\":1,\"ctc\":[{\"ct\":0}],\"t\":" + Math.Clamp(target, 1, 1000) + ",\"cc\":0}";
private static WeeklyChallengeReward NormalizeWeeklyReward(WeeklyChallengeReward? reward)
{
reward ??= DefaultWeeklyReward();
return new WeeklyChallengeReward(
GiftDropId: Math.Max(0, reward.GiftDropId),
Xp: Math.Max(0, reward.Xp),
Tokens: Math.Max(0, reward.Tokens),
Level: Math.Max(0, reward.Level),
GiftContext: Math.Max(0, reward.GiftContext),
GiftRarity: Math.Max(0, reward.GiftRarity),
AvatarItemDesc: reward.AvatarItemDesc?.Trim() ?? string.Empty,
ConsumableItemDesc: reward.ConsumableItemDesc?.Trim() ?? string.Empty,
EquipmentPrefabName: reward.EquipmentPrefabName?.Trim() ?? string.Empty,
EquipmentModificationGuid: reward.EquipmentModificationGuid?.Trim() ?? string.Empty);
}
public static List<WeeklyChallengeTemplate> DefaultWeeklyChallenges() =>
[
new(
Index: 0,
Name: "Play 3 Rec Room Originals",
Config: CountedAnyChallengeConfig(3),
Description: "Complete three Rec Room Original activities.",
Tooltip: "Play any RRO activity to make progress."),
new(
Index: 1,
Name: "Cheer 5 Players",
Config: CountedAnyChallengeConfig(5),
Description: "Send cheers to five players.",
Tooltip: "Use the watch to cheer players you meet."),
new(
Index: 2,
Name: "Visit 5 Rooms",
Config: CountedAnyChallengeConfig(5),
Description: "Visit five rooms this week.",
Tooltip: "Any room visit counts toward this challenge."),
];
public static WeeklyChallengeReward DefaultWeeklyReward() =>
new(
GiftDropId: 0,
Xp: 250,
Tokens: 250,
Level: 1,
GiftContext: 0,
GiftRarity: 0,
AvatarItemDesc: string.Empty,
ConsumableItemDesc: string.Empty,
EquipmentPrefabName: string.Empty,
EquipmentModificationGuid: string.Empty);
public static PlayMenuTagSettings DefaultPlayMenuTags() =>
new(
PinnedTags:
[
"community",
"recroomoriginal",
"featured",
"quest",
"sport",
"template",
"hangout",
"creative",
],
PopularTags:
[
"paintball",
"dodgeball",
"soccer",
"lasertag",
"recroyale",
"discgolf",
"charades",
"bowling",
"paddleball",
"stuntrunner",
"makerpen",
"pvp",
"co-op",
"chill",
"music",
"parkour",
],
TrendingTags:
[
"featured",
"community",
"makerpen",
"pvp",
"quest",
"music",
"parkour",
"hangout",
"creative",
"recroomoriginal",
],
UpdatedAt: DateTime.UtcNow);
public static RecCenterDoorSettings DefaultRecCenterDoors() =>
new(
Doors:
[
new("Shooters", "Shooters", "#paintball|#lasertag|#recroyale"),
new("Creative", "Creative", "#creative|#makerpen|#template"),
new("Quests", "Quests", "#quest"),
new("Sports", "Sports", "#sport"),
new("Featured", "Featured", "#featured|#recroomoriginal"),
],
UpdatedAt: DateTime.UtcNow);
public static DiscoveredGameConfigSettings DefaultDiscoveredGameConfigs() =>
new(
FriendsPostGamePromptUnderFriendCount: 3,
FriendsSuggestFriendCodeOnFriendsScreenCount: 5,
ScreensForceVerification: false,
VrForceVerification: false,
RewardsUseRewardSelection: false,
RewardsSelectionTimeout: 30,
RoomDetailsPhotoRollEnabled: false,
LoadingNetworkTimeout: 30,
RunningNetworkTimeout: 30,
SynchronizedFieldRemoveDefaultEntries: false,
RenderingDisableSrpBatcher: false,
SplitTestSoftOverrides: "{}",
SplitTestHardOverrides: "{}",
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,
CircuitsV2LimitSettings circuits)
{
var rows = new List<GameConfigurationSetting>();
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
{
rows.Add(new($"Door.{door.Key}.Title", door.Title));
rows.Add(new($"Door.{door.Key}.Query", door.Query));
}
var gameConfig = NormalizeDiscoveredGameConfigs(discovered);
rows.AddRange([
new("Friends.PostGamePromptUnderFriendCount", gameConfig.FriendsPostGamePromptUnderFriendCount.ToString()),
new("Friends.SuggestFriendCodeOnFriendsScreenCount", gameConfig.FriendsSuggestFriendCodeOnFriendsScreenCount.ToString()),
new("Screens.ForceVerification", gameConfig.ScreensForceVerification ? "1" : "0"),
new("VR.ForceVerification", gameConfig.VrForceVerification ? "1" : "0"),
new("Rewards.UseRewardSelection", gameConfig.RewardsUseRewardSelection.ToString().ToLowerInvariant()),
new("Rewards.SelectionTimeout", gameConfig.RewardsSelectionTimeout.ToString()),
new("RoomDetails.PhotoRollEnabled", gameConfig.RoomDetailsPhotoRollEnabled.ToString().ToLowerInvariant()),
new("loadingNetworkTimeout", gameConfig.LoadingNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("runningNetworkTimeout", gameConfig.RunningNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("SynchronizedField.RemoveDefaultEntries", gameConfig.SynchronizedFieldRemoveDefaultEntries.ToString().ToLowerInvariant()),
new("Rendering.DisableSrpBatcher", gameConfig.RenderingDisableSrpBatcher.ToString().ToLowerInvariant()),
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(
IReadOnlyList<string>? pinned,
IReadOnlyList<string>? popular,
IReadOnlyList<string>? trending)
{
static List<string> Normalize(IReadOnlyList<string>? tags, int max)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return (tags ?? [])
.Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant())
.Where(t => t.Length > 0)
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
.Where(seen.Add)
.Take(max)
.ToList();
}
var defaults = DefaultPlayMenuTags();
var pinnedTags = Normalize(pinned, 16);
var popularTags = Normalize(popular, 32);
var trendingTags = Normalize(trending, 32);
return new PlayMenuTagSettings(
pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags,
popularTags.Count > 0 ? popularTags : defaults.PopularTags,
trendingTags.Count > 0 ? trendingTags : defaults.TrendingTags,
DateTime.UtcNow);
}
private static RecCenterDoorSettings NormalizeRecCenterDoors(
IReadOnlyList<RecCenterDoorConfig> doors)
{
static string NormalizeKey(string value)
{
var clean = new string((value ?? string.Empty)
.Where(char.IsLetterOrDigit)
.ToArray());
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean;
}
static string NormalizeQuery(string value)
{
var tags = (value ?? string.Empty)
.Split(['|', ',', '\n', '\r', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.Trim().TrimStart('#').ToLowerInvariant())
.Where(t => t.Length > 0)
.Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'))
.Select(t => $"#{t}")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(12)
.ToList();
return string.Join("|", tags);
}
var normalized = doors
.Select(d =>
{
var key = NormalizeKey(d.Key);
var title = string.IsNullOrWhiteSpace(d.Title) ? key : d.Title.Trim();
var query = NormalizeQuery(d.Query);
return new RecCenterDoorConfig(key, title, query);
})
.Where(d => d.Key.Length > 0 && d.Query.Length > 0)
.GroupBy(d => d.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.Take(8)
.ToList();
return new RecCenterDoorSettings(
normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors,
DateTime.UtcNow);
}
private static DiscoveredGameConfigSettings NormalizeDiscoveredGameConfigs(
DiscoveredGameConfigSettings settings)
{
static string JsonObjectOrDefault(string value)
{
value = string.IsNullOrWhiteSpace(value) ? "{}" : value.Trim();
try
{
using var doc = JsonDocument.Parse(value);
return doc.RootElement.ValueKind == JsonValueKind.Object ? value : "{}";
}
catch
{
return "{}";
}
}
return new DiscoveredGameConfigSettings(
FriendsPostGamePromptUnderFriendCount: Math.Clamp(settings.FriendsPostGamePromptUnderFriendCount, 0, 100),
FriendsSuggestFriendCodeOnFriendsScreenCount: Math.Clamp(settings.FriendsSuggestFriendCodeOnFriendsScreenCount, 0, 100),
ScreensForceVerification: settings.ScreensForceVerification,
VrForceVerification: settings.VrForceVerification,
RewardsUseRewardSelection: settings.RewardsUseRewardSelection,
RewardsSelectionTimeout: Math.Clamp(settings.RewardsSelectionTimeout, 0, 300),
RoomDetailsPhotoRollEnabled: settings.RoomDetailsPhotoRollEnabled,
LoadingNetworkTimeout: Math.Clamp(settings.LoadingNetworkTimeout, 1, 300),
RunningNetworkTimeout: Math.Clamp(settings.RunningNetworkTimeout, 1, 300),
SynchronizedFieldRemoveDefaultEntries: settings.SynchronizedFieldRemoveDefaultEntries,
RenderingDisableSrpBatcher: settings.RenderingDisableSrpBatcher,
SplitTestSoftOverrides: JsonObjectOrDefault(settings.SplitTestSoftOverrides),
SplitTestHardOverrides: JsonObjectOrDefault(settings.SplitTestHardOverrides),
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(
bool CompletedRequired,
IReadOnlyList<WeeklyChallengeTemplate> Challenges,
WeeklyChallengeReward Reward,
DateTime UpdatedAt);
public sealed record WeeklyChallengeTemplate(
int Index,
string Name,
string Config,
string Description,
string Tooltip);
public sealed record WeeklyChallengeReward(
int GiftDropId,
int Xp,
int Tokens,
int Level,
int GiftContext,
int GiftRarity,
string AvatarItemDesc,
string ConsumableItemDesc,
string EquipmentPrefabName,
string EquipmentModificationGuid);
public sealed record PlayMenuTagSettings(
List<string> PinnedTags,
List<string> PopularTags,
List<string> TrendingTags,
DateTime UpdatedAt);
public sealed record RecCenterDoorSettings(
List<RecCenterDoorConfig> Doors,
DateTime UpdatedAt);
public sealed record RecCenterDoorConfig(
string Key,
string Title,
string Query);
public sealed record GameConfigurationSetting(
string Key,
string Value,
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,
bool ScreensForceVerification,
bool VrForceVerification,
bool RewardsUseRewardSelection,
int RewardsSelectionTimeout,
bool RoomDetailsPhotoRollEnabled,
float LoadingNetworkTimeout,
float RunningNetworkTimeout,
bool SynchronizedFieldRemoveDefaultEntries,
bool RenderingDisableSrpBatcher,
string SplitTestSoftOverrides,
string SplitTestHardOverrides,
string SplitTestSegmentProbabilities,
DateTime UpdatedAt);