diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 8fd74f4..8e1e218 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -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(); + 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(); + 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; diff --git a/DorkNet.ClientMod/RecRoyaleNight.cs b/DorkNet.ClientMod/RecRoyaleNight.cs new file mode 100644 index 0000000..3928570 --- /dev/null +++ b/DorkNet.ClientMod/RecRoyaleNight.cs @@ -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, 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) ? "" : 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) ? "" : 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) ? "" : 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 _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(); + 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; 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 _hidden = new(); + private static bool _hideScanned; + + private static void HideObjects() + { + if (_hideScanned) return; + _hideScanned = true; + try + { + var patterns = new List(); + 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(); + 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(); + 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)}="); 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 ""; + 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 ? "" : 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(); + 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(); + 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 }); + } +} diff --git a/DorkNet.ClientMod/dorknet-clientmod.json.template b/DorkNet.ClientMod/dorknet-clientmod.json.template index a4224ee..f3622c7 100644 --- a/DorkNet.ClientMod/dorknet-clientmod.json.template +++ b/DorkNet.ClientMod/dorknet-clientmod.json.template @@ -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": "" } diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs new file mode 100644 index 0000000..510b2a1 --- /dev/null +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -0,0 +1,177 @@ +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; + +/// +/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty +/// , and every details builder and +/// matchmaking resolver turns that into an empty DataBlob. 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 IsRRO 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. +/// +public sealed class RroRoomBlobTests : IClassFixture +{ + 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); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (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 = $"Royale{Guid.NewGuid():N}"[..18], + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal,sport", + CurrentDataBlobName = string.Empty, + }); + 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); + + // ── 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()); + } + + [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(); + (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()); + } + + private static JsonElement SubRoomZero(JsonElement details) => + details.GetProperty("SubRooms").EnumerateArray() + .First(e => e.GetProperty("SubRoomId").GetInt64() == 0); + + private static async Task 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 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; + } +} diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 953e8a9..0ac5a0e 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1117,6 +1117,9 @@ public class AdminController( .OrderBy(s => s.OrderIndex) .ToListAsync(); + var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id); + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + return Ok(scenes.Select(s => new { // Ids go out as strings: the admin SPA is JavaScript and silently @@ -1128,10 +1131,134 @@ 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); + } + + /// 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 _sub{n} form for the others. + private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) => + subRoomId == 0 + ? RoomService.SyntheticDefaultRoomDataBlobName(roomId) + : $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat"; + + /// POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable + /// — 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 , and every details + /// builder + matchmaking resolver turns that into an empty + /// DataBlob, 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 RecRoyaleSolos_GameRuleWrapperCircuit lives in + /// it, not in the scene). Stamping the synthetic default name is enough: + /// every resolver returns it verbatim, and CdnController 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. + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")] + public async Task 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) return NotFound(); + + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + if (!string.IsNullOrWhiteSpace(scene.DataBlobName)) + return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false)); + + var blobName = SyntheticSubRoomBlobName(id, subRoomId); + var now = DateTime.UtcNow; + 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)); + } + + /// POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset + /// — 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 RoomDataBlobs; only the pointers move, so + /// the in-game restore list can bring an old save back. + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")] + public async Task 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) return NotFound(); + + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + var previousScene = scene.DataBlobName; + var previousRoom = room.CurrentDataBlobName; + var changed = false; + var now = DateTime.UtcNow; + + if (!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)); + } + + private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity scene, bool bakedOriginal, bool changed) => new + { + id = scene.Id.ToString(), + subRoomId = scene.OrderIndex, + name = scene.Name, + dataBlobName = scene.DataBlobName, + roomCurrentDataBlobName = room.CurrentDataBlobName, + effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal), + isBakedOriginal = bakedOriginal, + changed, + }; + public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers); /// PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index d913733..5e300a9 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -563,6 +563,11 @@ 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; } // Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is @@ -576,6 +581,34 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n const { data, loading, error, refresh } = useApi(`/rooms/${roomId}/subrooms`); const [edits, setEdits] = useState>({}); const [saving, setSaving] = useState(null); + const [blobBusy, setBlobBusy] = useState(null); + const [resetArmed, setResetArmed] = useState(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. + 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 +664,46 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n {sub.isSandbox && Sandbox} {!sub.canMatchmakeInto && No matchmaking} - {sub.dataBlobName && ( -
{sub.dataBlobName}
+
+ {sub.effectiveBlobName + ? sub.effectiveBlobName + : sub.isBakedOriginal + ? 'no data blob — baked scene only (no Maker Pen / chips)' + : '(synthetic default blob)'} +
+ {sub.isBakedOriginal && ( +
+ {!sub.dataBlobName ? ( + + ) : resetArmed === sub.subRoomId ? ( + <> + Drops the blob pointer; saved blobs stay restorable. + + + + ) : ( + + )} +
)}
diff --git a/README.md b/README.md index 9274448..a6d6563 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,87 @@ filter for extra players, so player indexes above the four baked spawn slots can reuse existing quest spawn points instead of being left out. Default `0` (off). +### Rec Royale night mode (opt-in) + +Rec Royale never shipped a night variant, but the client carries the room +"mood" engine (`RecRoom.Core.Scene.SceneMoodSettings`) and ready-made +night `MoodSetting` assets in `resources.assets`. Set +`"RecRoyaleNightMode"` in `dorknet-clientmod.json` and the mod applies one +of those moods (sky dome, sun, fog, bloom and colour grading) as soon as +the `RecRoyale_Frontier` scene finishes loading, and unlocks the weapon +flashlight prefab while it is on: + +```json +{ + "RecRoyaleNightMode": true, + "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": "N" +} +``` + +`RecRoyaleNightDisableFog` turns fog off entirely while night is on +(`RenderSettings.fog` forced off and the mood's fog block pushed out of +range); it is ignored when `RecRoyaleNightFog` is `true`. +`RecRoyaleNightHideObjects` hides every renderer whose object or material +name contains one of the entries; the default hides Frontier's daytime +cloud meshes (`CloudPlane`, `Clouds`, the `_Cloud_RecRoyale_Frontier_Mat` +static batch). Both are restored on toggle-off or scene change. + +The mood engine only ever drives `RenderSettings.sun`, which on Frontier +is the sun-disc light, not the map's key light, so daylight would keep +casting. `RecRoyaleNightMatchSunLights` (default `true`) copies the +engine-driven sun's rotation and colour onto every other directional +light and scales their intensity by `RecRoyaleNightSunScale`. + +Two more RRO quirks the mod works around: the mood skybox material the +engine swaps in (`MoodConfig.SkyBoxMaterial`) is not loaded outside +custom-room scenes, so the mod builds one on the `Custom/SkyBox` shader +and hands it to the engine (otherwise the sky is plain black and the +engine refuses to touch sky colours or the sun); and post-exposure only +shows when the scene Volume is on and the camera renders +post-processing, which `RecRoyaleNightForcePostProcessing` (default +`true`) forces while night mode is active. + +Brightness: RRO maps are lightmapped, and baked lighting ignores the sun +and ambient changes a mood makes, so the mod also darkens the mood data +itself before applying it. `RecRoyaleNightExposure` is an EV offset added +to the mood's colour-grading post-exposure (a full-frame post effect, so +it dims lightmapped surfaces too; `0` = mood default), and +`RecRoyaleNightSunScale` / `RecRoyaleNightAmbientScale` multiply the +mood's sun and ambient intensities (`1` = unchanged). Raise the exposure +toward `0` if it is too dark. + +RRO scenes have no room data blob, so the mood engine starts disabled +there; the mod replays the room-data loader's bring-up (disable, capture +the scene's own lighting, enable), points `RenderSettings.sun` at the +scene's directional light (Frontier ships without the reference, which +would otherwise skip every sun change), and re-captures once the mood +skybox material is in place so the sky colours apply. `RecRoyaleNightFog` +is off by default: the mood's fog block is swapped for one captured from +the scene, so fog stays as the map author left it. + +Shipped moods: `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`. +`RecRoyaleNightToggleKey` is an optional `UnityEngine.KeyCode` name that +flips night on/off in-game (off restores the scene's own mood); setting a +key also arms the feature when `RecRoyaleNightMode` is `false`. This is +purely **client-side**: the mod calls the mood engine's apply step +directly and never touches the networked room-mood index, so other players +keep their own view. Baked lightmaps are not changed, so interiors stay +lit; the mood's colour grading does the darkening. Default `false` (off). +Wire-level notes: `docs/recroom-2023-scene-moods.md`. + --- ## Code map diff --git a/docs/admin.md b/docs/admin.md index 05546e3..13866b9 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -179,6 +179,61 @@ 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. +- **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`. + ## Test cases ↔ GitHub issues QA test cases can file and track GitHub issues. Configure with: diff --git a/docs/architecture.md b/docs/architecture.md index d2837ef..de1e13a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`). --- diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 2ec4075..4bcd009 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -132,6 +132,31 @@ 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. + +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 diff --git a/docs/recroom-2023-scene-moods.md b/docs/recroom-2023-scene-moods.md new file mode 100644 index 0000000..bf176e3 --- /dev/null +++ b/docs/recroom-2023-scene-moods.md @@ -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` 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.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` 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`) 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("", 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`), 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. diff --git a/tools/install-melon.ps1 b/tools/install-melon.ps1 index 0ca2307..b439872 100644 --- a/tools/install-melon.ps1 +++ b/tools/install-melon.ps1 @@ -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))