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:
Alexa
2026-10-15 14:07:54 +03:00
parent cf3aa29ba2
commit 36e64e99ba
21 changed files with 7994 additions and 34 deletions
+92 -3
View File
@@ -6,8 +6,9 @@
// 2. TLS trust bypass — make BouncyCastle never reject server certs
//
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
// needed so a modded client isn't kicked, and two opt-in features that
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size).
// needed so a modded client isn't kicked, and three opt-in features that
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size,
// Rec Royale night mode).
//
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
@@ -109,6 +110,52 @@ public class Mod : MelonMod
// generic "Quest" config is intentionally NOT in the default list.
public static string[] QuestMaxTeamSizeRooms =
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
// ── Rec Royale night mode (client-side visual) ──
// Re-skins Rec Royale with one of the game's own shipped night
// MoodSetting assets (sky dome, sun, fog, bloom, colour grading)
// through SceneMoodSettings' apply path, and unlocks the weapon
// flashlight prefab while it is on. Purely local: the networked room
// mood index is never touched. See RecRoyaleNight.
public static bool RecRoyaleNightMode = false;
// Resources asset name of the MoodSetting to apply. Shipped options:
// Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
// Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood";
// Unity scene names (substring, case-insensitive) that count as Rec
// Royale. 2023-03-21 has a single map: RecRoyale_Frontier.
public static string[] RecRoyaleNightScenes = { "RecRoyale" };
// Force WeaponFlashlightController.FlashlightAllowed while night is on.
public static bool RecRoyaleNightFlashlights = true;
// Apply the mood's fog block too. Off = the mood's FogSettings are
// swapped for a block captured from the scene, so fog stays as-is.
public static bool RecRoyaleNightFog = false;
// Darkening knobs applied to the mood data before it is used. Baked
// lightmaps can't be dimmed, so post-exposure (EV, added to the mood's
// ColorGradingSettings.exposure) is what actually darkens the whole
// frame; the sun/ambient multipliers tone down realtime lighting.
public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default)
public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged)
public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged)
// Post-exposure only renders if the scene Volume is on and the camera
// renders post-processing; force both while night mode is active.
public static bool RecRoyaleNightForcePostProcessing = true;
// The engine only drives RenderSettings.sun (Frontier: the sun-disc
// light). Copy its rotation/colour onto every other directional light
// and scale their intensity by RecRoyaleNightSunScale.
public static bool RecRoyaleNightMatchSunLights = true;
// Turn fog off entirely while night is on (RenderSettings.fog=false and
// the mood's fog block pushed out of range). Ignored when
// RecRoyaleNightFog is true.
public static bool RecRoyaleNightDisableFog = false;
// Renderers whose GameObject or material name contains any of these
// (case-insensitive) are hidden while night is on. Frontier's daytime
// cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat.
public static string[] RecRoyaleNightHideObjects = { "Cloud" };
// Optional UnityEngine.KeyCode name to toggle night at runtime (e.g.
// "N"). Empty = no hotkey. A hotkey also arms the feature when
// RecRoyaleNightMode is false, so it can be switched on in-game.
public static string RecRoyaleNightToggleKey = "";
}
public override void OnInitializeMelon()
{
@@ -178,6 +225,19 @@ public class Mod : MelonMod
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
}
// Rec Royale night mode: armed when enabled OR when a hotkey is set
// (so it can be switched on in-game). Scene detection + mood apply
// run from OnSceneWasLoaded / OnUpdate below.
if (Cfg.RecRoyaleNightMode || !string.IsNullOrWhiteSpace(Cfg.RecRoyaleNightToggleKey))
{
_recRoyaleNightArmed = true;
RecRoyaleNight.Register(HarmonyInstance);
}
}
private bool _recRoyaleNightArmed;
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
{
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
}
private bool PatchFileHashCheckerCallback()
{
@@ -359,6 +419,8 @@ public class Mod : MelonMod
}
public override void OnUpdate()
{
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
{
_antiTamperCallbackRetryFrame = 0;
@@ -404,6 +466,33 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
}
if (TryGetConfigValue(r, "RecRoyaleNightMode", out v)) Cfg.RecRoyaleNightMode = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightMood", out v)) Cfg.RecRoyaleNightMood = v.GetString() ?? Cfg.RecRoyaleNightMood;
if (TryGetConfigValue(r, "RecRoyaleNightFlashlights", out v)) Cfg.RecRoyaleNightFlashlights = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightFog", out v)) Cfg.RecRoyaleNightFog = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightExposure", out v)) Cfg.RecRoyaleNightExposure = (float)v.GetDouble();
if (TryGetConfigValue(r, "RecRoyaleNightSunScale", out v)) Cfg.RecRoyaleNightSunScale = (float)v.GetDouble();
if (TryGetConfigValue(r, "RecRoyaleNightAmbientScale", out v)) Cfg.RecRoyaleNightAmbientScale = (float)v.GetDouble();
if (TryGetConfigValue(r, "RecRoyaleNightForcePostProcessing", out v)) Cfg.RecRoyaleNightForcePostProcessing = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightMatchSunLights", out v)) Cfg.RecRoyaleNightMatchSunLights = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightDisableFog", out v)) Cfg.RecRoyaleNightDisableFog = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightHideObjects", out v) && v.ValueKind == JsonValueKind.Array)
{
var names = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
names.Add(e.GetString()!);
Cfg.RecRoyaleNightHideObjects = names.ToArray(); // explicit (incl. empty = hide nothing)
}
if (TryGetConfigValue(r, "RecRoyaleNightToggleKey", out v)) Cfg.RecRoyaleNightToggleKey = v.GetString() ?? "";
if (TryGetConfigValue(r, "RecRoyaleNightScenes", out v) && v.ValueKind == JsonValueKind.Array)
{
var scenes = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
scenes.Add(e.GetString()!);
if (scenes.Count > 0) Cfg.RecRoyaleNightScenes = scenes.ToArray();
}
}
catch (Exception ex)
{
@@ -573,7 +662,7 @@ public class Mod : MelonMod
private MethodInfo GetPatchMethod(string name)
{
// Look in the core + feature patch holder classes.
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize) })
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
+1401
View File
@@ -0,0 +1,1401 @@
// Rec Royale night mode — client-side, opt-in.
//
// Rec Royale never shipped a night variant, but the March-2023 client
// carries everything needed to build one from the game's own tooling:
//
// • Every room scene owns a RecRoomSceneManager (name-preserved field
// `sceneMoodSettings` → RecRoom.Core.Scene.SceneMoodSettings). That
// object is the room "mood" engine behind the Maker Pen sky/sun/fog
// settings: it holds `availableMoods` (MoodSetting[]), `defaultMood`
// and `CurrentMood`, plus blenders for skybox, sun, fog, bloom and
// colour grading.
// • SceneMoodSettings.MLLBALMKEGJ(MoodSetting) is the apply step
// (ISIL: SceneMoodSettings.txt:3025-3581). It pushes the mood's six
// settings blocks into the blenders (ALCMNGKOEBK), swaps the URP
// VolumeProfile (OEKNGEMDMDB), sets RenderSettings.skybox to the
// global MoodConfig skybox material (LNMDIIFOFPD.NHAEHKJINFH, shader
// "Custom/SkyBox"), re-aims RenderSettings.sun, sets every
// ReflectionProbe.customBakedTexture to the mood's baked cubemap and
// force-updates the blenders. It does NOT touch the networked mood
// index, so calling it is purely local.
// • The build ships ready-made night moods as Resources assets
// (resources.assets; ResourceManager table in globalgamemanagers):
// Night_Calm_Outdoor_Mood ("Calm Night"), Night_Spooky_Outdoor_Mood
// ("Spooky Night"), Night_Wild_Outdoor_Mood ("Wild Night"),
// Night_StuntRunner_Outdoor_Mood and OuterSpace_Mood ("Outer Space").
// • The only Rec Royale map in this build is
// Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity.
//
// RRO SCENES HAVE NO ROOM DATA BLOB. The mood engine's `isEnabled` flag
// (SceneMoodSettings+0x38) is only switched on by the room-data loader
// (EIMFCPIHEPF.JPKEMACDNCF, ISIL :2136-2310), which runs for custom rooms
// only. While it is off, get_CurrentMood returns null and nothing ticks.
// The loader's bring-up sequence is:
// LKIKMLNPCFK(false) // SetEnabled(false): applies a null mood
// LIEDFOLBGHL() // capture the scene's own skybox/sun/fog as the
// // blenders' "previous" data (checks shader name
// // "Custom/SkyBox" → HasRecRoomSkyboxShader)
// LKIKMLNPCFK(true) // SetEnabled(true)
// This mod replays exactly that before applying the night mood.
//
// Deliberately NOT used: SceneMoodSettings.IPAMPMBDPNB(Guid) /
// set_CurrentRoomMoodIndex. `_currentRoomMoodIndex` is a
// RecRoom.Networking.SynchronizedFields DFAOKLDJPEL<int>, i.e. it
// replicates to every player in the instance. Applying the mood directly
// keeps night mode a per-client visual choice.
//
// Weapon flashlights: WeaponFlashlightController.get_FlashlightAllowed
// (ISIL: WeaponFlashlightController.txt:3-75) is
// `Matchmaking.FBELDNLEKAC() != null && [+0x28] == 15` — a game-mode
// gate for an activity that isn't Rec Royale. A Harmony postfix forces it
// true while night mode is active, so weapons picked up in the dark get
// the game's own flashlight prefab.
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
namespace DorkNet.ClientMod;
internal static class RecRoyaleNight
{
private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
// Runtime state. `Active` starts from Cfg.RecRoyaleNightMode and flips
// with the hotkey; `_inRoyale` tracks whether the current scene is one
// of Cfg.RecRoyaleNightScenes.
public static bool Active { get; private set; }
private static bool _inRoyale;
private static string _sceneName = "";
private static bool _appliedThisScene;
private static int _frame;
private static int _sceneFrames; // frames since the Royale scene loaded
private static int _reassertFrames; // frames since the last re-apply
// Per-scene bring-up bookkeeping so toggling off can put things back.
private static bool _engineWasDisabled; // we enabled the mood engine ourselves
private static object? _originalSkybox; // RenderSettings.skybox before we touched it
private static bool _sunWasNull; // we assigned RenderSettings.sun ourselves
private static object? _originalFog; // MoodSetting.FogSettings before we swapped it
private static object? _fogPatchedMood; // the mood asset whose FogSettings we replaced
private static object? _darkPatchedMood; // the mood asset whose exposure/sun/ambient we changed
private static float? _origExposure, _origSunIntensity, _origAmbient;
private static bool _diagLogged;
private const int PollEveryFrames = 30; // ~0.5 s @ 60 fps
private const int SettleFrames = 90; // let the scene's own lighting settle first
private const int PollGiveUpFrames = 60 * 120; // stop polling after ~2 min
private const int ReassertEveryFrames = 60 * 10; // re-apply every ~10 s
// Resolved reflection handles (lazy, cached).
private static bool _resolved;
private static Type? _sceneManagerType;
private static PropertyInfo? _sceneManagerInstance;
private static FieldInfo? _sceneMoodSettingsField;
private static PropertyInfo? _sceneMoodSettingsProp;
private static Type? _moodSettingType;
private static MethodInfo? _applyMood; // MLLBALMKEGJ(MoodSetting)
private static MethodInfo? _setEnabled; // LKIKMLNPCFK(bool)
private static MethodInfo? _capture; // LIEDFOLBGHL()
private static FieldInfo? _isEnabled; // isEnabled
private static PropertyInfo? _roomMoodsEnabled;
private static PropertyInfo? _hasSkyShader;
private static PropertyInfo? _hasSunSource;
private static PropertyInfo? _volume;
private static FieldInfo? _availableMoods;
private static PropertyInfo? _currentMood;
private static PropertyInfo? _friendlyName;
private static PropertyInfo? _renderSkybox; // UnityEngine.RenderSettings.skybox
private static PropertyInfo? _renderSun; // UnityEngine.RenderSettings.sun
private static Type? _lightType; // UnityEngine.Light
private static Type? _fogSettingsType; // FogMoodSettings
private static MethodInfo? _fogCapture; // FogMoodSettings.NLDDIDEIEFH() — read RenderSettings fog
private static PropertyInfo? _moodFog; // MoodSetting.FogSettings
private static Type? _materialType; // UnityEngine.Material
private static Type? _shaderType; // UnityEngine.Shader
private static Type? _moodConfigHolder; // LNMDIIFOFPD (static MoodConfig holder)
private static Type? _cameraDataType; // UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
private static object? _ourSkybox; // Material we created for the mood skybox
private static object? _nightMood; // MoodSetting interop wrapper
private static string _nightMoodLoadedName = "";
// Hotkey
private static bool _inputResolved;
private static MethodInfo? _getKeyDown;
private static object? _toggleKey;
// ── registration ──────────────────────────────────────────────────
public static void Register(HarmonyLib.Harmony harmony)
{
Active = Mod.Cfg.RecRoyaleNightMode;
Mod.Log.Msg($"[royale-night] enabled={Active} mood={Mod.Cfg.RecRoyaleNightMood} " +
$"scenes=[{string.Join(",", Mod.Cfg.RecRoyaleNightScenes)}] " +
$"flashlights={Mod.Cfg.RecRoyaleNightFlashlights} " +
$"hotkey={(string.IsNullOrWhiteSpace(Mod.Cfg.RecRoyaleNightToggleKey) ? "<none>" : Mod.Cfg.RecRoyaleNightToggleKey)}");
if (Mod.Cfg.RecRoyaleNightFlashlights)
TryPatchFlashlightGate(harmony);
}
// POSTFIX on WeaponFlashlightController.get_FlashlightAllowed. The
// interop property carries the obfuscated name (KIMMMCCKMJL in the
// 2023-03-21 build) while the il2cpp method keeps `get_FlashlightAllowed`,
// so resolve by name first and fall back to "the only read-only bool
// property declared on the type".
private static void TryPatchFlashlightGate(HarmonyLib.Harmony harmony)
{
try
{
var type = Mod.ResolveType("WeaponFlashlightController");
if (type is null)
{
Mod.Log.Warning("[patch-miss] WeaponFlashlightController: type not found");
return;
}
MethodInfo? getter = AccessTools.PropertyGetter(type, "FlashlightAllowed")
?? AccessTools.PropertyGetter(type, "KIMMMCCKMJL");
if (getter is null)
{
PropertyInfo? only = null;
int count = 0;
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (p.PropertyType != typeof(bool) || !p.CanRead || p.CanWrite || p.GetIndexParameters().Length != 0) continue;
only = p; count++;
}
if (count == 1) getter = only!.GetGetMethod(true);
}
if (getter is null)
{
Mod.Log.Warning("[patch-miss] WeaponFlashlightController.get_FlashlightAllowed");
return;
}
var postfix = typeof(RecRoyaleNight).GetMethod(nameof(FlashlightAllowed_Postfix), BindingFlags.Public | BindingFlags.Static)!;
harmony.Patch(getter, postfix: new HarmonyMethod(postfix));
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] WeaponFlashlightController.get_FlashlightAllowed: {ex.Message}");
}
}
public static void FlashlightAllowed_Postfix(ref bool __result)
{
if (Active && _inRoyale) __result = true;
}
// ── lifecycle (called from Mod) ───────────────────────────────────
public static void OnSceneLoaded(string sceneName)
{
_sceneName = sceneName ?? "";
var was = _inRoyale;
_inRoyale = MatchesScene(_sceneName);
if (_inRoyale)
{
_appliedThisScene = false;
_sceneFrames = 0;
_reassertFrames = 0;
_engineWasDisabled = false;
_originalSkybox = null;
_sunWasNull = false;
_diagLogged = false;
RestoreMoodPatches();
Mod.Log.Msg($"[royale-night] scene '{_sceneName}' loaded — night mode {(Active ? "will apply" : "is off (hotkey to enable)")}");
}
else if (was)
{
_appliedThisScene = false;
_engineWasDisabled = false;
_originalSkybox = null;
_sunWasNull = false;
RestoreMoodPatches();
}
}
public static void Tick()
{
_frame++;
PollHotkey();
if (!_inRoyale || !Active) return;
_sceneFrames++;
if (_frame % PollEveryFrames != 0) return;
if (!_appliedThisScene)
{
if (_sceneFrames < SettleFrames || _sceneFrames > PollGiveUpFrames) return;
if (TryApply(true)) _appliedThisScene = true;
return;
}
// Cheap safeguard: the game re-applies its own mood on quality
// changes / studio events. Re-assert every ~10 s; when the blenders
// are already at target this is visually a no-op.
_reassertFrames += PollEveryFrames;
if (_reassertFrames >= ReassertEveryFrames)
{
_reassertFrames = 0;
TryApply(true);
}
}
public static void Toggle()
{
Active = !Active;
Mod.Log.Msg($"[royale-night] toggled {(Active ? "ON" : "OFF")}");
if (!_inRoyale) return;
if (TryApply(Active)) _appliedThisScene = Active;
else if (!Active) _appliedThisScene = false;
}
// ── the actual apply ──────────────────────────────────────────────
// night=true → bring the mood engine up if needed, apply the night mood
// night=false → restore: scene mood if the engine was already running,
// otherwise disable the engine again + put the skybox back
private static bool TryApply(bool night)
{
try
{
if (!_resolved && !Resolve()) return false;
if (_applyMood is null) return false;
var manager = GetSceneManager();
if (manager is null) { Diag("scene manager instance is null"); return false; }
var moods = _sceneMoodSettingsField?.GetValue(manager) ?? _sceneMoodSettingsProp?.GetValue(manager);
if (moods is null) { Diag("sceneMoodSettings is null"); return false; }
// `isEnabled` reads differently through the interop property vs the
// field (seen True/False for the same state), so don't gate on it.
// RoomMoodsEnabled (= isEnabled && defaultMood != null) is computed
// game-side and is false for every RRO scene; bring the engine up
// exactly once per Royale scene whenever it is not true.
var enabled = GetMember(moods, "isEnabled") is true;
var roomMoodsEnabled = Read(_roomMoodsEnabled, moods) is true;
LogState(moods, enabled);
if (night)
{
var target = GetNightMood();
if (target is null) return false;
if (!roomMoodsEnabled && !_engineWasDisabled)
{
// Replay the room-data loader's bring-up. Remember the
// scene's own skybox so toggle-off can restore it.
if (_originalSkybox is null && _renderSkybox is not null)
_originalSkybox = _renderSkybox.GetValue(null);
if (_setEnabled is null || _capture is null)
{
Mod.Log.Warning("[royale-night] mood engine is disabled in this scene and SetEnabled/Capture are unresolved — cannot bring it up");
return false;
}
// RRO scenes leave RenderSettings.sun unset, and the engine
// skips every sun change (direction, colour, intensity) when
// HasSunSource is false. Point it at the scene's directional
// light BEFORE capture so the flag comes out true.
EnsureSun();
// Keep the scene's own fog unless asked otherwise: replace
// the mood's FogSettings with a block captured from the
// current RenderSettings before anything changes them.
if (!Mod.Cfg.RecRoyaleNightFog) PreserveFog(target);
// Baked lightmaps can't be dimmed, so darken the frame via
// the mood's own post-exposure and tone down sun/ambient.
PatchDarkness(target);
_setEnabled.Invoke(moods, new object[] { false });
_capture.Invoke(moods, null);
// The per-frame update (CEKLEBHDAHA, ISIL :3895-4031) returns
// at once while get_CurrentMood() is null, and the one-shot
// apply only sets blend TARGETS — the update is what writes
// sky colours, rotates the sun and pushes exposure. In RRO
// scenes CurrentMood is null because defaultMood is unset.
// Register the night mood as this scene's defaultMood and
// sole availableMoods entry so the engine runs like a
// one-mood custom room.
InstallSceneMood(moods, target);
_setEnabled.Invoke(moods, new object[] { true });
_engineWasDisabled = true;
ProbeCircuitsSupport();
var cm = _currentMood?.GetValue(moods);
Mod.Log.Msg($"[royale-night] mood engine brought up (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)} " +
$"roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} currentMood={(cm is null || IsUnityNull(cm) ? "<null>" : Describe(cm))})");
// First apply swaps RenderSettings.skybox to
// MoodConfig.SkyBoxMaterial. In RRO scenes that material is
// not loaded (it lives in the custom-room scene's shared
// assets), so the skybox ends up null → black sky and the
// engine's HasRecRoomSkyboxShader / HasSunSource stay false
// (both need shader "Custom/SkyBox" on RenderSettings.skybox).
// Supply a material built from that shader, re-capture so
// the flags flip, then fall through to the real apply.
_applyMood.Invoke(moods, new[] { target });
EnsureMoodSkybox();
_capture.Invoke(moods, null);
Mod.Log.Msg($"[royale-night] re-captured (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} skybox={DescribeSkybox()})");
EnsurePostProcessing(moods);
}
_applyMood.Invoke(moods, new[] { target });
if (Mod.Cfg.RecRoyaleNightMatchSunLights) MatchOtherSunLights();
if (Mod.Cfg.RecRoyaleNightDisableFog && !Mod.Cfg.RecRoyaleNightFog) ForceFogOff();
HideObjects();
Mod.Log.Msg($"[royale-night] applied mood '{Describe(target)}' in '{_sceneName}'");
return true;
}
// restore
if (_engineWasDisabled)
{
RestoreOtherSunLights();
ShowObjects();
RestoreFogState();
_setEnabled?.Invoke(moods, new object[] { false }); // applies the captured "previous" data
UninstallSceneMood(moods);
if (_originalSkybox is not null && _renderSkybox is not null)
_renderSkybox.SetValue(null, _originalSkybox);
if (_sunWasNull && _renderSun is not null)
_renderSun.SetValue(null, null);
RestoreMoodPatches();
_engineWasDisabled = false;
_originalSkybox = null;
_sunWasNull = false;
Mod.Log.Msg($"[royale-night] mood engine disabled again, scene skybox/sun restored in '{_sceneName}'");
return true;
}
var current = _currentMood?.GetValue(moods);
if (current is null || IsUnityNull(current)) { Diag("no CurrentMood to restore"); return false; }
_applyMood.Invoke(moods, new[] { current });
Mod.Log.Msg($"[royale-night] restored scene mood '{Describe(current)}' in '{_sceneName}'");
return true;
}
catch (Exception ex)
{
Mod.Log.Warning($"[royale-night] apply failed: {(ex.InnerException ?? ex).Message}");
return false;
}
}
private static void LogState(object moods, bool enabled)
{
if (_diagLogged) return;
_diagLogged = true;
try
{
var arr = GetMember(moods, "availableMoods");
var cur = _currentMood?.GetValue(moods);
Mod.Log.Msg($"[royale-night] engine state: isEnabled={enabled} roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} " +
$"availableMoods={Count(arr)} currentMood={(cur is null || IsUnityNull(cur) ? "<null>" : Describe(cur))} " +
$"skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] state log failed: {ex.Message}"); }
}
private static object? Read(PropertyInfo? p, object o)
{
try { return p?.GetValue(o); } catch { return null; }
}
private static void Diag(string why)
{
if (_frame % (PollEveryFrames * 20) == 0) Mod.Log.Msg($"[royale-night] waiting: {why}");
}
private static bool Resolve()
{
_resolved = true;
_sceneManagerType = Mod.ResolveType("RecRoomSceneManager");
var moodSettingsType = Mod.ResolveType("RecRoom.Core.Scene.SceneMoodSettings");
_moodSettingType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodSetting");
if (_sceneManagerType is null || moodSettingsType is null || _moodSettingType is null)
{
Mod.Log.Warning($"[royale-night] types missing — sceneManager={_sceneManagerType is not null} " +
$"sceneMoodSettings={moodSettingsType is not null} moodSetting={_moodSettingType is not null}");
return false;
}
// RecRoomSceneManager.Instance: the il2cpp getter is get_Instance but
// the interop property name is obfuscated (PDCOCJBICGC in 2023-03-21).
// Pick the static property whose type is RecRoomSceneManager.
_sceneManagerInstance = AccessTools.Property(_sceneManagerType, "Instance");
if (_sceneManagerInstance is null)
{
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (p.PropertyType == _sceneManagerType && p.CanRead && p.GetIndexParameters().Length == 0)
{ _sceneManagerInstance = p; break; }
}
}
_sceneMoodSettingsField = AccessTools.Field(_sceneManagerType, "sceneMoodSettings");
if (_sceneMoodSettingsField is null)
{
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
if (p.PropertyType == moodSettingsType && p.CanRead) { _sceneMoodSettingsProp = p; break; }
}
_currentMood = AccessTools.Property(moodSettingsType, "CurrentMood");
_roomMoodsEnabled = AccessTools.Property(moodSettingsType, "RoomMoodsEnabled");
_hasSkyShader = AccessTools.Property(moodSettingsType, "HasRecRoomSkyboxShader");
_hasSunSource = AccessTools.Property(moodSettingsType, "HasSunSource");
_volume = AccessTools.Property(moodSettingsType, "Volume");
_availableMoods = AccessTools.Field(moodSettingsType, "availableMoods");
_isEnabled = AccessTools.Field(moodSettingsType, "isEnabled");
_friendlyName = AccessTools.Property(_moodSettingType, "FriendlyName");
// Apply step: MLLBALMKEGJ(MoodSetting) in 2023-03-21. Fallback: the
// only non-setter void method taking exactly one MoodSetting.
_applyMood = AccessTools.Method(moodSettingsType, "MLLBALMKEGJ", new[] { _moodSettingType });
if (_applyMood is null)
{
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType == _moodSettingType) { _applyMood = m; break; }
}
}
// SetEnabled(bool): LKIKMLNPCFK in 2023-03-21. Fallback: the only
// public non-setter void(bool) declared on the type.
_setEnabled = AccessTools.Method(moodSettingsType, "LKIKMLNPCFK", new[] { typeof(bool) });
if (_setEnabled is null)
{
MethodInfo? only = null; int n = 0;
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType == typeof(bool)) { only = m; n++; }
}
if (n == 1) _setEnabled = only;
}
// Capture current scene lighting: LIEDFOLBGHL() in 2023-03-21. Too
// many void() methods on the type to pick by shape — name only.
_capture = AccessTools.Method(moodSettingsType, "LIEDFOLBGHL", Type.EmptyTypes);
var renderSettings = Mod.ResolveType("UnityEngine.RenderSettings");
_renderSkybox = renderSettings is null ? null : AccessTools.Property(renderSettings, "skybox");
_renderSun = renderSettings is null ? null : AccessTools.Property(renderSettings, "sun");
_lightType = Mod.ResolveType("UnityEngine.Light");
// Fog preservation: FogMoodSettings() + NLDDIDEIEFH() reads
// RenderSettings.fog/fogStartDistance/fogEndDistance/fogColor into the
// block (ISIL FogMoodSettings.txt, called right after the ctor in
// LIEDFOLBGHL). MoodSetting.FogSettings is the serialized reference
// the apply step hands to the fog blender.
_fogSettingsType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodBlendData.FogMoodSettings");
_fogCapture = _fogSettingsType is null ? null : AccessTools.Method(_fogSettingsType, "NLDDIDEIEFH", Type.EmptyTypes);
_moodFog = AccessTools.Property(_moodSettingType, "FogSettings");
_materialType = Mod.ResolveType("UnityEngine.Material");
_shaderType = Mod.ResolveType("UnityEngine.Shader");
_moodConfigHolder = Mod.ResolveType("LNMDIIFOFPD");
_cameraDataType = Mod.ResolveType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData");
var ok = _sceneManagerInstance is not null
&& (_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null)
&& _applyMood is not null;
Mod.Log.Msg($"[royale-night] resolved: instance={_sceneManagerInstance is not null} " +
$"moodSettings={_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null} " +
$"apply={_applyMood is not null} setEnabled={_setEnabled is not null} capture={_capture is not null} " +
$"isEnabled={_isEnabled is not null} currentMood={_currentMood is not null} skybox={_renderSkybox is not null} " +
$"sun={_renderSun is not null} light={_lightType is not null} fogCapture={_fogCapture is not null} moodFog={_moodFog is not null}");
return ok;
}
// RenderSettings.sun ← the scene's directional Light when unset. Frontier
// ships without the reference ("Sun Source reference has not been set in
// Lighting settings" is the engine's own warning), so nothing sun-related
// from the mood would otherwise apply.
private static void EnsureSun()
{
try
{
if (_renderSun is null || _lightType is null) return;
var current = _renderSun.GetValue(null);
if (current is not null && !IsUnityNull(current))
{
Mod.Log.Msg($"[royale-night] RenderSettings.sun already set: '{ObjectName(current)}'");
return;
}
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppLight = Il2CppTypeOf(_lightType);
if (unityObject is null || il2cppLight is null) return;
object? directional = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length != 1) continue;
var arr = m.Invoke(null, new object[] { il2cppLight });
var n = Count(arr);
float best = -1f;
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var light = AsType(item, _lightType);
if (light is null) continue;
// LightType.Directional == 1
var kind = GetMember(light, "type");
if (kind is null || Convert.ToInt32(kind) != 1) continue;
var intensity = GetMember(light, "intensity") is float f ? f : 0f;
if (intensity > best) { best = intensity; directional = light; }
}
break;
}
if (directional is null)
{
Mod.Log.Warning("[royale-night] no directional Light found in scene — sun direction/colour will not change");
return;
}
_renderSun.SetValue(null, directional);
_sunWasNull = true;
Mod.Log.Msg($"[royale-night] RenderSettings.sun ← '{ObjectName(directional)}'");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureSun failed: {(ex.InnerException ?? ex).Message}"); }
}
// Swap the mood asset's FogSettings for a block captured from the
// scene's current RenderSettings so applying the mood leaves fog as-is.
// The asset is shared for the session, so RestoreFog puts the original
// block back when we leave or toggle off.
private static void PreserveFog(object mood)
{
try
{
if (_fogSettingsType is null || _fogCapture is null || _moodFog is null || !_moodFog.CanWrite)
{
Mod.Log.Warning("[royale-night] fog preservation unavailable (FogMoodSettings/NLDDIDEIEFH/FogSettings unresolved) — mood fog will apply");
return;
}
if (ReferenceEquals(_fogPatchedMood, mood) && _originalFog is not null) return;
var captured = Activator.CreateInstance(_fogSettingsType);
if (captured is null) return;
_fogCapture.Invoke(captured, null);
if (Mod.Cfg.RecRoyaleNightDisableFog)
{
// The block has no exposed enabled flag; push it out of range so
// even if the blender re-enables fog each frame it is invisible.
SetMember(captured, "startDistance", 1_000_000f);
SetMember(captured, "fadeDistance", 1_000_000f);
}
_originalFog = _moodFog.GetValue(mood);
_fogPatchedMood = mood;
_moodFog.SetValue(mood, captured);
Mod.Log.Msg("[royale-night] mood fog replaced with the scene's own fog settings");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PreserveFog failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void RestoreFog()
{
try
{
if (_fogPatchedMood is not null && _originalFog is not null && _moodFog is not null && _moodFog.CanWrite)
_moodFog.SetValue(_fogPatchedMood, _originalFog);
}
catch { }
_fogPatchedMood = null;
_originalFog = null;
}
private static void RestoreMoodPatches()
{
RestoreFog();
RestoreDarkness();
RestoreOtherSunLights();
// Scene objects die with the scene; just forget them.
_probed = false;
_hidden.Clear();
_hideScanned = false;
_fogStateSaved = false;
_origFogEnabled = null;
// The scene's SceneMoodSettings dies with the scene; just forget it.
_sceneMoodInstalled = false;
_sceneMoodsObj = null;
_origDefaultMood = _origAvailableMoods = null;
}
// The engine drives exactly one light: RenderSettings.sun (Frontier:
// "SkyboxLight", the sun-disc light). The map's actual key light is a
// separate directional Light it never touches, so daylight keeps
// casting. Copy the engine-driven sun's rotation and colour onto every
// other directional light and scale their intensity down. Originals are
// kept per light and restored on toggle-off / scene change.
private sealed class LightState
{
public object Light = null!;
public object? Intensity, Color, Rotation, ShadowStrength;
}
private static readonly List<LightState> _otherSuns = new();
private static void MatchOtherSunLights()
{
try
{
if (_renderSun is null || _lightType is null) return;
var sun = _renderSun.GetValue(null);
if (sun is null || IsUnityNull(sun)) return;
var sunPtr = (sun as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
var sunTransform = GetMember(sun, "transform");
var sunRotation = sunTransform is null ? null : GetMember(sunTransform, "rotation");
var sunColor = GetMember(sun, "color");
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppLight = Il2CppTypeOf(_lightType);
if (unityObject is null || il2cppLight is null) return;
MethodInfo? findAll = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
if (findAll is null) return;
var arr = findAll.Invoke(null, new object[] { il2cppLight });
var n = Count(arr);
var touched = new List<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var light = AsType(item, _lightType);
if (light is null) continue;
var kind = GetMember(light, "type");
if (kind is null || Convert.ToInt32(kind) != 1) continue; // Directional only
var ptr = (light as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
if (ptr != IntPtr.Zero && ptr == sunPtr) continue; // engine already drives this one
var state = _otherSuns.Find(s => (s.Light as Il2CppObjectBase)?.Pointer == ptr);
if (state is null)
{
state = new LightState
{
Light = light,
Intensity = GetMember(light, "intensity"),
Color = GetMember(light, "color"),
ShadowStrength = GetMember(light, "shadowStrength"),
Rotation = GetMember(light, "transform") is object tr ? GetMember(tr, "rotation") : null,
};
_otherSuns.Add(state);
}
if (state.Intensity is float orig)
SetMember(light, "intensity", orig * Mod.Cfg.RecRoyaleNightSunScale);
if (sunColor is not null) SetMember(light, "color", sunColor);
if (sunRotation is not null && GetMember(light, "transform") is object t)
SetMember(t, "rotation", sunRotation);
touched.Add(ObjectName(light));
}
if (touched.Count > 0 && !_otherSunsLogged)
{
_otherSunsLogged = true;
Mod.Log.Msg($"[royale-night] matched {touched.Count} other directional light(s) to the mood sun: {string.Join(", ", touched)}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] MatchOtherSunLights failed: {(ex.InnerException ?? ex).Message}"); }
}
private static bool _otherSunsLogged;
// Register the mood as the scene's defaultMood + sole availableMoods entry
// (get_CurrentMood returns availableMoods[index] when the synced index is
// set, else defaultMood — either way our mood). Originals are kept so
// UninstallSceneMood can put the scene back to "no room moods".
private static object? _sceneMoodsObj;
private static object? _origDefaultMood, _origAvailableMoods;
private static bool _sceneMoodInstalled;
private static void InstallSceneMood(object moods, object mood)
{
try
{
if (_sceneMoodInstalled && ReferenceEquals(_sceneMoodsObj, moods)) return;
_sceneMoodsObj = moods;
_origDefaultMood = GetMember(moods, "defaultMood");
_origAvailableMoods = GetMember(moods, "availableMoods");
var okDefault = SetMember(moods, "defaultMood", mood);
// availableMoods is an Il2CppReferenceArray<MoodSetting>; build a
// one-element array of the same interop type and fill slot 0.
var okArray = false;
var arrProp = moods.GetType().GetProperty("availableMoods", Any);
var arrType = arrProp?.PropertyType ?? _origAvailableMoods?.GetType();
if (arrType is not null)
{
object? arr = null;
foreach (var sig in new[] { new[] { typeof(long) }, new[] { typeof(int) } })
{
var ctor = arrType.GetConstructor(sig);
if (ctor is null) continue;
try { arr = ctor.Invoke(new object[] { sig[0] == typeof(long) ? 1L : 1 }); break; } catch { }
}
if (arr is not null)
{
var set = arrType.GetMethod("set_Item", new[] { typeof(int), _moodSettingType! });
if (set is null)
{
foreach (var m in arrType.GetMethods())
if (m.Name == "set_Item" && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(int)) { set = m; break; }
}
try { set?.Invoke(arr, new[] { 0, mood }); okArray = set is not null && SetMember(moods, "availableMoods", arr); } catch { }
}
}
_sceneMoodInstalled = okDefault || okArray;
Mod.Log.Msg($"[royale-night] installed mood as scene default (defaultMood={okDefault} availableMoods={okArray})");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] InstallSceneMood failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void UninstallSceneMood(object moods)
{
try
{
if (!_sceneMoodInstalled) return;
if (_origDefaultMood is not null) SetMember(moods, "defaultMood", _origDefaultMood);
else SetMember(moods, "defaultMood", null!);
if (_origAvailableMoods is not null) SetMember(moods, "availableMoods", _origAvailableMoods);
}
catch { }
_sceneMoodInstalled = false;
_sceneMoodsObj = null;
_origDefaultMood = _origAvailableMoods = null;
}
// ── fog off / hide objects ────────────────────────────────────────
private static bool _fogStateSaved;
private static object? _origFogEnabled;
private static Type? _renderSettingsType;
private static void ForceFogOff()
{
try
{
_renderSettingsType ??= Mod.ResolveType("UnityEngine.RenderSettings");
if (_renderSettingsType is null) return;
var p = _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static);
if (p is null) return;
if (!_fogStateSaved) { _origFogEnabled = p.GetValue(null); _fogStateSaved = true; }
if (p.GetValue(null) is true) p.SetValue(null, false);
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] ForceFogOff failed: {ex.Message}"); }
}
private static void RestoreFogState()
{
try
{
if (_fogStateSaved && _renderSettingsType is not null && _origFogEnabled is bool b)
_renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static)?.SetValue(null, b);
}
catch { }
_fogStateSaved = false;
_origFogEnabled = null;
}
// Disable every Renderer whose GameObject or material name contains one
// of Cfg.RecRoyaleNightHideObjects. Runs each apply cycle but only scans
// once per scene (the matches are static scenery); ShowObjects re-enables.
private static readonly List<object> _hidden = new();
private static bool _hideScanned;
private static void HideObjects()
{
if (_hideScanned) return;
_hideScanned = true;
try
{
var patterns = new List<string>();
foreach (var s in Mod.Cfg.RecRoyaleNightHideObjects)
if (!string.IsNullOrWhiteSpace(s)) patterns.Add(s.Trim());
if (patterns.Count == 0) return;
var rendererType = Mod.ResolveType("UnityEngine.Renderer");
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppRenderer = rendererType is null ? null : Il2CppTypeOf(rendererType);
if (rendererType is null || unityObject is null || il2cppRenderer is null) return;
MethodInfo? findAll = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
if (findAll is null) return;
var arr = findAll.Invoke(null, new object[] { il2cppRenderer });
var n = Count(arr);
var names = new HashSet<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var r = AsType(item, rendererType);
if (r is null) continue;
var name = ObjectName(r);
var matName = "";
try { var mat = GetMember(r, "sharedMaterial"); if (mat is not null && !IsUnityNull(mat)) matName = ObjectName(mat); } catch { }
var hit = false;
foreach (var p in patterns)
if (name.Contains(p, StringComparison.OrdinalIgnoreCase) || matName.Contains(p, StringComparison.OrdinalIgnoreCase)) { hit = true; break; }
if (!hit) continue;
if (GetMember(r, "enabled") is true && SetMember(r, "enabled", false))
{
_hidden.Add(r);
names.Add(name);
}
}
Mod.Log.Msg($"[royale-night] hid {_hidden.Count} renderer(s) matching [{string.Join(",", patterns)}]: {string.Join(", ", names)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] HideObjects failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void ShowObjects()
{
foreach (var r in _hidden)
{
try { if (!IsUnityNull(r)) SetMember(r, "enabled", true); } catch { }
}
_hidden.Clear();
_hideScanned = false;
}
// ── CV2 room-settings probe ───────────────────────────────────────
// The Maker Pen mood chips ("Skydome Constant", "Room Skydome Modify",
// Sun/Fog/Background Objects equivalents) drive RoomSkydome / RoomSun /
// RoomFog components that hang off RoomElementSettingsManager, which
// CircuitsV2Manager brings up. Log whether any of that exists in this
// scene so we know what a chips-in-Rec-Royale build would have to add.
private static bool _probed;
private static void ProbeCircuitsSupport()
{
if (_probed) return;
_probed = true;
try
{
var unityObject = Mod.ResolveType("UnityEngine.Object");
if (unityObject is null) return;
MethodInfo? findAll = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
if (findAll is null) return;
var parts = new List<string>();
foreach (var name in new[]
{
"RecRoom.CircuitsV2.CircuitsV2Manager",
"RecRoom.CircuitsV2.RoomSettings.RoomElementSettingsManager",
"RecRoom.CircuitsV2.RoomSettings.RoomSkydome",
"RecRoom.CircuitsV2.RoomSettings.RoomSun",
"RecRoom.CircuitsV2.RoomSettings.RoomFog",
"RecRoom.CircuitsV2.RoomSettings.RoomBackgroundObjects",
"MakerPen",
})
{
var t = Mod.ResolveType(name);
if (t is null) { parts.Add($"{name.Substring(name.LastIndexOf('.') + 1)}=<type missing>"); continue; }
var il2 = Il2CppTypeOf(t);
if (il2 is null) { parts.Add($"{t.Name}=?"); continue; }
int n = 0;
try { n = Count(findAll.Invoke(null, new object[] { il2 })); } catch (Exception ex) { parts.Add($"{t.Name}=err:{(ex.InnerException ?? ex).Message}"); continue; }
parts.Add($"{t.Name}={n}");
}
Mod.Log.Msg($"[royale-night] CV2 probe in '{_sceneName}': {string.Join(" ", parts)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] CV2 probe failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void RestoreOtherSunLights()
{
foreach (var s in _otherSuns)
{
try
{
if (IsUnityNull(s.Light)) continue;
if (s.Intensity is not null) SetMember(s.Light, "intensity", s.Intensity);
if (s.Color is not null) SetMember(s.Light, "color", s.Color);
if (s.ShadowStrength is not null) SetMember(s.Light, "shadowStrength", s.ShadowStrength);
if (s.Rotation is not null && GetMember(s.Light, "transform") is object t) SetMember(t, "rotation", s.Rotation);
}
catch { }
}
_otherSuns.Clear();
_otherSunsLogged = false;
}
// Make sure RenderSettings.skybox is a material on the "Custom/SkyBox"
// shader. The apply step assigns MoodConfig.SkyBoxMaterial
// (LNMDIIFOFPD.NHAEHKJINFH → SkyBoxMaterial); in RRO scenes that
// reference resolves to nothing, leaving the skybox null. Prefer any
// already-loaded material on that shader, else create one, and also
// write it into MoodConfig so later applies keep using it.
private static void EnsureMoodSkybox()
{
try
{
if (_renderSkybox is null || _materialType is null || _shaderType is null) return;
var current = _renderSkybox.GetValue(null);
var currentShader = current is null || IsUnityNull(current) ? "" : ShaderNameOf(current);
if (currentShader == "Custom/SkyBox") return;
object? mat = _ourSkybox is not null && !IsUnityNull(_ourSkybox) ? _ourSkybox : null;
// 1. Any loaded material already on the shader (e.g. the real
// Skybox_Moods_Sunny_Default_Mat if some scene brought it in).
if (mat is null)
{
var resources = Mod.ResolveType("UnityEngine.Resources");
var il2cppMat = Il2CppTypeOf(_materialType);
if (resources is not null && il2cppMat is not null)
{
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
var arr = m.Invoke(null, new object[] { il2cppMat });
var n = Count(arr);
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var wrapped = AsType(item, _materialType);
if (wrapped is not null && ShaderNameOf(wrapped) == "Custom/SkyBox") { mat = wrapped; break; }
}
break;
}
}
if (mat is not null) Mod.Log.Msg($"[royale-night] using loaded skybox material '{ObjectName(mat)}'");
}
// 2. Build one: new Material(Shader.Find("Custom/SkyBox")).
if (mat is null)
{
var find = AccessTools.Method(_shaderType, "Find", new[] { typeof(string) });
var shader = find?.Invoke(null, new object[] { "Custom/SkyBox" });
if (shader is null || IsUnityNull(shader))
{
Mod.Log.Warning("[royale-night] Shader.Find(\"Custom/SkyBox\") returned nothing — sky colours will not apply");
return;
}
var ctor = _materialType.GetConstructor(new[] { _shaderType }) ?? _materialType.GetConstructor(new[] { shader.GetType() });
if (ctor is null)
{
Mod.Log.Warning("[royale-night] Material(Shader) constructor not found");
return;
}
mat = ctor.Invoke(new[] { shader });
SetMember(mat, "name", "DorkNet_RecRoyaleNight_Skybox");
Mod.Log.Msg("[royale-night] created skybox material on Custom/SkyBox");
}
_ourSkybox = mat;
_renderSkybox.SetValue(null, mat);
// Keep the engine's own apply path pointing at it too.
if (_moodConfigHolder is not null)
{
foreach (var p in _moodConfigHolder.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (!p.CanRead || p.GetIndexParameters().Length != 0) continue;
var cfg = p.GetValue(null);
if (cfg is null || IsUnityNull(cfg)) continue;
if (SetMember(cfg, "SkyBoxMaterial", mat)) Mod.Log.Msg("[royale-night] MoodConfig.SkyBoxMaterial ← our material");
break;
}
}
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureMoodSkybox failed: {(ex.InnerException ?? ex).Message}"); }
}
private static string ShaderNameOf(object material)
{
try
{
var shader = GetMember(material, "shader");
return shader is null || IsUnityNull(shader) ? "" : ObjectName(shader);
}
catch { return ""; }
}
private static string DescribeSkybox()
{
try
{
var sky = _renderSkybox?.GetValue(null);
if (sky is null || IsUnityNull(sky)) return "<null>";
return $"'{ObjectName(sky)}' [{ShaderNameOf(sky)}]";
}
catch { return "?"; }
}
// Post-exposure lives in the URP Volume the mood engine drives. It only
// shows if the Volume is enabled with weight and the camera renders
// post-processing (Rec Room's graphics settings can turn that off).
private static void EnsurePostProcessing(object moods)
{
try
{
var volume = Read(_volume, moods);
if (volume is not null && !IsUnityNull(volume))
{
var enabled = GetMember(volume, "enabled");
var weight = GetMember(volume, "weight");
var isGlobal = GetMember(volume, "isGlobal");
var profile = GetMember(volume, "profile");
Mod.Log.Msg($"[royale-night] volume: enabled={enabled} weight={weight} global={isGlobal} profile={(profile is null ? "<null>" : ObjectName(profile))}");
if (Mod.Cfg.RecRoyaleNightForcePostProcessing)
{
if (enabled is false) SetMember(volume, "enabled", true);
if (weight is float w && w < 1f) SetMember(volume, "weight", 1f);
}
}
else Mod.Log.Warning("[royale-night] scene mood Volume is null — post-exposure darkening unavailable");
if (_cameraDataType is null) { Mod.Log.Warning("[royale-night] UniversalAdditionalCameraData type not found"); return; }
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppCam = Il2CppTypeOf(_cameraDataType);
if (unityObject is null || il2cppCam is null) return;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
var arr = m.Invoke(null, new object[] { il2cppCam });
var n = Count(arr);
int forced = 0;
var states = new List<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var cam = AsType(item, _cameraDataType);
if (cam is null) continue;
var pp = GetMember(cam, "renderPostProcessing");
states.Add($"{ObjectName(cam)}={pp}");
if (pp is false && Mod.Cfg.RecRoyaleNightForcePostProcessing && SetMember(cam, "renderPostProcessing", true)) forced++;
}
Mod.Log.Msg($"[royale-night] camera post-processing: {string.Join(", ", states)}{(forced > 0 ? $" → forced on ({forced})" : "")}");
break;
}
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsurePostProcessing failed: {(ex.InnerException ?? ex).Message}"); }
}
// Darken the mood data in place before it is applied:
// ColorGradingSettings.exposure += RecRoyaleNightExposure (EV; the
// ColorGrading blender writes it into ColorAdjustments.postExposure
// via VolumeParameter.Override — the only knob that also dims baked
// lightmaps, since it's a full-frame post effect)
// SunSettings.sunIntensity *= RecRoyaleNightSunScale
// SkyBoxSettings.ambientLightIntensity *= RecRoyaleNightAmbientScale
// Originals are kept and restored by RestoreDarkness so the shared asset
// is clean again for any custom room that uses the same mood.
private static void PatchDarkness(object mood)
{
try
{
if (ReferenceEquals(_darkPatchedMood, mood)) return;
RestoreDarkness();
var grading = GetMember(mood, "ColorGradingSettings");
var sun = GetMember(mood, "SunSettings");
var sky = GetMember(mood, "SkyBoxSettings");
if (grading is not null && Mod.Cfg.RecRoyaleNightExposure != 0f && GetMember(grading, "exposure") is float ev)
{
_origExposure = ev;
SetMember(grading, "exposure", ev + Mod.Cfg.RecRoyaleNightExposure);
}
if (sun is not null && Mod.Cfg.RecRoyaleNightSunScale != 1f && GetMember(sun, "sunIntensity") is float si)
{
_origSunIntensity = si;
SetMember(sun, "sunIntensity", si * Mod.Cfg.RecRoyaleNightSunScale);
}
if (sky is not null && Mod.Cfg.RecRoyaleNightAmbientScale != 1f && GetMember(sky, "ambientLightIntensity") is float ai)
{
_origAmbient = ai;
SetMember(sky, "ambientLightIntensity", ai * Mod.Cfg.RecRoyaleNightAmbientScale);
}
_darkPatchedMood = mood;
Mod.Log.Msg($"[royale-night] darkened mood: exposure {_origExposure?.ToString("0.##") ?? "?"}→{(_origExposure + Mod.Cfg.RecRoyaleNightExposure)?.ToString("0.##") ?? "?"} EV, " +
$"sun ×{Mod.Cfg.RecRoyaleNightSunScale} ({_origSunIntensity?.ToString("0.##") ?? "?"}), ambient ×{Mod.Cfg.RecRoyaleNightAmbientScale} ({_origAmbient?.ToString("0.##") ?? "?"})");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PatchDarkness failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void RestoreDarkness()
{
try
{
if (_darkPatchedMood is not null)
{
var grading = GetMember(_darkPatchedMood, "ColorGradingSettings");
var sun = GetMember(_darkPatchedMood, "SunSettings");
var sky = GetMember(_darkPatchedMood, "SkyBoxSettings");
if (grading is not null && _origExposure is float e) SetMember(grading, "exposure", e);
if (sun is not null && _origSunIntensity is float s) SetMember(sun, "sunIntensity", s);
if (sky is not null && _origAmbient is float a) SetMember(sky, "ambientLightIntensity", a);
}
}
catch { }
_darkPatchedMood = null;
_origExposure = _origSunIntensity = _origAmbient = null;
}
private static bool SetMember(object o, string name, object value)
{
var t = o.GetType();
var p = t.GetProperty(name, Any);
if (p is not null && p.CanWrite) { try { p.SetValue(o, value); return true; } catch { } }
var f = t.GetField(name, Any);
if (f is not null) { try { f.SetValue(o, value); return true; } catch { } }
return false;
}
// Il2CppInterop exposes il2cpp fields as properties (and, on some
// versions, as fields). Read either.
private static object? GetMember(object o, string name)
{
var t = o.GetType();
var p = t.GetProperty(name, Any);
if (p is not null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return p.GetValue(o); } catch { } }
var f = t.GetField(name, Any);
if (f is not null) { try { return f.GetValue(o); } catch { } }
return null;
}
// Il2CppType.From(Type) → Il2CppSystem.Type, via reflection so this
// project keeps not referencing Il2Cppmscorlib at compile time.
private static object? Il2CppTypeOf(Type type)
{
foreach (var m in typeof(Il2CppType).GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "From" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length >= 1 && ps[0].ParameterType == typeof(Type))
{
var args = new object?[ps.Length];
args[0] = type;
for (int i = 1; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null);
var r = m.Invoke(null, args);
if (r is not null) return r;
}
}
return null;
}
private static object? GetSceneManager()
{
var inst = _sceneManagerInstance?.GetValue(null);
if (inst is null || IsUnityNull(inst)) return null;
return inst;
}
// Loads the configured MoodSetting asset. Resources.Load(path, Type)
// pulls it out of resources.assets by name (the ResourceManager table
// keys are the lower-cased asset names, no folder). Fallback:
// Resources.FindObjectsOfTypeAll(MoodSetting) by name, which only sees
// assets already in memory.
private static object? GetNightMood()
{
var wanted = (Mod.Cfg.RecRoyaleNightMood ?? "").Trim();
if (wanted.Length == 0) wanted = "Night_Calm_Outdoor_Mood";
if (_nightMood is not null && _nightMoodLoadedName == wanted && !IsUnityNull(_nightMood))
return _nightMood;
_nightMood = null;
var resources = Mod.ResolveType("UnityEngine.Resources");
if (resources is null || _moodSettingType is null)
{
Mod.Log.Warning("[royale-night] UnityEngine.Resources not found");
return null;
}
var il2cppType = Il2CppTypeOf(_moodSettingType);
if (il2cppType is null)
{
Mod.Log.Warning("[royale-night] Il2CppType.From(Type) not found");
return null;
}
object? found = null;
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "Load" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length != 2 || ps[0].ParameterType != typeof(string)) continue;
try
{
found = m.Invoke(null, new object[] { wanted, il2cppType });
if (found is not null && !IsUnityNull(found)) break;
found = null;
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] Resources.Load failed: {(ex.InnerException ?? ex).Message}"); }
}
if (found is null)
{
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length != 1) continue;
try
{
var arr = m.Invoke(null, new object[] { il2cppType });
var n = Count(arr);
var names = new List<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var name = ObjectName(item);
names.Add(name);
if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) { found = item; break; }
}
if (found is null)
Mod.Log.Warning($"[royale-night] mood '{wanted}' not in memory; loaded moods: {string.Join(", ", names)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] FindObjectsOfTypeAll failed: {(ex.InnerException ?? ex).Message}"); }
break;
}
}
if (found is null)
{
Mod.Log.Warning($"[royale-night] could not load MoodSetting '{wanted}'");
return null;
}
// Resources.Load hands back a UnityEngine.Object wrapper; re-wrap the
// same il2cpp pointer as MoodSetting so the apply method accepts it.
_nightMood = AsType(found, _moodSettingType);
_nightMoodLoadedName = wanted;
return _nightMood;
}
private static object? AsType(object obj, Type target)
{
if (target.IsInstanceOfType(obj)) return obj;
if (obj is Il2CppObjectBase il2cpp)
{
var ctor = target.GetConstructor(new[] { typeof(IntPtr) });
if (ctor is not null) return ctor.Invoke(new object[] { il2cpp.Pointer });
}
return obj;
}
// ── hotkey ────────────────────────────────────────────────────────
private static void PollHotkey()
{
var key = Mod.Cfg.RecRoyaleNightToggleKey;
if (string.IsNullOrWhiteSpace(key)) return;
try
{
if (!_inputResolved) ResolveInput(key);
if (_getKeyDown is null || _toggleKey is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKey }) is true) Toggle();
}
catch (Exception ex)
{
Mod.Log.Warning($"[royale-night] hotkey poll failed: {ex.Message}");
_getKeyDown = null; // stop spamming
}
}
private static void ResolveInput(string key)
{
_inputResolved = true;
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
var inputType = Mod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
Mod.Log.Warning("[royale-night] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKey = Enum.Parse(keyCodeType, key, ignoreCase: true); }
catch
{
Mod.Log.Warning($"[royale-night] '{key}' is not a UnityEngine.KeyCode — hotkey disabled");
return;
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) Mod.Log.Warning("[royale-night] Input.GetKeyDown(KeyCode) not found — hotkey disabled");
}
// ── helpers ───────────────────────────────────────────────────────
private static bool MatchesScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return false;
foreach (var s in Mod.Cfg.RecRoyaleNightScenes)
{
if (string.IsNullOrWhiteSpace(s)) continue;
if (sceneName.Contains(s.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
// UnityEngine.Object wrappers are non-null C# objects even when the
// native object is destroyed/missing; `== null` is overloaded in the
// interop, so route through the static op_Equality when present.
private static bool IsUnityNull(object obj)
{
try
{
var t = obj.GetType();
var eq = t.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
if (eq is not null && eq.GetParameters().Length == 2)
return eq.Invoke(null, new[] { obj, null }) is true;
}
catch { }
return false;
}
private static string ObjectName(object obj)
{
try
{
var p = obj.GetType().GetProperty("name", Any);
return p?.GetValue(obj) as string ?? "";
}
catch { return ""; }
}
private static string Describe(object mood)
{
var friendly = "";
try { friendly = _friendlyName?.GetValue(mood) as string ?? ""; } catch { }
var name = ObjectName(mood);
return friendly.Length > 0 ? $"{friendly} ({name})" : name;
}
private static int Count(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var p = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (p?.GetValue(arr) as int?) ?? 0;
}
private static object? Item(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
}
@@ -11,5 +11,18 @@
"GoldenTrophy",
"IsleOfLostSkulls",
"TheRiseofJumbotron"
]
],
"RecRoyaleNightMode": false,
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
"RecRoyaleNightScenes": ["RecRoyale"],
"RecRoyaleNightFlashlights": true,
"RecRoyaleNightFog": false,
"RecRoyaleNightExposure": -3.5,
"RecRoyaleNightSunScale": 0.15,
"RecRoyaleNightAmbientScale": 0.2,
"RecRoyaleNightForcePostProcessing": true,
"RecRoyaleNightMatchSunLights": true,
"RecRoyaleNightDisableFog": false,
"RecRoyaleNightHideObjects": ["Cloud"],
"RecRoyaleNightToggleKey": ""
}
@@ -0,0 +1,254 @@
using DorkNet.Server.Services;
using System.Net.Http.Json;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// The March-2023 client budgets every CircuitsV2 network send against
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
/// frame; past that the send is dropped and the room save is flagged
/// <c>cv2_rpc_limited</c>. DorkNet serves budgets that lift the cap, and
/// these tests pin both the wire shape the client's Newtonsoft reader
/// binds and the clamps that keep a hand-edited value from crashing it.
/// </summary>
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Gameconfigs_endpoint_serves_the_network_budget()
{
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
var rows = await client.GetFromJsonAsync<List<GameConfigurationSetting>>(
"api/gameconfigs/v1/all");
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
// A server that never saved limits (empty column) serves the
// current defaults, not the retail values.
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
}
private static IReadOnlyList<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
ServerSettingsService.ToGameConfigurations(
ServerSettingsService.DefaultRecCenterDoors(),
ServerSettingsService.DefaultDiscoveredGameConfigs(),
limits);
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> rows, string key) =>
rows.Single(r => r.Key == key).Value;
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
[Fact]
public void Defaults_lift_the_rpc_and_byte_budgets()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
Assert.Equal(JsonValueKind.Array, network.ValueKind);
var entry = Assert.Single(network.EnumerateArray());
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
// budget never actually got lifted.
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
}
[Fact]
public void Network_json_uses_the_field_names_the_client_binds()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
.EnumerateArray().First();
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
Assert.True(entry.TryGetProperty("Bytes", out _));
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
Assert.True(entry.TryGetProperty("Rpcs", out _));
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
}
[Fact]
public void Scalar_limits_serialize_invariant_without_separators()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Chips = 123456,
CloudValuesAutosaveSeconds = 300f,
});
// The client parses these with int/float.TryParse under
// InvariantCulture, so a grouped or comma-decimal value silently
// falls back to its baked default.
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
}
[Fact]
public void Defaults_raise_the_room_size_limits_past_retail()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
// Retail: Compute 50000, Chips 3000, CloudValues 100,
// Unculled/VisibleChips 500, InkPerChip 15.
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
// InkPerChip is a cost, so the lift is downward.
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
// A save cadence, not a capacity limit: left at retail.
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
}
[Fact]
public void Default_visible_chips_do_not_exceed_the_chip_cap()
{
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
// The add-chip path only charges InkPerChip when Chips < VisibleChips
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
// defaults keep that path off as well as zeroing the cost.
Assert.True(limits.VisibleChips <= limits.Chips);
Assert.True(limits.UnculledChips <= limits.Chips);
}
[Fact]
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
{
InkPerChip = int.MaxValue,
CloudValues = int.MaxValue,
});
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
// also makes a single chip unaffordable.
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
Assert.Equal(30_000, ink);
Assert.Equal(10_000, cloud);
Assert.True((long)ink * cloud < int.MaxValue / 2);
}
[Fact]
public void Negative_ink_cost_is_floored_at_zero()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
}
[Fact]
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
{
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
// Four List<Chip>(Chips) at culling-manager construction: 0 or less
// throws, and int.MaxValue is an out-of-memory kill.
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
}
[Fact]
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
}
[Fact]
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
}).EnumerateArray().First();
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
}
[Fact]
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
}).EnumerateArray().First();
// Each window frame is one int in a client-side ring buffer, so an
// unbounded window is an out-of-memory kill at circuit init.
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
}
[Fact]
public void Lowest_entry_is_forced_to_activate_at_zero_players()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network =
[
new(20, 4096, 60, 50, 1),
new(8, 2048, 60, 25, 1),
],
});
// Without an entry at 0 the client rejects the whole array and
// falls back to the retail 5-RPC budget.
var entries = network.EnumerateArray().ToList();
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
// The rewrite keeps that entry's own budget.
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
}
[Fact]
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network =
[
new(16, 4096, 60, 50, 1),
new(0, 1024, 60, 10, 1),
new(16, 8192, 60, 99, 1),
],
});
var thresholds = network.EnumerateArray()
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
.ToList();
Assert.Equal([0, 16], thresholds);
}
[Fact]
public void Empty_network_list_falls_back_to_the_default_budget()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [],
});
var entry = Assert.Single(network.EnumerateArray());
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
}
}
+448
View File
@@ -0,0 +1,448 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RecRoom.Protobuf;
namespace DorkNet.Server.Tests;
/// <summary>
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
/// reads that as "no room data at all": no persistence views, no room-role
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
/// in the scene — so DorkNet needs a switch that gives such a room one.
///
/// The property pinned here: the admin "enable blob" action stamps the
/// synthetic default name, that name flows through the game-client details
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
/// map, and only additionally fetches the blob), the CDN answers that name
/// with the RRO-editable blob (permissive room roles, no objects), and
/// "reset" puts everything back to scene-only.
/// </summary>
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
{
using var setup = Client("rooms");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
var roomName = $"Royale{Guid.NewGuid():N}"[..18];
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
// Shape of a seeded baked original: system-owned, AG-flagged,
// tagged recroomoriginal, no blob anywhere.
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = roomName,
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal,sport",
CurrentDataBlobName = string.Empty,
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
});
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
await db.SaveChangesAsync();
}
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var gameClient = Client("rooms", player);
using var adminClient = Client("admin", admin);
using var cdnClient = Client("cdn");
// ── before: baked scene only ────────────────────────────────────────
var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(before.GetProperty("IsRRO").GetBoolean());
Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString());
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var row = listed.EnumerateArray().Single();
Assert.True(row.GetProperty("isBakedOriginal").GetBoolean());
Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString());
// ── enable: the synthetic name lands on the scene AND the room ─────
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.True(enabled.GetProperty("changed").GetBoolean());
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString());
Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString());
// Idempotent: a second enable changes nothing and keeps the name.
var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.False(again.GetProperty("changed").GetBoolean());
Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString());
// The game client now gets the blob name, and IsRRO is untouched —
// that flag is what makes it load the baked map (a false there sends
// it looking for geometry inside the blob and the join fails).
var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(after.GetProperty("IsRRO").GetBoolean());
Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString());
// ── the CDN answers that name with the RRO-editable blob ───────────
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
Assert.True(blobResponse.IsSuccessStatusCode,
$"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}");
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
Assert.NotNull(persisted.RoomRoleData);
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
// The March-2023 client rejects a blob past PersistedRoomVersion 16
// (the old stub was stamped 19 and loaded as "no room data"), and a
// real save carries the room's location id as activity_id. Our proto
// doesn't type activity_id, so look for it on the wire.
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
var blobText = System.Text.Encoding.UTF8.GetString(blobBytes);
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText);
// Every real room save carries three GameRoleNode chips (default role
// named after the room, "In-Game", "Eliminated" — well-known GUIDs).
// The 2023 client grants Maker Pen off the player's GameRole, so a
// blob with no role objects leaves even a co-owner with nothing.
Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes));
Assert.Contains("In-Game", blobText);
Assert.Contains("Eliminated", blobText);
Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText);
Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText);
Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText);
Assert.Contains(roomName, blobText);
// ── reset: back to scene-only ───────────────────────────────────────
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
Assert.True(reset.GetProperty("changed").GetBoolean());
Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString());
var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(restored.GetProperty("IsRRO").GetBoolean());
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
}
/// <summary>The seeded Rec Royale rows have NO RoomScenes at all (the admin
/// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise
/// the entry scene the way the game-client details do, and enable must
/// create the row rather than 404.</summary>
[Fact]
public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it()
{
using var setup = Client("rooms");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_670_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"NoScene{Guid.NewGuid():N}"[..18],
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
MaxCapacity = 24,
});
await db.SaveChangesAsync();
}
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var gameClient = Client("rooms", player);
using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var virtualRow = listed.EnumerateArray().Single();
Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean());
Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32());
Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32());
Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString());
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.True(enabled.GetProperty("changed").GetBoolean());
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
// The row now exists with the game-side defaults, and the game client
// sees the blob on its entry scene.
var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var real = relisted.EnumerateArray().Single();
Assert.False(real.TryGetProperty("isVirtual", out _));
Assert.Equal("Home", real.GetProperty("name").GetString());
Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32());
Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString());
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(details.GetProperty("IsRRO").GetBoolean());
Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString());
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
Assert.True(reset.GetProperty("changed").GetBoolean());
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
}
/// <summary>Rec Royale's Frontier scene has no baked game rule; real Rec
/// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip
/// in the blob. The stub for those rooms must carry it (a fourth
/// persistence view with the palette tag the client generates).</summary>
[Theory]
[InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")]
[InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")]
public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag)
{
using var setup = Client("rooms");
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_680_000 + Random.Shared.Next(1, 99_999);
var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24];
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = roomName,
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f",
});
await db.SaveChangesAsync();
}
using var adminClient = Client("admin", admin);
using var cdnClient = Client("cdn");
await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
Assert.True(blobResponse.IsSuccessStatusCode);
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
}
/// <summary>A real save from any client version can be attached to a baked
/// original: activity id rewritten to the target room, output log /
/// sub-room id / unknown fields dropped, version clamped, and in
/// <c>basics</c> mode only the role + rule objects kept with circuits
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
[Fact]
public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023()
{
using var setup = Client("rooms");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_690_000 + Random.Shared.Next(1, 99_999);
const string location = "253fa009-6e65-4c90-91a1-7137a56a267f";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Upload{Guid.NewGuid():N}"[..18],
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
LocationReplicationId = location,
});
await db.SaveChangesAsync();
}
// A "modern" save: version 138, someone else's activity id, an output
// log, a sub-room id, a newer-only field 37, circuit payloads, and
// three views — a game role (field 50), a game rule (field 41) and a
// plain prop (field 15 only).
var modern = BuildModernSave();
using var adminClient = Client("admin", admin);
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb");
using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form);
var uploadBody = await uploaded.Content.ReadAsStringAsync();
Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}");
var result = JsonDocument.Parse(uploadBody).RootElement;
var blobName = result.GetProperty("dataBlobName").GetString()!;
Assert.StartsWith($"room_{roomId}_upload_", blobName);
var summary = result.GetProperty("summary");
Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32());
Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32());
Assert.Equal(138, summary.GetProperty("versionIn").GetInt32());
Assert.Equal(16, summary.GetProperty("versionOut").GetInt32());
// The game client is now pointed at the uploaded blob …
using var gameClient = Client("rooms", player);
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString());
// … and the CDN serves it rewritten: 2 views, clamped header, our
// activity id, the role overlay applied, no field 37 / output log.
using var cdnClient = Client("cdn");
using var served = await cdnClient.GetAsync($"/room/{blobName}");
Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}");
var bytes = await served.Content.ReadAsByteArrayAsync();
var parsed = PersistedRoomData.Parser.ParseFrom(bytes);
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version);
Assert.NotEmpty(parsed.RoomRoleData.RoomRoles);
Assert.Equal(2, CountTopLevelPersistenceViews(bytes));
var text = System.Text.Encoding.UTF8.GetString(bytes);
Assert.Contains(location, text);
Assert.DoesNotContain("someone-elses-activity", text);
Assert.DoesNotContain("Serialized 3 persistence views", text);
}
private static byte[] BuildModernSave()
{
var ms = new MemoryStream();
void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); }
void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); }
static byte[] View(int markerField)
{
var v = new MemoryStream();
WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray());
WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0);
return v.ToArray();
}
Varint(1, 38);
Bytes(2, View(50)); // game role
Bytes(2, View(41)); // game rule wrapper
Bytes(2, View(15)); // a prop (tagged tool only)
Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000"));
Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views"));
Varint(6, 3991471883933115623UL);
Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
Varint(30, 138);
Varint(31, 1);
Varint(37, 1);
return ms.ToArray();
}
private static void WriteVarint(Stream s, ulong value)
{
while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; }
s.WriteByte((byte)value);
}
[Fact]
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
{
using var setup = Client("rooms");
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_650_000 + Random.Shared.Next(1, 99_999);
const string saved = "room_saved_by_a_player_v3.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Saved{Guid.NewGuid():N}"[..18],
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
CurrentDataBlobName = saved,
});
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
await db.SaveChangesAsync();
}
using var adminClient = Client("admin", admin);
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.False(enabled.GetProperty("changed").GetBoolean());
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
}
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
/// walking the wire format — our server proto doesn't type field 2.</summary>
private static int CountTopLevelPersistenceViews(byte[] blob)
{
var pos = 0;
var count = 0;
while (pos < blob.Length)
{
var tag = ReadVarint(blob, ref pos);
var field = (int)(tag >> 3);
var wireType = (int)(tag & 7);
switch (wireType)
{
case 0: ReadVarint(blob, ref pos); break;
case 1: pos += 8; break;
case 2:
var len = (int)ReadVarint(blob, ref pos);
if (field == 2) count++;
pos += len;
break;
case 5: pos += 4; break;
default: throw new InvalidDataException($"wire type {wireType}");
}
}
return count;
}
private static ulong ReadVarint(byte[] input, ref int pos)
{
ulong value = 0;
var shift = 0;
while (true)
{
var b = input[pos++];
value |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0) return value;
shift += 7;
}
}
private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
{
using var response = await client.PostAsync(path, content: null);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string host, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
if (session is not null)
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -7,6 +7,7 @@ using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using RecRoom.Protobuf;
namespace DorkNet.Server.Controllers.Admin;
@@ -974,7 +975,8 @@ public class AdminController(
double? HotScore,
int? State,
string? ImageName,
int? MaxCapacity);
int? MaxCapacity,
bool? IsAGRoom);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
@@ -1004,6 +1006,20 @@ public class AdminController(
room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}");
}
if (body.IsAGRoom is bool ag)
{
// IsAGRoom is what the client reads as IsRRO. True: load the
// baked scene from the client's own assets AND consult the
// client-baked AGRoomRuntimeConfig table (which, per original,
// decides e.g. whether creation is allowed — Rec Center yes, Rec
// Royale no). False: treat the room as a custom room (roles and
// blob decide everything). Flipping a CLONE of a baked original
// to false is the server-side experiment for "editable Rec
// Royale"; whether the client still loads the baked scene for a
// non-original is what that experiment answers.
room.IsAGRoom = ag;
changes.Add($"isAGRoom={ag}");
}
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
@@ -1117,6 +1133,36 @@ public class AdminController(
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
// Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at
// all; the game-client details builder synthesises a "Home" entry
// scene for them (RoomsController.BuildSubRoomWire with a null scene)
// and the mutation handlers create the row on first write. Mirror
// that here so the SPA has something to act on — the blob/enable
// action below creates the real row.
if (scenes.Count == 0)
{
return Ok(new[]
{
new
{
id = "0",
subRoomId = 0,
name = "Home",
maxPlayers = room.MaxCapacity,
canMatchmakeInto = true,
isSandbox = false,
dataBlobName = string.Empty,
effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal),
isBakedOriginal = bakedOriginal,
isVirtual = true,
dataModifiedAt = room.UpdatedAt,
},
});
}
return Ok(scenes.Select(s => new
{
// Ids go out as strings: the admin SPA is JavaScript and silently
@@ -1128,10 +1174,260 @@ public class AdminController(
canMatchmakeInto = s.CanMatchmakeInto,
isSandbox = s.IsSandbox,
dataBlobName = s.DataBlobName,
// What the client will actually be told to download for this
// sub-room (same rule as RoomsController.BuildSubRoomWire): the
// scene's own blob, else the room's, else a synthetic default —
// except baked Rec Room Originals, which get NO blob until an
// admin enables one (see EnableSubRoomBlob).
effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal),
isBakedOriginal = bakedOriginal,
dataModifiedAt = s.DataModifiedAt,
}).ToList());
}
private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal)
{
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName;
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (bakedOriginal) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
/// editing: the synthetic default for the entry scene (the same name the
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
subRoomId == 0
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
/// — give a baked Rec Room Original sub-room a data blob so the client
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
///
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
/// builder + matchmaking resolver turns that into an empty
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
/// no persistence views, no room-role overlay, no Maker Pen, no room
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
/// it, not in the scene). Stamping the synthetic default name is enough:
/// every resolver returns it verbatim, and <c>CdnController</c> already
/// answers that name for AG rooms with the RRO-editable blob (permissive
/// room roles, no objects) when nothing has been saved yet. The first
/// in-game save then replaces the name with a real upload.
///
/// No-op when the sub-room already has a blob. Idempotent.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
var now = DateTime.UtcNow;
if (scene is null)
{
// Seeded baked originals carry no RoomScenes row; create the
// entry scene with the same defaults the game-side mutation
// path uses (RoomsController.GetOrCreateSceneForMutationAsync).
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
};
db.RoomScenes.Add(scene);
}
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}");
await db.SaveChangesAsync();
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
/// — clear the sub-room's data blob reference. For a baked Rec Room
/// Original that puts the room back to "scene only, no blob" (the escape
/// hatch if a save left the room unplayable); for any other room the
/// resolvers fall back to the synthetic default, i.e. an empty room.
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
/// the in-game restore list can bring an old save back.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
var previousScene = scene?.DataBlobName ?? string.Empty;
var previousRoom = room.CurrentDataBlobName;
var changed = false;
var now = DateTime.UtcNow;
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
{
scene.DataBlobName = string.Empty;
scene.DataModifiedAt = now;
changed = true;
}
if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = string.Empty;
room.UpdatedAt = now;
changed = true;
}
if (changed)
{
await LogAsync("reset_subroom_blob", "room", id,
$"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'");
await db.SaveChangesAsync();
}
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload</c>
/// — multipart <c>file</c> = a real <c>PersistedRoomData</c> save (any
/// client version; <c>.binpb</c> / <c>.room</c> / <c>.dat</c>), optional
/// form/query <c>mode</c> = <c>full</c> (default) or <c>basics</c>.
/// Rewrites it for this room (activity id, 2023 version clamp, drops
/// output log / sub-room id / unknown fields; <c>basics</c> also keeps
/// only the game-role and game-rule objects and drops the circuit
/// payloads — see <see cref="RoomDataBlobService.PrepareUploadedRoomBlob"/>),
/// stores it under a fresh <c>room_{id}_upload_*.dat</c> name, records
/// the RoomDataBlobs row and points the sub-room (and, for sub-room 0,
/// the room) at it. The CDN's RRO role overlay and version clamp then
/// apply on serve exactly as for Rec Center's saved blob.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")]
[RequestSizeLimit(64 * 1024 * 1024)]
public async Task<ActionResult> UploadSubRoomBlob(long id, int subRoomId, IFormFile? file, [FromQuery] string? mode)
{
if (file is null || file.Length == 0) return BadRequest("missing file");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
byte[] raw;
using (var ms = new MemoryStream((int)file.Length))
{
await file.CopyToAsync(ms);
raw = ms.ToArray();
}
try { _ = PersistedRoomData.Parser.ParseFrom(raw); }
catch (Exception ex) { return BadRequest($"not a PersistedRoomData blob: {ex.Message}"); }
var basics = string.Equals(mode ?? Request.Form["mode"].ToString(), "basics", StringComparison.OrdinalIgnoreCase);
var (bytes, summary) = RoomDataBlobService.PrepareUploadedRoomBlob(raw, room.LocationReplicationId, basics);
var now = DateTime.UtcNow;
var blobName = $"room_{id}_upload_{now:yyyyMMddHHmmss}.dat";
var (bucket, key) = BlobRouter.Route(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, "application/octet-stream");
}
catch (Exception ex)
{
adminLogger.LogError(ex, "[admin] blob upload S3 PUT failed room={Room} blob={Blob}", id, blobName);
return StatusCode(502, new { error = "storage_put_failed", detail = ex.Message });
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = id,
BlobName = blobName,
SubRoomId = subRoomId,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = now,
});
if (scene is null)
{
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
};
db.RoomScenes.Add(scene);
}
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0)
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("upload_subroom_blob", "room", id,
$"subRoom={subRoomId} blob={blobName} mode={(basics ? "basics" : "full")} bytes={bytes.Length} views={summary.ViewsKept}/{summary.ViewsIn} roles={summary.RoleViews} rules={summary.GameRuleViews} version={summary.VersionIn}->{summary.VersionOut}");
await db.SaveChangesAsync();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
return Ok(new
{
id = scene.Id.ToString(),
subRoomId = scene.OrderIndex,
name = scene.Name,
dataBlobName = scene.DataBlobName,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
bytes = bytes.Length,
// Explicit camelCase: the API's naming policy is "as declared",
// and the rest of this payload is camelCase.
summary = new
{
viewsIn = summary.ViewsIn,
viewsKept = summary.ViewsKept,
roleViews = summary.RoleViews,
gameRuleViews = summary.GameRuleViews,
versionIn = summary.VersionIn,
versionOut = summary.VersionOut,
circuitsStripped = summary.CircuitsStripped,
activityId = summary.ActivityId,
},
});
}
private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new
{
id = (scene?.Id ?? 0).ToString(),
subRoomId = scene?.OrderIndex ?? 0,
name = scene?.Name ?? "Home",
dataBlobName = scene?.DataBlobName ?? string.Empty,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
changed,
};
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
@@ -1959,6 +2255,22 @@ public class AdminController(
return Ok(settings);
}
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
[HttpGet("settings/circuits-limits")]
public async Task<ActionResult> GetCircuitsV2Limits()
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
[HttpPost("settings/circuits-limits")]
public async Task<ActionResult> SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body)
{
var settings = await serverSettings.SetCircuitsV2LimitsAsync(body);
await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits");
await db.SaveChangesAsync();
return Ok(settings);
}
// ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
+45 -16
View File
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Cdn;
@@ -557,11 +558,18 @@ public class CdnController(
return new FileContentResult(TransparentPng, "image/png");
}
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
var rroRoom = await ResolveRroRoomAsync(fileName);
if (rroRoom is not null)
{
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
Request.Host.Host, fileName);
var rroBlob = roomDataBlob.GetRroEditableBlob();
// Per-room stub: a real save's skeleton at the 2023 version
// ceiling with the room's own activity_id. The old generic stub
// (version 19, roles only) was fetched and silently rejected by
// the client, which is why an enabled baked room still had no
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name);
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, rroBlob);
return new FileContentResult(rroBlob, "application/octet-stream");
@@ -569,14 +577,34 @@ public class CdnController(
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
Request.Host.Host, fileName);
var fallbackBlob = roomDataBlob.GetDefaultBlob();
var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
signatures.AddContentSignature(Response, fallbackBlob);
return new FileContentResult(fallbackBlob, "application/octet-stream");
}
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
/// <summary>Same 2023 version clamp the S3-hit path applies, for the
/// synthesised blobs on the miss path — a stub past the client's ceiling
/// is rejected exactly like a modern export would be.</summary>
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return false;
if (!IsRoomDataName(fileName)) return bytes;
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
if (changed)
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
return clamped;
}
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
await ResolveRroRoomAsync(fileName) is not null;
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
/// the per-room stub on misses.</summary>
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return null;
// MakerPen across ALL Rec Room Originals: overlay the permissive
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
@@ -587,9 +615,9 @@ public class CdnController(
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
// RoleData) — the game-rule circuit data and GameRoleData are left
// intact — so quest rooms keep working, exactly as Stunt Runner does.
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
r.IsAGRoom && r.CurrentDataBlobName == fileName))
return true;
var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
r.IsAGRoom && r.CurrentDataBlobName == fileName);
if (byCurrentBlob is not null) return byCurrentBlob;
// Synthetic/default room-data blob names for rooms with no saved blob:
// the CURRENT form is room_{id}_dorknet_v8.dat
@@ -603,18 +631,19 @@ public class CdnController(
{
var rest = fileName["room_".Length..];
var underscore = rest.IndexOf('_');
if (underscore > 0 &&
long.TryParse(rest[..underscore], out var roomId) &&
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom))
return true;
if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
{
var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
if (byId is not null) return byId;
}
}
return await (
from scene in db.RoomScenes.AsNoTracking()
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
where room.IsAGRoom && scene.DataBlobName == fileName
select scene.Id
).AnyAsync();
select room
).FirstOrDefaultAsync();
}
/// <summary>
@@ -65,6 +65,12 @@ public class StorageController(
[HttpPost("/upload", Order = -2000)]
[Consumes("multipart/form-data")]
// Kestrel's default 30,000,000-byte body cap would 413 the room save of
// a big circuits room now that the served CircuitsV2 chip limit is 30000
// (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin
// import endpoints and Cloudflare's own per-request ceiling.
[RequestSizeLimit(100_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)]
public async Task<IActionResult> Upload()
{
// Form parsing — ASP.NET Core deserialises the multipart body
@@ -108,5 +108,14 @@ public class ServerSettingsEntity
/// admins curate the base-room list from the SPA without a redeploy.</summary>
public string BaseRoomNamesJson { get; set; } = string.Empty;
/// <summary>Admin-managed CircuitsV2 creation limits, served to the
/// client as the <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. The
/// network entry carries the per-frame RPC and byte budgets a circuit
/// may spend; DorkNet ships them effectively unlimited, where retail
/// shipped 5 RPCs/frame and 1024 bytes/60 frames. Stored as JSON so
/// new limit keys don't need a table migration.
/// See <see cref="Services.CircuitsV2LimitSettings"/>.</summary>
public string CircuitsV2LimitsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,3658 @@
// <auto-generated />
using System;
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DorkNet.Server.Migrations
{
[DbContext(typeof(DorkNetDbContext))]
[Migration("20260802190504_CircuitsV2Limits")]
partial class CircuitsV2Limits
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AdminPlayerId");
b.HasIndex("Timestamp");
b.HasIndex("TargetType", "TargetId");
b.ToTable("AdminActions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ReadAt");
b.HasIndex("ReporterPlayerId");
b.ToTable("BugReports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("PlayerId");
b.ToTable("Cards");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey", "SentAt");
b.ToTable("ChatMessages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ThreadKey", "PlayerId")
.IsUnique();
b.ToTable("ChatThreadMembers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TargetPhotoId");
b.HasIndex("TargetPlayerId");
b.HasIndex("TargetRoomId");
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
.IsUnique();
b.ToTable("Cheers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("ReadAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("AnnouncementId", "PlayerId")
.IsUnique();
b.ToTable("ClubAnnouncementReads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CategoryTagId");
b.HasIndex("ClubId", "CategoryTagId")
.IsUnique();
b.ToTable("ClubCategoryAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Visibility")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Clubs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("ClubId", "PlayerId")
.IsUnique();
b.ToTable("ClubMemberships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubscribedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId");
b.HasIndex("PlayerId", "ClubId")
.IsUnique();
b.ToTable("ClubSubscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Variant")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CohortKey")
.IsUnique();
b.ToTable("CohortAssignments");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsFeatured");
b.HasIndex("IsPublic");
b.HasIndex("PublicId")
.IsUnique();
b.ToTable("CustomAvatarItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CustomAvatarItemId");
b.HasIndex("PlayerId", "CustomAvatarItemId")
.IsUnique();
b.ToTable("CustomAvatarItemOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
{
b.Property<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Region");
b.ToTable("GameSessions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CheerCount");
b.HasIndex("CreatorPlayerId");
b.ToTable("Inventions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "ItemKey", "ItemType")
.IsUnique();
b.ToTable("ItemWishlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "EventKey");
b.ToTable("Keepsakes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
{
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("Value")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId", "PlayerId", "StatChannel")
.IsUnique();
b.HasIndex("RoomId", "StatChannel", "Value");
b.ToTable("LeaderboardStats");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "SentAt");
b.HasIndex("SenderPlayerId", "SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("ViewCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName");
b.HasIndex("CreatedAt");
b.HasIndex("RoomId");
b.HasIndex("UploaderPlayerId");
b.ToTable("Photos");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
.IsUnique();
b.ToTable("PlatformIgnores");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DeviceId");
b.HasIndex("PlayerId");
b.ToTable("PlayerDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Wins")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GameMode", "Elo");
b.HasIndex("PlayerId", "GameMode")
.IsUnique();
b.ToTable("PlayerElo");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("StartsAt");
b.ToTable("PlayerEvents");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Response")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("EventId", "PlayerId")
.IsUnique();
b.ToTable("PlayerEventResponses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "ItemSlug")
.IsUnique();
b.ToTable("PlayerInventory");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("PlayerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("WarningMask")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("IsCurated");
b.HasIndex("Name");
b.ToTable("Playlists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlaylistId", "PlayerId")
.IsUnique();
b.ToTable("PlaylistInteractions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.HasIndex("RoomId");
b.HasIndex("PlaylistId", "OrderIndex");
b.ToTable("PlaylistRooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
{
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SubRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OwnerPlayerId");
b.HasIndex("RoomId", "SubRoomId");
b.ToTable("PrivateInstances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
{
b.Property<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("PushTokens");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ReporterPlayerId");
b.HasIndex("ResolvedAt");
b.HasIndex("TargetPlayerId");
b.ToTable("Reports");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BannedPlayerId");
b.HasIndex("RoomId", "BannedPlayerId");
b.ToTable("RoomBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("PlayerId", "RoomId")
.IsUnique();
b.ToTable("RoomBookmarks");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomConsumables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomConsumableId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomConsumableId");
b.HasIndex("PlayerId", "RoomConsumableId")
.IsUnique();
b.ToTable("RoomConsumableOwnership");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomCurrencyId");
b.HasIndex("PlayerId", "RoomCurrencyId")
.IsUnique();
b.ToTable("RoomCurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomCurrencies");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomCurrencyId");
b.ToTable("RoomCurrencyPurchaseOffers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("UploadedByPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("BlobName")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("RoomDataBlobs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("HotScore");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Rooms");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "Name");
b.ToTable("RoomKeys");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("RoomKeyId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomKeyId", "PlayerId")
.IsUnique();
b.ToTable("RoomKeyPurchases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoomId");
b.HasIndex("RoomId", "PlayerId", "Role")
.IsUnique();
b.ToTable("RoomRoles");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("StudioUnityAssetId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoomId", "Name");
b.HasIndex("RoomId", "OrderIndex")
.IsUnique();
b.ToTable("RoomScenes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("RoomId", "LastVisitAt");
b.HasIndex("RoomId", "PlayerId")
.IsUnique();
b.ToTable("RoomVisits");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.HasIndex("MatchId", "PlayerId");
b.ToTable("RoyaleMatchPlayers");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("Storefront");
b.HasIndex("IsActive", "Category");
b.ToTable("StoreItems");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SubscriberPlayerId");
b.HasIndex("TargetPlayerId");
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
.IsUnique();
b.ToTable("Subscriptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
{
b.Property<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Pk");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("TestPassId");
b.ToTable("TestCases");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
{
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorPlayerId");
b.HasIndex("PublicId")
.IsUnique();
b.HasIndex("RoomId");
b.ToTable("UgcPurchasables");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithOne("Avatar")
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
.WithMany("Settings")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
{
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
.WithMany("Relationships")
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Requester");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
{
b.Navigation("Avatar");
b.Navigation("Relationships");
b.Navigation("Settings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class CircuitsV2Limits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CircuitsV2LimitsJson",
table: "ServerSettings",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CircuitsV2LimitsJson",
table: "ServerSettings");
}
}
}
+366 -6
View File
@@ -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_&lt;id&gt;_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 (&gt; 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,
+140 -3
View File
@@ -165,6 +165,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
const [description, setDescription] = useState(room.description);
const [accessibility, setAccessibility] = useState(room.accessibility);
const [cloningAllowed, setCloningAllowed] = useState(room.cloningAllowed);
const [isAGRoom, setIsAGRoom] = useState(room.isAGRoom);
const [tagsCsv, setTagsCsv] = useState(room.tagsCsv);
const [hotScore, setHotScore] = useState(String(room.hotScore));
const [imageName, setImageName] = useState(room.imageName);
@@ -176,6 +177,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
setDescription(room.description);
setAccessibility(room.accessibility);
setCloningAllowed(room.cloningAllowed);
setIsAGRoom(room.isAGRoom);
setTagsCsv(room.tagsCsv);
setHotScore(String(room.hotScore));
setImageName(room.imageName);
@@ -193,6 +195,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
Description: description,
Accessibility: accessibility,
CloningAllowed: cloningAllowed,
IsAGRoom: isAGRoom,
TagsCsv: tagsCsv,
HotScore: Number.isFinite(hot) ? hot : undefined,
ImageName: imageName,
@@ -283,6 +286,14 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
/>
<span>Cloning allowed</span>
</label>
<label className="flex items-center gap-2 text-sm" title="What the client reads as IsRRO. On: load the baked scene from the client's assets and consult the client's built-in per-original table (which forbids creation in Rec Royale). Off: treat as a custom room — roles and blob decide. Flip a CLONE of an original off to test editable Rec Royale.">
<input
type="checkbox"
checked={isAGRoom}
onChange={e => setIsAGRoom(e.target.checked)}
/>
<span>Rec Room Original (IsRRO) — baked scene + client creation table</span>
</label>
<div className="flex gap-2 pt-2 border-t border-ink-800">
<button onClick={save} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving…' : 'Save changes'}
@@ -563,6 +574,14 @@ interface SubRoom {
canMatchmakeInto: boolean;
isSandbox: boolean;
dataBlobName: string;
/** What the client is actually told to download: scene blob, else room
* blob, else synthetic default — or '' for a baked Rec Room Original
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
effectiveBlobName: string;
isBakedOriginal: boolean;
/** No RoomScenes row exists yet (seeded baked originals); the entry
* scene is synthesised on read and created by "Enable Maker Pen blob". */
isVirtual?: boolean;
}
// Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is
@@ -576,6 +595,60 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
const { data, loading, error, refresh } = useApi<SubRoom[]>(`/rooms/${roomId}/subrooms`);
const [edits, setEdits] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<number | null>(null);
const [blobBusy, setBlobBusy] = useState<number | null>(null);
const [resetArmed, setResetArmed] = useState<number | null>(null);
// Baked Rec Room Originals ship with no data blob, so the client never
// runs its room-data path there (no Maker Pen, no CV2 chips, no saves).
// "Enable" stamps the synthetic default name the CDN already answers with
// the RRO-editable blob; "Reset" clears the pointers back to scene-only.
// Attach a real PersistedRoomData save (any client version) to a sub-room:
// the server rewrites it for this room (activity id, 2023 version clamp,
// drops output log / sub-room id / unknown fields; "basics" keeps only the
// game-role + game-rule objects and strips circuits).
const [uploadMode, setUploadMode] = useState<'full' | 'basics'>('basics');
const uploadBlob = async (sub: SubRoom, file: File) => {
setBlobBusy(sub.subRoomId);
try {
const fd = new FormData();
fd.append('file', file, file.name);
const res = await api<{ dataBlobName: string; summary: { viewsIn: number; viewsKept: number; versionIn: number; versionOut: number } }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/upload?mode=${uploadMode}`,
{ method: 'POST', formData: fd, timeoutMs: 120000 },
);
toast.push(
`Uploaded ${res.dataBlobName}: ${res.summary.viewsKept}/${res.summary.viewsIn} objects kept, version ${res.summary.versionIn} → ${res.summary.versionOut}`,
'success',
);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const setBlob = async (sub: SubRoom, action: 'enable' | 'reset') => {
setBlobBusy(sub.subRoomId);
try {
const res = await api<{ effectiveBlobName: string; changed: boolean }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/${action}`,
{ method: 'POST' },
);
toast.push(
action === 'enable'
? `${sub.name || `Sub-room ${sub.subRoomId}`} now loads ${res.effectiveBlobName}`
: `${sub.name || `Sub-room ${sub.subRoomId}`} reset to the baked scene (no blob)`,
'success',
);
setResetArmed(null);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const save = async (sub: SubRoom) => {
const raw = edits[sub.subRoomId];
@@ -631,8 +704,71 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
{sub.isSandbox && <span className="badge-neutral">Sandbox</span>}
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
</div>
{sub.dataBlobName && (
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">
{sub.effectiveBlobName
? sub.effectiveBlobName
: sub.isBakedOriginal
? 'no data blob — baked scene only (no Maker Pen / chips)'
: '(synthetic default blob)'}
</div>
{sub.isBakedOriginal && (
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<label className="text-xs text-ink-400">Upload save</label>
<select
value={uploadMode}
onChange={e => setUploadMode(e.target.value as 'full' | 'basics')}
className="input text-xs py-0.5 w-40"
title="basics = keep only the game-role and game-rule objects and strip circuits (a newer CircuitsV2 graph is rejected by the 2023 client); full = keep every object"
>
<option value="basics">basics (roles + game rule)</option>
<option value="full">full (every object)</option>
</select>
<input
type="file"
accept=".binpb,.room,.dat,.bin"
disabled={blobBusy === sub.subRoomId}
onChange={e => {
const f = e.target.files?.[0];
if (f) uploadBlob(sub, f);
e.target.value = '';
}}
className="text-xs"
/>
</div>
)}
{sub.isBakedOriginal && (
<div className="flex items-center gap-2 mt-1.5">
{!sub.dataBlobName ? (
<button
onClick={() => setBlob(sub, 'enable')}
disabled={blobBusy === sub.subRoomId}
className="btn-secondary text-xs"
title="Serve a room data blob for this baked sub-room so Maker Pen, CV2 chips and saves work. The first in-game save replaces it with a real upload."
>
{blobBusy === sub.subRoomId ? 'Enabling…' : 'Enable Maker Pen blob'}
</button>
) : resetArmed === sub.subRoomId ? (
<>
<span className="text-xs text-danger">Drops the blob pointer; saved blobs stay restorable.</span>
<button
onClick={() => setBlob(sub, 'reset')}
disabled={blobBusy === sub.subRoomId}
className="btn-danger text-xs"
>
{blobBusy === sub.subRoomId ? 'Resetting…' : 'Confirm reset'}
</button>
<button onClick={() => setResetArmed(null)} className="btn-secondary text-xs">Cancel</button>
</>
) : (
<button
onClick={() => setResetArmed(sub.subRoomId)}
className="btn-secondary text-xs"
title="Go back to the baked scene with no data blob (escape hatch if a save broke the room)."
>
Reset to baked scene
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
@@ -647,8 +783,9 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
/>
<button
onClick={() => save(sub)}
disabled={!dirty || saving === sub.subRoomId}
disabled={!dirty || saving === sub.subRoomId || sub.isVirtual}
className="btn-primary text-xs"
title={sub.isVirtual ? 'This room has no sub-room row yet — enable the blob first' : undefined}
>
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
</button>
+238 -1
View File
@@ -139,10 +139,31 @@ interface DiscoveredGameConfigSettings {
updatedAt: string;
}
interface CircuitsV2NetworkLimit {
numPlayersForActivation: number;
bytes: number;
bytesTimeWindowFrame60s: number;
rpcs: number;
rpcTimeWindowFrame60s: number;
}
interface CircuitsV2LimitSettings {
network: CircuitsV2NetworkLimit[];
compute: number;
chips: number;
cloudValues: number;
cloudValuesAutosaveSeconds: number;
unculledChips: number;
visibleChips: number;
inkPerChip: number;
updatedAt: string;
}
export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [gameConfigs, setGameConfigs] = useState<DiscoveredGameConfigSettings | null>(null);
const [circuits, setCircuits] = useState<CircuitsV2LimitSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
avatarItems: [],
consumables: [],
@@ -152,20 +173,23 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [busy, setBusy] = useState(false);
const [weeklyBusy, setWeeklyBusy] = useState(false);
const [gameConfigBusy, setGameConfigBusy] = useState(false);
const [circuitsBusy, setCircuitsBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const [s, w, rewards, configs] = await Promise.all([
const [s, w, rewards, configs, limits] = await Promise.all([
get<ServerSettings>('/settings'),
get<WeeklyChallengeSettings>('/settings/weekly-challenges'),
get<RewardOptions>('/settings/weekly-challenges/reward-options'),
get<DiscoveredGameConfigSettings>('/settings/gameconfigs'),
get<CircuitsV2LimitSettings>('/settings/circuits-limits'),
]);
setSettings(s);
setWeekly(w);
setRewardOptions(rewards);
setGameConfigs(configs);
setCircuits(limits);
setErr(null);
} catch (e) {
setErr((e as Error).message);
@@ -470,6 +494,39 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
}
};
const updateCircuits = (patch: Partial<CircuitsV2LimitSettings>) => {
setCircuits((current) => current ? { ...current, ...patch } : current);
};
const updateCircuitsNetwork = (index: number, patch: Partial<CircuitsV2NetworkLimit>) => {
setCircuits((current) => current
? {
...current,
network: current.network.map((entry, i) => i === index ? { ...entry, ...patch } : entry),
}
: current);
};
const saveCircuits = async (reset = false) => {
if (!circuits && !reset) return;
setCircuitsBusy(true);
try {
const body = reset ? defaultCircuitsLimits() : circuits;
const updated = await api<CircuitsV2LimitSettings>('/settings/circuits-limits', {
method: 'POST',
body,
});
setCircuits(updated);
toast.push(
reset ? 'CircuitsV2 limits reset.' : 'CircuitsV2 limits saved. Players must rejoin their room.',
'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setCircuitsBusy(false);
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
@@ -665,6 +722,17 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
/>
)}
{circuits && (
<CircuitsV2LimitsPanel
settings={circuits}
busy={circuitsBusy}
onChange={updateCircuits}
onNetworkChange={updateCircuitsNetwork}
onSave={() => saveCircuits(false)}
onReset={() => saveCircuits(true)}
/>
)}
{weekly && (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -1068,19 +1136,168 @@ function DiscoveredGameConfigsPanel({
);
}
function CircuitsV2LimitsPanel({
settings,
busy,
onChange,
onNetworkChange,
onSave,
onReset,
}: {
settings: CircuitsV2LimitSettings;
busy: boolean;
onChange: (patch: Partial<CircuitsV2LimitSettings>) => void;
onNetworkChange: (index: number, patch: Partial<CircuitsV2NetworkLimit>) => void;
onSave: () => void;
onReset: () => void;
}) {
const number = (key: keyof CircuitsV2LimitSettings, value: string) => {
const n = Number.parseFloat(value);
onChange({ [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2LimitSettings>);
};
const netNumber = (index: number, key: keyof CircuitsV2NetworkLimit, value: string) => {
const n = Number.parseInt(value, 10);
onNetworkChange(index, { [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2NetworkLimit>);
};
return (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">CircuitsV2 limits</h2>
<p className="mt-1 text-xs text-ink-400">
Served as the <span className="font-mono">UGC.CircuitsV2.Limit.*</span> gameconfig keys.
Retail budgets a circuit 5 RPCs per frame and 1024 bytes per second and silently drops
everything past that; DorkNet ships both effectively unlimited, and raises the room-size
limits below about 10x past retail. Clients read these once
when circuits start, so players must rejoin their room to pick up a change.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={onReset} disabled={busy} className="btn-secondary text-xs">
<RefreshCw />
Defaults
</button>
<button onClick={onSave} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
{settings.network.map((entry, index) => (
<div key={index} className="rounded-lg border border-ink-800 bg-ink-950/30 p-4 lg:col-span-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">
Network budget{settings.network.length > 1 ? ` — from ${entry.numPlayersForActivation} players` : ''}
</h3>
<p className="mt-1 text-[11px] text-ink-500">
Each budget is spent over a rolling window measured in frames, so a window of 1 means
per frame and 60 means roughly per second at 60fps. Widening a window makes the limit
stricter, not looser — raise the budget instead.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<NumberConfig
label="Rpcs"
value={entry.rpcs}
onChange={(v) => netNumber(index, 'rpcs', v)}
/>
<NumberConfig
label="RpcTimeWindowFrame60s"
value={entry.rpcTimeWindowFrame60s}
onChange={(v) => netNumber(index, 'rpcTimeWindowFrame60s', v)}
/>
<NumberConfig
label="Bytes"
value={entry.bytes}
onChange={(v) => netNumber(index, 'bytes', v)}
/>
<NumberConfig
label="BytesTimeWindowFrame60s"
value={entry.bytesTimeWindowFrame60s}
onChange={(v) => netNumber(index, 'bytesTimeWindowFrame60s', v)}
/>
</div>
</div>
))}
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Circuit budget</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="Compute"
value={settings.compute}
hint="Per-frame CPU budget for all circuits in the room. Retail 50000. Keep it finite: it is what stops a runaway loop. Max 10,000,000."
onChange={(v) => number('compute', v)}
/>
<NumberConfig
label="Chips"
value={settings.chips}
hint="Room chip cap. Retail 3000. Max 1,000,000."
onChange={(v) => number('chips', v)}
/>
<NumberConfig
label="UnculledChips"
value={settings.unculledChips}
hint="Chips past this count get culled. Retail 500. Higher costs frame time."
onChange={(v) => number('unculledChips', v)}
/>
<NumberConfig
label="VisibleChips"
value={settings.visibleChips}
hint="Visible-chip limit. Retail 500. Above Chips, adding a chip also charges InkPerChip."
onChange={(v) => number('visibleChips', v)}
/>
<NumberConfig
label="InkPerChip"
value={settings.inkPerChip}
hint="Ink COST per chip against the fixed 30000-ink room cap. Retail 15; 0 = chips use no ink. Max 30,000."
onChange={(v) => number('inkPerChip', v)}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Cloud variables</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="CloudValues"
value={settings.cloudValues}
hint="Room cloud-variable cap. Retail 100. Max 10,000."
onChange={(v) => number('cloudValues', v)}
/>
<NumberConfig
label="CloudValuesAutosaveSeconds"
value={settings.cloudValuesAutosaveSeconds}
hint="Seconds between player save-data autosaves. Retail 300. Lower means more save traffic."
onChange={(v) => number('cloudValuesAutosaveSeconds', v)}
/>
</div>
</div>
</div>
</div>
);
}
function NumberConfig({
label,
value,
hint,
onChange,
}: {
label: string;
value: number;
hint?: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="label font-mono normal-case tracking-normal">{label}</span>
<input className="input mt-1" type="number" value={value} onChange={(e) => onChange(e.target.value)} />
{hint && <span className="mt-1 block text-[11px] text-ink-500">{hint}</span>}
</label>
);
}
@@ -1128,6 +1345,26 @@ function JsonConfig({
);
}
function defaultCircuitsLimits(): CircuitsV2LimitSettings {
return {
network: [{
numPlayersForActivation: 0,
bytes: 1_000_000_000,
bytesTimeWindowFrame60s: 60,
rpcs: 1_000_000_000,
rpcTimeWindowFrame60s: 1,
}],
compute: 500_000,
chips: 30_000,
cloudValues: 1_000,
cloudValuesAutosaveSeconds: 300,
unculledChips: 3_000,
visibleChips: 3_000,
inkPerChip: 0,
updatedAt: new Date().toISOString(),
};
}
function defaultGameConfigs(): DiscoveredGameConfigSettings {
return {
friendsPostGamePromptUnderFriendCount: 3,
+157
View File
@@ -139,6 +139,45 @@ in S3 is modified. Toggle endpoint:
`{"Disabled": bool}`. Clamped responses are served with a 60s edge TTL so
flipping the toggle lands within a minute.
## CircuitsV2 limits (`/settings`)
`ServerSettingsEntity.CircuitsV2LimitsJson` backs the
`UGC.CircuitsV2.Limit.*` gameconfig keys the client reads into
`RecRoom.CircuitsV2.Config.LimitConfig`. The panel's headline setting is
the network budget: retail allows a circuit **5 RPCs per frame** and
**1024 bytes per 60 frames**, and silently **drops** every send past
that, which is the usual cause of a busy CV2 room appearing to desync.
DorkNet ships both budgets at 1,000,000,000.
Two things about this are counter-intuitive enough to be worth stating:
- The budgets are spent over a rolling window measured in **frames**, so
widening a window makes the limit *stricter*, not looser. Raise the
budget, not the window.
- The **byte** budget is checked before the **RPC** budget, so raising
`Rpcs` on its own leaves circuits blocked. Both have to move together.
- `InkPerChip` is a **cost** charged against a 30000-ink room cap that is
hard-coded in the client, so lowering it is what gives circuits more
room. DorkNet ships it at 0.
The room-size limits ship at about 10x retail: Compute 500000, Chips
30000, CloudValues 1000, and UnculledChips/VisibleChips 3000.
CloudValuesAutosaveSeconds stays at 300.
Values are clamped on save, and most of the clamps are crash guards
rather than taste. `Rpcs` of 0 divides by zero in the client's CV2 debug
subscreen. A time window allocates one array slot per frame, so an
unbounded window is an out-of-memory kill at circuit init. Chips sets
the size of four list preallocations. InkPerChip × CloudValues is an
unchecked int multiply. Compute is the client's only runaway-loop
guard, so it has to stay finite. Full
reasoning and the disassembly it came from are in
`docs/recroom-2023-circuitsv2-limits.md`.
Endpoints: `GET`/`POST api/admin/v1/settings/circuits-limits`. The client
builds its rate limiter once when circuits start, so **players must
rejoin their room** before a change takes effect.
## Per-sub-room max players (`/rooms` → room detail)
Player caps are two separate settings, and conflating them is the easy
@@ -179,6 +218,124 @@ Enforcement caveat carried over from `RoomEntity.MaxCapacity`: this is the
advertised cap. The client never sets Photon `RoomOptions.MaxPlayers`, so
hard enforcement still needs a ClientMod Photon patch.
## RRO room data blob — Maker Pen and chips in a Rec Room Original (`/rooms` → room detail → Sub-rooms)
Baked Rec Room Originals (Rec Royale, Paintball, Laser Tag, …) are seeded
with an empty `RoomSceneEntity.DataBlobName` and empty
`RoomEntity.CurrentDataBlobName`. Every details builder and matchmaking
resolver (`RoomsController.CurrentOrSyntheticDataBlobName`,
`GoToController.ResolveInitialDataBlobAsync`,
`MatchPlayerController`) turns that into an empty `DataBlob`, and the 2023
client reads an empty blob name as "this room has no room data": it skips
the persistence download entirely, so there are no room roles (no Maker
Pen despite the CDN's RRO role overlay), no CV2 room-settings chips, no
room mood engine and no saves. Real Rec Room served these rooms a blob —
Rec Royale's game-rule chip (`RecRoyaleSolos_GameRuleWrapperCircuit` /
`RecRoyaleSquads_GameRuleWrapperCircuit`, prefabs in `resources.assets`)
is delivered that way; the Frontier scene has no baked game rule.
The Sub-rooms tab shows, per sub-room, the blob the client will actually
be told to download (`effectiveBlobName`) and, for baked originals, two
actions:
- **Enable Maker Pen blob** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable`.
Stamps the synthetic default name (`room_{id}_dorknet_v8.dat` for the
entry scene, `room_{id}_dorknet_v8_sub{n}.dat` otherwise) on the scene
and, for sub-room 0, on the room. No resolver changes are involved: they
already return a non-empty name verbatim, and `CdnController` already
answers that name for AG rooms with the RRO-editable blob (permissive
room roles, no objects) while nothing is saved. `IsRRO` is derived from
`IsAGRoom`, so the client keeps loading the baked map and only
additionally fetches the blob. Idempotent; a no-op on a sub-room that
already has a blob. Seeded originals such as Rec Royale have **no
`RoomScenes` row at all** ("Scenes 0"); the list synthesises a virtual
"Home" entry (`isVirtual: true`, max players = room capacity) and enable
creates the real row with the game-side defaults
(`RoomsController.GetOrCreateSceneForMutationAsync`).
What the CDN hands back for that name while nothing is saved is a
**per-room stub** built by `RoomDataBlobService.BuildRroEditableBlob`:
the skeleton of a real save (Rec Center's `room_100_v1.dat` was the
reference) — `version` 16 (the client's ceiling; the old roles-only stub
was stamped 19 and the client fetched it and silently loaded the room
with no roles, i.e. no Maker Pen), `activity_id` = the room's
`LocationReplicationId`, `last_save_time`, empty connectable graph /
object model, scene settings and the four `room_mood_*` blocks, plus the
permissive role collection. The miss path now runs the same 2023
version clamp as the S3-hit path. It also carries the room's own three
**game-role objects** (a default role named after the room, plus the
built-in "In-Game" and "Eliminated" roles, well-known GUIDs shared by
every room save — Rec Center and Stunt Runner both have exactly these).
The 2023 client grants Maker Pen off the player's *game* role
(`ICreatorRole.CreatorRoleCanUseMakerPen` on `GameRole`), so a blob with
no role objects leaves even a co-owner with nothing to hold — that was
the second reason "enable + co-owner" still showed no Maker Pen. The
default role sets `can_use_maker_pen` to true, matching the permissive
room-role overlay; the built-in roles mirror the reference saves.
For the two Rec Royale rooms the stub also carries the room's
**game-rule chip** (`RecRoyaleSolos_GameRuleWrapperCircuit` prefab id
`983db81a…`, `RecRoyaleSquads_…` `857f7500…`, read from the client's
spawnable-tool table; a saved Squads chip is header + prefab id + tag +
node id + an empty wrapper payload), so the match logic exists on first
join without anyone spawning it by hand.
- **Upload save** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload?mode=full|basics`,
multipart `file` = a real `PersistedRoomData` save from ANY client
version (`.binpb` / `.room` / `.dat`, e.g. a RecNet export of a custom
Rec Royale room). The server rewrites it for this room
(`RoomDataBlobService.PrepareUploadedRoomBlob`): `activity_id` → the
room's `LocationReplicationId`, drops `output_log`, `sub_room_id` and
every field the 2023 schema doesn't know, clamps the header versions to
the 2023 maxima, and in `basics` mode keeps only the game-role (field 50)
and game-rule (fields 40/41) objects while stripping the circuit payloads
(`circuit_data`, connectable graph, `circuit_v2_data` — a newer
CircuitsV2 graph is rejected by the client's own CV2 version gate no
matter what). Stored under a fresh `room_{id}_upload_*.dat`, recorded in
`RoomDataBlobs`, and the sub-room (and room, for sub-room 0) is pointed
at it. On serve the CDN applies the RRO role overlay and clamp exactly
as for Rec Center's saved blob. Response carries a `summary`
(`viewsIn/viewsKept/roleViews/gameRuleViews/versionIn/versionOut`).
- **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`.
Clears both pointers. This is the escape hatch if a save left the room
unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
list can bring one back later.
Both are audited (`enable_subroom_blob`, `reset_subroom_blob`). Who can
then save the room in game is the normal rule in `SaveDataCore`: owner,
any admin, or an accepted co-owner (`RoomRoles.Role == 0`). Grant a
co-owner role from the Roles tab if a non-admin should be able to save.
Procedure that makes Rec Royale playable AND editable:
1. Enable the blob on the Solos room's sub-room 0 (and the Squads room's).
2. Join as an admin; the Maker Pen is available. Spawn the room's game-rule
chip (`RecRoyaleSolos_GameRuleWrapperCircuit` in Solos,
`RecRoyaleSquads_GameRuleWrapperCircuit` in Squads) — via the palette's
Game Rules section, or the debug console's `DebugSpawnTool` if it is not
listed — then save. Without that chip there is no `BattleRoyaleManager`
in the scene and the match never starts.
3. From then on the saved blob (with the game-rule chip and whatever
room-settings chips were placed) is what everyone loads. If a save
breaks the room, Reset to baked scene.
Tests: `RroRoomBlobTests`.
### Why Rec Royale still has no Maker Pen after all of the above
The Maker Pen permission check (`AGMBHDNGOEH.IJEIEBGJJLB`) first runs a
room precondition (`LALJEMHEIBH`) that, via `MLKAFHHHEGA.GHPIBJPNOFF` →
`PNPLLCBBMJP.JGPBNEHFLJA`, looks the current room up in
`AGRoomRuntimeConfig` — a table **baked into the client's
`resources.assets`** — and returns a per-original boolean. Rec Center and
Stunt Runner allow creation there; Rec Royale does not. No blob, role or
server response changes that table, and it is consulted only for rooms the
client considers originals (`IsRRO` = our `IsAGRoom`).
Server-side experiment: clone Rec Royale, flip the clone's **Rec Room
Original (IsRRO)** checkbox off on the General tab
(`rooms/{id}/props` `IsAGRoom: false`), upload a real save to it, join. If
the client still loads the Frontier scene for a non-original, Rec Royale
becomes an editable template; if the scene fails to load, the table gate
has to be bypassed client-side (ClientMod postfix on `GHPIBJPNOFF`).
## Test cases ↔ GitHub issues
QA test cases can file and track GitHub issues. Configure with:
+3 -1
View File
@@ -55,7 +55,9 @@ DorkNet.sln
watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex),
Photon AppId override, and TLS trust bypass. It loads under
MelonLoader 0.6.x and reads its settings from a JSON config under
`MelonLoader/UserData/`.
`MelonLoader/UserData/`. Opt-in extras (all off by default) live in the
same config: Desktop Screen-Share FPS, RRO quest party size, and Rec
Royale night mode (see the README and `recroom-2023-scene-moods.md`).
---
+255
View File
@@ -0,0 +1,255 @@
# 2023 client CircuitsV2 limits (`UGC.CircuitsV2.Limit.*`)
How the March-2023 client budgets CircuitsV2, and how DorkNet lifts the
network budget so circuits stop dropping sends. Reverse-engineered from
`RecRoom.CircuitsV2.Config.LimitConfig` in the 2023-03-21 ISIL dump
(`IsilDump/Assembly-CSharp/RecRoom/CircuitsV2/Config/LimitConfig.txt`)
and the limiter chain under `IsilDump/Circuits.V2/`.
## Why it matters
Retail budgets a circuit **5 RPCs per frame** and **1024 bytes per 60
frames**. A circuit that exceeds either budget does not queue or slow
down — the send is **dropped on the floor** and an execution error of
kind 2 (bytes) or 3 (RPCs) is raised, and the room save gets flagged
`cv2_rpc_limited` / `cv2_net_limited`. This is what makes busy CV2 rooms
feel like they're desyncing at random.
DorkNet serves both budgets at 1,000,000,000, which is more than a
circuit can spend in a frame. It also raises the room-size limits
(compute, chips, cloud variables and culling) to about 10x retail.
[The scalar limits](#the-scalar-limits) covers what each one does and
why its value stops where it does.
## The keys
The client reads these through its gameconfig provider, i.e. the array
served by `GET api/gameconfigs/v1/all`. Any key the server omits — or
sends unparseably — silently falls back to a value baked into the
client, with no error logged.
| Key | Type | Client default | DorkNet default |
| --- | --- | --- | --- |
| `UGC.CircuitsV2.Limit.Network` | JSON array | one entry, below | budgets lifted |
| `UGC.CircuitsV2.Limit.Compute` | int | 50000 | 500000 |
| `UGC.CircuitsV2.Limit.Chips` | int | 3000 | 30000 |
| `UGC.CircuitsV2.Limit.CloudValues` | int | 100 | 1000 |
| `UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds` | float | 300 | 300 |
| `UGC.CircuitsV2.Limit.UnculledChips` | int | 500 | 3000 |
| `UGC.CircuitsV2.Limit.VisibleChips` | int | 500 | 3000 |
| `UGC.CircuitsV2.Limit.InkPerChip` | int | 15 | 0 |
The client defaults are the values `LimitConfig..ctor` bakes in (the
`mov dword ptr [rbx+28h..40h]` run in `LimitConfig.txt`). For comparison,
the 2023-06-21 build raised its `COMPUTE_DEFAULT` to 100000 and left the
others unchanged.
Scalars are parsed with `int.TryParse` / `float.TryParse` under
`NumberStyles.Integer` / `NumberStyles.Float` and
`CultureInfo.InvariantCulture` — so no thousands separators, and a dot
decimal separator only.
## The network array
`UGC.CircuitsV2.Limit.Network` is a **top-level JSON array**, parsed by
Newtonsoft (`JsonConvert.DeserializeObject`), not Unity's `JsonUtility`.
Newtonsoft binds public fields and falls back to case-insensitive
matching, but DorkNet emits the client's own field names verbatim:
```json
[{"NumPlayersForActivation":0,"Bytes":1000000000,"BytesTimeWindowFrame60s":60,"Rpcs":1000000000,"RpcTimeWindowFrame60s":1}]
```
The retail value is the same shape with `Bytes` 1024, `Rpcs` 5.
**Entry selection.** The client sorts entries ascending by
`NumPlayersForActivation` and activates the highest one whose threshold
is at or below the room's **max player capacity** (not the live player
count). One entry at threshold 0 therefore applies everywhere.
**Validation.** The client rejects the whole array — falling back to the
retail budget — if it is not valid JSON, is empty, or has no entry with
`NumPlayersForActivation` of 0. `ServerSettingsService` normalizes
around all three: it sorts, de-duplicates thresholds, and rewrites the
lowest entry's threshold to 0.
## The scalar limits
Every getter is consumed in exactly the places listed here. A sweep of
the whole ISIL dump for `LimitConfig.get_` finds only these files.
### Compute: per-frame CPU budget
`CircuitsV2Manager`'s per-frame tick (`OCGDEIJJBAG(float)`, ISIL 240 at
`CircuitsV2Manager.txt:21241`) builds a fresh compute context every
frame through `DIILFAPPLOI` (ISIL 018 at `:12637`), which wraps the value
in `Circuits.V2/BENFNKBKHEA`. That object keeps four int counters. Each
charge adds to one of them and checks the sum against the budget with
`<=`. The default costs come from the `MPKGOFPJCOP` interface: 100, 100,
50 or 500 depending on the kind of operation. When the budget runs out,
circuit work stops for the rest of the frame and the save is flagged
`cv2_cpu_limited`.
Nothing is allocated from this value. **It is the only guard against
runaway loops**, though. Raising it lets a broken loop burn more frame
time before it is stopped. A budget near `int.MaxValue` would let the
summed counters wrap negative so the check never fires. DorkNet serves
500,000, which is 10x retail and 5x the June 2023 build. That allows
1,000 to 10,000 operations per frame, depending on their kind.
### Chips: room chip cap
`CreationManager.OnCircuitsV2LimitsRefreshed` (`CreationManager.txt:3179`)
rebuilds the room's max sandbox resources as
`CostInfo(ink 30000, lights 64, ai 101, numChips = Chips, numCloudVariables = CloudValues)`
(ISIL 050-068). Adding a chip checks `CanAffordResourceCost(OneChip)`
against it. A failed check shows the "Too many chips." toast
(`CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt` ISIL 164-187, 592).
Hazard: the chip-culling manager's constructor preallocates **four**
`List<Chip>(Chips)` (`PAFDMBHNMBP.txt` `.ctor` ISIL 053-108). That costs
32 bytes per unit of the limit whether or not the room uses it, and a
value of 0 or less throws. The clamp is 1..1,000,000, which is 32MB at
the top. DorkNet serves 30,000, which preallocates under 1MB.
### CloudValues: room cloud-variable cap
This is the `numCloudVariables` field of the same max-resources
`CostInfo`. `CreationManager.CalculateActiveResourceCost` also reads it
(ISIL 389-415 at `CreationManager.txt:6136-6162`):
```
inkCost += InkPerChip * Math.Min(numChips, CloudValues)
```
That is an unchecked int multiply. It also means that raising
CloudValues while InkPerChip stays at retail's 15 makes chips use
**more** room ink. Serving InkPerChip 0 removes the term. The clamp was
lowered from 100,000 to 0..10,000 so the product stays at or below 3e8
even with InkPerChip at its own ceiling. DorkNet serves 1,000.
### InkPerChip: ink cost per chip (lower is more)
This is a **cost**, not an allowance. It feeds the
`CalculateActiveResourceCost` term above. The add-chip path also charges
it directly through `CanAffordResourceCost` ("Not enough ink.",
`AMEJFMPHNEO` ISIL 247-265). That direct charge only runs when
`ShowGadgetsToEveryone` is set and `Chips < VisibleChips` (ISIL 221-234),
so with the defaults below it never runs.
The room's ink cap is a **hard-coded 30000** (`0x7530`,
`CreationManager.txt:3374`), not a gameconfig key. Lowering this cost is
therefore the only way to leave circuits more room under that cap. No
client code divides by it. DorkNet serves **0**, so chips use no room
ink. The clamp is 0..30,000, because past 30000 no single chip can be
afforded.
### UnculledChips and VisibleChips: chip culling
The chip-culling manager reads both together (`PAFDMBHNMBP.DJLEFJCOLCF`,
ISIL 079-087 at `PAFDMBHNMBP.txt:2520-2525`). It subtracts
`UnculledChips` from the live chip count and culls that many chips.
`VisibleChips` is used in the same arithmetic further down. Both values
only appear in counts and subtractions: they allocate nothing and are
never divisors. Raising them costs rendering time, not stability.
DorkNet serves 3,000 for both, so every chip in a retail-sized room stays
drawn. Both stay at or below Chips, which keeps the `Chips < VisibleChips`
ink charge above switched off.
### CloudValuesAutosaveSeconds: save cadence
A `CircuitsV2Manager` coroutine reads this value
(`CircuitsV2Manager_NestedType_CDPKCHNIHFG.txt` ISIL 059 at `:193`),
waits that many seconds, then flushes the CircuitsV2 player save-data
manager. It sets how often saves happen, not how much a room can hold. A
shorter interval sends more save traffic to a shared server. A longer
one loses more progress if the client crashes. DorkNet leaves it at
retail's 300 seconds. The clamp stays at 1..3600 and rejects non-finite
values.
### Limits these keys don't raise
- **Room ink 30000, lights 64 and AI controllers 101** are literals in
the max-resources `CostInfo` (`CreationManager.txt:3374`, and again in
the static constructor at `:22761`).
- **Event recursion depth is fixed at 16.**
`Circuits.V2/BCNIMLFCMMD.MENMKOOAJPF` (ISIL 041,
`Compare [rbx+240], 16`) raises "Max recursion depth reached. Do you
have events in a loop?"
- **Room save uploads are capped at 100,000,000 bytes on the server.**
`StorageController.Upload` sets `[RequestSizeLimit]` and
`MultipartBodyLengthLimit` to 100 MB, up from Kestrel's 30 MB default
that a room with tens of thousands of chips could reach. 100 MB is
also Cloudflare's per-request ceiling, so raising it further does
nothing behind the tunnel.
## How the budget is enforced
Each budget is a sliding window with **one bucket per rendered frame**.
`RpcTimeWindowFrame60s` is that window's length in frames, so 1 means
per frame and 60 means roughly per second at 60fps. On each send the
client adds the cost to the current bucket and compares the window's
running sum against the budget.
Two consequences worth keeping in mind:
- **Widening a window makes the limit stricter, not looser** — the same
budget now has to cover more frames. Raise the budget instead.
- The **byte budget is checked before the RPC budget**, so lifting
`Rpcs` alone leaves circuits blocked on `Bytes`. Both have to move.
Enforcement is entirely client-local: each client reads its own
gameconfig and polices its own outgoing sends, and nothing validates
budgets on receive. Photon's own server-side message-rate and bandwidth
caps still apply underneath this and are not affected by these keys.
## Clamps, and why they exist
`NormalizeCircuitsV2Limits` clamps admin input. Most of the clamps are
crash guards read straight off the disassembly:
- **`Rpcs` is floored at 1.** The CircuitsV2 debug subscreen renders
`RpcRecentMax / Rpcs` with a hardware integer divide, so a budget of 0
is a divide-by-zero the moment that panel opens.
- **Time windows are capped at 3600 frames.** A window allocates
`new int[N]` — one bucket per frame — when circuits start, so an
unbounded window is an out-of-memory kill rather than a bigger budget.
- **`Chips` is capped at 1,000,000 and floored at 1**, because it sets
the size of four list preallocations in the chip-culling manager.
- **`InkPerChip` is capped at 30,000 and `CloudValues` at 10,000**,
because the ink calculation multiplies them as unchecked ints.
- **`Compute` is capped at 10,000,000.** It allocates nothing, but it is
the runaway-loop guard, and a value near `int.MaxValue` lets its
counters wrap past the check.
Budgets themselves are capped at 1,000,000,000: the client only ever
compares a counter against them, and this leaves 2x headroom under
`int.MaxValue`.
## Operational notes
- The client builds its rate limiter **once, when circuits start**.
Changing these settings does not affect anyone already in a room —
players have to rejoin.
- Raising `Rpcs` also changes the Maker Pen top bar's network readout,
which renders `<RpcRecentMax> / <Rpcs>`; the ratio collapses toward 0
and the bar effectively tracks byte usage only.
- The keys are additive to the gameconfig array, so older clients that
know nothing about CircuitsV2 just ignore them.
- Defaults are applied when the settings are read. While the
`CircuitsV2LimitsJson` column is empty (no admin has saved the panel),
the server always serves the current defaults. Saving the panel
stores the full record, so every value sticks from then on, including
the ones the admin didn't touch. **Reset** in the panel restores the
current defaults.
## Where this lives
| Concern | File |
| --- | --- |
| Settings record, defaults, clamps, key emission | `DorkNet.Server/Services/ServerSettingsService.cs` |
| Persisted column | `ServerSettingsEntity.CircuitsV2LimitsJson` |
| Admin API | `api/admin/v1/settings/circuits-limits` (GET/POST) |
| Admin SPA | `admin-ui/src/pages/Settings.tsx` → CircuitsV2 limits panel |
| Served to the client | `api/gameconfigs/v1/all` |
| Tests | `DorkNet.Server.Tests/CircuitsV2LimitsTests.cs` |
+68
View File
@@ -132,6 +132,74 @@ role-holder (co-owner/mod/host), nor an instance invitee return
`errorCode=4` (RoomDoesNotExist) so private rooms don't leak their
existence. Tests: `PrivateRoomInstanceTests.cs`.
## Baked Rec Room Originals and data blobs (Rec Royale)
Baked RRO rooms are seeded with empty `DataBlobName` / `CurrentDataBlobName`,
which the details builders and matchmaking resolvers pass through as an
empty `DataBlob`. The 2023 client treats that as "no room data": it never
downloads a blob, so the room has no room roles (no Maker Pen even though
the CDN overlays permissive roles for AG rooms), no CV2 room-settings
chips, no mood engine and nothing to save into. `IsRRO` (= `IsAGRoom`) is
independent of the blob, so a baked room CAN carry one: the client loads
the baked map from its own assets and layers the blob's persistence views
on top — exactly how Rec Center behaves.
Rec Royale needs this to be a game at all: the Frontier scene has no baked
game rule (contrast `PaintballTeamBattle_GameRuleWrapperCircuit`, which is
baked into the paintball scene). Its rule is the Maker Pen chip
`RecRoyaleSolos_GameRuleWrapperCircuit` / `RecRoyaleSquads_GameRuleWrapperCircuit`
(`BattleRoyaleGameRuleWrapper : GameRuleCircuitWrapperTool`), which real
Rec Room delivered inside the room blob. Without it there is no
`BattleRoyaleManager` in the scene.
Stub shape matters. Until something is saved the CDN synthesises the blob,
and the client fetches it through `RecNet.Rooms.GetRoomData` and rejects
one it deems unusable — a clone surfaces that as the bare "Failed to copy
room", a baked room as a silent load with no room roles (no Maker Pen).
Compared against Rec Center's working `room_100_v1.dat`, the old stub was
stamped `PersistedRoomVersion` 19 (client ceiling 16; only the S3-hit path
clamped) and lacked the save skeleton (`last_save_time`, `activity_id` =
LocationReplicationId, connectable graph, scene settings, object model,
`room_mood_*`). `RoomDataBlobService.BuildRroEditableBlob` now emits that
skeleton per room at version 16.
Second requirement — game-role objects. Both reference saves carry three
`PersistenceViewData` entries whose `game_role_node_data` (field 50) holds a
`PlayerGameRoleData`: the room's default role (Rec Center: "Rec Center",
Stunt Runner: "StuntRunner"; GUID `c48bac02-…` in both), "In-Game"
(`role_id` 2097152, GUID `2c721342-…`) and "Eliminated" (`role_id`
4194304, rank 1015, `can_move` false, GUID `0b9ec39e-…`). Their
`spawnable_tool_data.prefab_id` is the GameRoleNode chip prefab
(`c46e1535…` raw). Since 2023 permissions hang off the player's GameRole,
a blob without these leaves a co-owner with no Maker Pen. The stub now
generates Rec Royale's own set (view ids derived from the activity id).
Game-rule chip. A newer-client export of a custom Rec Royale room showed
the saved `RecRoyaleSquads_GameRuleWrapperCircuit` chip is minimal:
`spawnable_tool_data.prefab_id` `857f7500…`, `tagged_tool_data` tag
`recroyalesquadsgameruleci` (prefab name lower-cased, 25 chars),
`circuit_node_data.id`, and an EMPTY `game_rule_wrapper_data` (field 41).
Prefab ids live in the client's spawnable-tool table in
`resources.assets`: after `SpawnableTool_Runtime_<ENUM>` comes a 20-byte
hash (len-prefixed), two u32s, then the 16-byte prefab id (len-prefixed),
then the enum again, display name and tag. Extracted:
Solos `983db81a68927f419edb0daf1243e93e`, Squads
`857f750016ca77408ae9f72f9877748a` (matches the save), StuntRunner
`28276d92470ff34ab05f72b2707a04d1`. Stunt Runner's other rule-ish object
(`84eb8434…`, `game_configuration_data` field 40) is the generic Game
Configuration chip, optional — `GameConfigurationTool` falls back to its
baked asset. The stub emits the wrapper chip for the two Rec Royale rooms.
Attaching a real save: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/upload`
(multipart `file`, `?mode=basics|full`) rewrites any-version
`PersistedRoomData` for the room — see `docs/admin.md` → "Upload save".
Used to A/B a generated stub against a genuine custom-Rec-Royale save.
Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable`
stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and
room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data
blob" for the full procedure (enable → spawn the game-rule chip → save).
## Related 2023 quirks fixed alongside
- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls
+223
View File
@@ -0,0 +1,223 @@
# Rec Room 2023-03-21 — scene moods (sky dome / sun / fog) and Rec Royale night mode
Client-side reference for the room "mood" engine the March-2023 client uses
for Maker Pen sky/sun/fog settings, and how `DorkNet.ClientMod`
(`RecRoyaleNight.cs`) reuses it to give Rec Royale a night variant the game
never shipped. Everything below was read from the Cpp2IL ISIL dump
(`C:\tmp\recroom-2023-03-21-isil`), the interop stubs
(`C:\tmp\recroom-2023-03-21-decompiled`) and the built game data
(`RecRoom_Data\resources.assets`, `globalgamemanagers`).
## Ownership chain
| Object | Where | Notes |
|---|---|---|
| `RecRoomSceneManager` | `MonoBehaviourPun`, one per room scene | static `get_Instance` (interop property name obfuscated: `PDCOCJBICGC`); name-preserved field `sceneMoodSettings` |
| `RecRoom.Core.Scene.SceneMoodSettings` | plain class held by the scene manager | initialised by `PCJGOACJEDL(RecRoomSceneManager)` at scene start |
| `RecRoom.Core.Creation.SceneMoods.MoodSetting` | `ScriptableObject` asset | `FriendlyName`, `SkyBoxSettings`, `SunSettings`, `FogSettings`, `BloomSettings`, `ColorGradingSettings`, `BackgroundObjectsSettings`, `VolumeProfile`, `BakedReflection`, `guid` |
`SceneMoodSettings` name-preserved members: `availableMoods`
(`MoodSetting[]`), `defaultMood`, `CurrentMood`, `PrevMood`,
`CurrentRoomMoodIndex`, `SupportsMoodCircuits`, `HasRecRoomSkyboxShader`,
`HasSunSource`, `Volume`, `DefaultVolumeProfile`, `ReflectionIntensity`,
`AmbientLightIntensity`, plus one `PFFMDAJPHJO<T>` blender per settings
block (`SkyBoxMoodSettingsBlender`, `SunMoodSettingsBlender`, …).
## Apply path (what actually changes the picture)
`SceneMoodSettings.MLLBALMKEGJ(MoodSetting)` — ISIL
`RecRoom/Core/Scene/SceneMoodSettings.txt:3025-3581`:
1. `ALCMNGKOEBK()` — pushes the mood's six settings blocks into the blenders
as the "top" data (6× generic blender set call).
2. `OEKNGEMDMDB(VolumeProfile)` — swaps the URP post-processing profile.
3. `RenderSettings.set_skybox` — the shared Rec Room skybox material; the
`SkyBoxMoodSettings` blender then writes `_SkyTopColor`-style shader
props into it (`SkyBoxMoodSettings.FPKCBLPBOFN(Material)`).
4. `RenderSettings.get_sun` → re-aims/recolours the sun (`SunMoodSettings`,
only when `HasSunSource`).
5. `ReflectionProbe.set_customBakedTexture` ← `MoodSetting.BakedReflection`.
6. 6× blender force-update.
It does **not** write `CurrentRoomMoodIndex`, so calling it is local only.
### RRO scenes: the engine starts disabled
`SceneMoodSettings.isEnabled` (+0x38) gates everything: `get_CurrentMood`
returns null while it is false, `get_RoomMoodsEnabled` is
`isEnabled && defaultMood != null`, and nothing ticks. The flag is only
switched on by the room data-blob loader (`EIMFCPIHEPF.JPKEMACDNCF`, ISIL
`EIMFCPIHEPF.txt:2136-2310`), which custom rooms run and RRO scenes (no
data blob) never do. Its bring-up sequence:
| Call | Meaning |
|---|---|
| `LKIKMLNPCFK(false)` | SetEnabled(false): applies a null mood (blenders reset, default VolumeProfile back) and toggles `BackgroundObjectsRoot` |
| `LIEDFOLBGHL()` | Capture: builds the blenders' "previous" data from `RenderSettings.skybox` / `sun` / fog; sets `hasRecRoomSkyboxShader` = skybox shader name `== "Custom/SkyBox"` |
| `LKIKMLNPCFK(true)` | SetEnabled(true) |
The apply step then swaps `RenderSettings.skybox` to the global
`MoodConfig` skybox material (`LNMDIIFOFPD.NHAEHKJINFH`, +0x18).
The ClientMod replays this exact sequence when it finds `isEnabled` false.
**The apply step only sets blend targets.** The per-frame update
`CEKLEBHDAHA` (ISIL :3895-4031) is what actually writes sky colours,
rotates/recolours the sun, blends fog and background objects and (via
`CPJBHLMCBBC`) pushes bloom/colour grading into the Volume — and it
returns immediately while `get_CurrentMood()` is null. `get_CurrentMood`
(ISIL :297-457) returns null when `isEnabled` is false, else
`_studioRoomUnityMood` if set, else `availableMoods[index]` when the
synced index is set, else `defaultMood`. RRO scenes have `defaultMood`
null and `availableMoods` empty, so even a fully enabled engine with a
successful apply shows only the ungated background objects (moon, stars).
The ClientMod therefore writes the night mood into `defaultMood` and a
one-element `availableMoods` before enabling the engine.
Two flags captured by `LIEDFOLBGHL` gate what the apply step and the
per-frame update are allowed to touch:
| Flag | Set by capture from | Gates |
|---|---|---|
| `hasRecRoomSkyboxShader` (+0xA8) | `RenderSettings.skybox.shader.name == "Custom/SkyBox"` | sky colour blender apply |
| `hasSunSource` (+0xA9) | `RenderSettings.sun != null` | sun direction / colour / intensity |
Note the sun flag is nested inside the sky check: `hasSunSource` can only
become true when the skybox shader test already passed (ISIL :2629-2705).
`RecRoyale_Frontier` fails both at capture time: its skybox is not the
mood shader. The observable result of a naive apply is exactly "moon and
stars appear, sky and sun unchanged" — the mood's
`BackgroundObjectsSettings` are not gated.
Worse, the apply step's own skybox swap does not help in RRO scenes: it
assigns `MoodConfig.SkyBoxMaterial`, and that material
(`Skybox_Moods_Sunny_Default_Mat`, sharedassets77.assets — a custom-room
scene's shared assets) is not loaded there, so `RenderSettings.skybox`
becomes null (plain black sky) and both flags stay false forever. The
`Custom/SkyBox` shader itself is in resources.assets, so
`new Material(Shader.Find("Custom/SkyBox"))` works anywhere.
Workarounds used by the ClientMod: assign the scene's directional `Light`
to `RenderSettings.sun` if unset; apply once; then put a material on the
`Custom/SkyBox` shader into `RenderSettings.skybox` and
`MoodConfig.SkyBoxMaterial`; capture again so both flags flip true; then
apply for real. Post-exposure additionally needs the scene `Volume`
enabled with weight 1 and `UniversalAdditionalCameraData.renderPostProcessing`
on (`RecRoyaleNightForcePostProcessing`). `LILOMACBFPI()` (:2789-2817) force-sets
both flags to true (writes 0x0101 at +0xA8) and clears
`_studioRoomUnityMood`; it is the Studio path and not used here because
a true `hasSunSource` with a null sun would NRE in the update loop.
Fog: `FogMoodSettings.NLDDIDEIEFH()` reads `RenderSettings.fog`,
`fogStartDistance`, `fogEndDistance`, `fogColor` into the block;
`KGBEBONMIII()` writes them back. Swapping `MoodSetting.FogSettings` for
a freshly captured block before the first apply keeps the scene's fog
(`RecRoyaleNightFog: false`). The block exposes only `color`,
`startDistance`, `fadeDistance` (no enabled flag), so "fog off"
(`RecRoyaleNightDisableFog`) pushes both distances to 1e6 and forces
`RenderSettings.fog = false` after each apply.
Frontier's daytime clouds are scene meshes, not mood background objects:
`CloudPlane` ×19 under `Clouds`, static-batched as
`StaticObjectsStaticBatch_Cloud_RecRoyale_Frontier_Mat` (level106).
`RecRoyaleNightHideObjects` disables renderers whose object or
`sharedMaterial` name contains a pattern (default `Cloud`).
Brightness: the mood blocks are plain data the ClientMod edits in place
before the apply (originals restored on scene exit / toggle-off):
| Block · field | Applied as | ClientMod knob |
|---|---|---|
| `ColorGradingMoodSettings.exposure` | `ColorAdjustments.postExposure` via `VolumeParameter<float>.Override` in `KGBEBONMIII` | `RecRoyaleNightExposure` (EV offset) — the only knob that dims baked lightmaps |
| `SunMoodSettings.sunIntensity` | `RenderSettings.sun.intensity` (needs `hasSunSource`) | `RecRoyaleNightSunScale` (×) |
| — | other directional `Light`s (never touched by the engine; Frontier's `RenderSettings.sun` is `SkyboxLight`, the sun-disc light, not the key light) | `RecRoyaleNightMatchSunLights`: copy the driven sun's rotation/colour, intensity × `RecRoyaleNightSunScale` |
| `SkyBoxMoodSettings.ambientLightIntensity` | `RenderSettings.ambientIntensity` | `RecRoyaleNightAmbientScale` (×) |
Other `ColorGradingMoodSettings` fields: `color`, `temperature`, `tint`,
`hueShift`, `saturation`, `contrast`, `colorFilterIntensity`.
`BloomMoodSettings`: `color`, `diffusion`, `intensity`, `threshold`.
### Networked path (do not use for a client-side effect)
`SceneMoodSettings.IPAMPMBDPNB(Guid)` scans `availableMoods` for the GUID
and calls `set_CurrentRoomMoodIndex`. `_currentRoomMoodIndex` is a
`DFAOKLDJPEL<int>` from `RecRoom.Networking.SynchronizedFields.Runtime`,
i.e. a synchronized field that replicates to everyone in the instance.
CircuitsV2 room-settings chips (`RecRoom.CircuitsV2.RoomSettings.RoomSkydome`,
`RoomSun`, `RoomFog`, all `RoomMoodBase<T,U,V>`) drive the same engine
through `ModifyMood(...)` and are also networked.
## Shipped mood assets
All live in `resources.assets` and are registered in the `globalgamemanagers`
ResourceManager table under their lower-cased asset name (no folder), so
`Resources.Load("<name>", typeof(MoodSetting))` works.
| Asset | `FriendlyName` |
|---|---|
| `Night_Calm_Outdoor_Mood` | Calm Night |
| `Night_Spooky_Outdoor_Mood` | Spooky Night |
| `Night_Wild_Outdoor_Mood` | Wild Night |
| `Night_StuntRunner_Outdoor_Mood` | (Stunt Runner night) |
| `OuterSpace_Mood` | Outer Space |
| `Sunset_Cozy_Outdoor_Mood` | Cozy Sunset |
| `Sunset_Burning_Mood` | Burning Sunset |
| `Sunset_Tropical_Outdoor_Mood` | Tropical Sunset |
| `Sunrise_Misty_Outdoor_Mood` | (misty sunrise) |
Reflection cubemaps ship alongside (`Moods_WildNight_ReflectionProbe`,
`Moods_SunnyDay_Skybox_Reflection_Baked`, …) and are referenced from each
`MoodSetting.BakedReflection`.
## Rec Royale specifics
* Only one Rec Royale map in this build:
`Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity`
(Unity scene name `RecRoyale_Frontier`). The `"recroyale"` literal in
`BattleRoyaleManager` is only `get_RuleTypeAutoTag`, not a scene name.
* `BattleRoyaleManager` has no static instance accessor; scene-name
matching via MelonLoader `OnSceneWasLoaded` is the detection choke point.
* Weapon flashlights already exist: `WeaponFlashlightController` (fields
`weapon`, `flashlightPrefab`, `flashlightRoot`) spawns
`WeaponFlashlight` when `get_FlashlightAllowed` is true. That getter
(ISIL `WeaponFlashlightController.txt:3-75`) is
`Matchmaking.FBELDNLEKAC() != null && [obj+0x28] == 15` — a game-mode
gate for a different activity. Interop property name: `KIMMMCCKMJL`.
## Real CV2 mood chips in an RRO scene
The Maker Pen chips ("Skydome Constant", "Room Skydome Modify/Reset", and
the Sun / Fog / Background Objects trios) drive `RoomSkydome`, `RoomSun`,
`RoomFog`, `RoomBackgroundObjects` (`RecRoom.CircuitsV2.RoomSettings`,
all `RoomMoodBase<T,U,V>`), which hang off `RoomElementSettingsManager` (a
`NetworkedSingletonMonoBehaviour` that `CircuitsV2Manager` brings up in
`MPDIMHOMIDP`). Their state persists in the room blob
(`PersistedRoomData` fields 32-35: `room_fog_data`, `room_mood_sun_data`,
`room_mood_background_objects`, `room_mood_skydome`). None of that runs in
a room the client believes has no data blob, so the prerequisite for chips
in Rec Royale is server-side: give the room a blob (`docs/admin.md` → "RRO
room data blob"). The ClientMod probe line
`[royale-night] CV2 probe in 'RecRoyale_Frontier': …` logs instance counts
for `CircuitsV2Manager`, `RoomElementSettingsManager`, `RoomSkydome`,
`RoomSun`, `RoomFog`, `RoomBackgroundObjects` and `MakerPen` so the effect
of the blob can be verified in one join.
## How the ClientMod uses this
`DorkNet.ClientMod/RecRoyaleNight.cs`:
1. `OnSceneWasLoaded` → scene name contains any `RecRoyaleNightScenes`
entry (default `RecRoyale`) ⇒ arm.
2. `OnUpdate` every 30 frames: `RecRoomSceneManager.Instance.sceneMoodSettings`
present and `CurrentMood` non-null (engine initialised) ⇒
`Resources.Load(RecRoyaleNightMood, MoodSetting)` ⇒
`MLLBALMKEGJ(mood)`. Re-asserted every ~10 s (the engine re-applies its
own mood on quality-level changes).
3. Hotkey (`RecRoyaleNightToggleKey`, a `UnityEngine.KeyCode` name) toggles;
off re-applies `CurrentMood`, i.e. the scene's own default.
4. Harmony postfix on `get_FlashlightAllowed` returns `true` while active
and in a Rec Royale scene (`RecRoyaleNightFlashlights`).
Limits: baked lightmaps are untouched (interiors keep their daytime bake);
darkening comes from the mood's colour grading, sun and ambient settings.
Purely per-client — other players see whatever their own client applies.
+29
View File
@@ -447,6 +447,35 @@ $cfg = [ordered]@{
DesktopScreenShareRaisePhotonRate = $true
DesktopScreenShareResolution = 0
DesktopScreenShareQuality = 0
# Rec Royale night mode (client-side visual). Applies one of the game's
# shipped night MoodSetting assets (sky dome, sun, fog, colour grading)
# to the Rec Royale map and unlocks weapon flashlights. Moods:
# Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
# Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
# ToggleKey is an optional UnityEngine.KeyCode name (e.g. 'N') to flip it
# in-game; it also arms the feature when NightMode is $false.
RecRoyaleNightMode = $false
RecRoyaleNightMood = 'Night_Calm_Outdoor_Mood'
RecRoyaleNightScenes = @('RecRoyale')
RecRoyaleNightFlashlights = $true
RecRoyaleNightFog = $false # $true = also apply the mood's fog
# Darkening: EV offset added to the mood's post-exposure (dims baked
# lightmaps too), and multipliers on the mood's sun / ambient intensity.
RecRoyaleNightExposure = -3.5
RecRoyaleNightSunScale = 0.15
RecRoyaleNightAmbientScale = 0.2
# Post-exposure needs the scene Volume on and camera post-processing
# enabled; force both while night mode is active.
RecRoyaleNightForcePostProcessing = $true
# The engine only drives RenderSettings.sun; match every other
# directional light's rotation/colour to it and scale its intensity.
RecRoyaleNightMatchSunLights = $true
# Fog off entirely while night is on, and renderers whose object or
# material name contains any of these are hidden (Frontier's daytime
# cloud meshes). Empty array = hide nothing.
RecRoyaleNightDisableFog = $false
RecRoyaleNightHideObjects = @('Cloud')
RecRoyaleNightToggleKey = ''
}
Write-Step "Writing mod config -> $cfgPath"
[System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json -Depth 4))