mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Rec Royale night mode (ClientMod) + admin switch to give baked RRO rooms a data blob
ClientMod (2023-03-21): opt-in Rec Royale night mode. RRO scenes never
enable the room mood engine (no data blob → SceneMoodSettings.isEnabled
stays false, CurrentMood null, the per-frame update returns at once), so
the mod replays the room-data loader's bring-up, registers a shipped
night MoodSetting (Night_Calm_Outdoor_Mood by default) as the scene's
default mood, supplies a skybox material on the Custom/SkyBox shader
(MoodConfig.SkyBoxMaterial is not loaded outside custom rooms), assigns
the scene's directional light as RenderSettings.sun, matches the key
light to the engine-driven sun, darkens via the mood's post-exposure and
sun/ambient intensities, forces post-processing on, keeps or disables
fog, hides the daytime cloud meshes, unlocks weapon flashlights, and
restores everything on hotkey-off / scene change. All knobs live in
dorknet-clientmod.json (RecRoyaleNight*), written by install-melon.ps1.
Server/admin: baked Rec Room Originals are seeded with an empty
DataBlobName, which the 2023 client reads as "no room data" — no Maker
Pen, no CV2 room-settings chips, no saves, and for Rec Royale no game at
all (its game-rule chip RecRoyaleSolos_GameRuleWrapperCircuit ships in
the blob, not the scene). New admin actions on the Sub-rooms tab:
POST rooms/{id}/subrooms/{sub}/blob/enable stamps the synthetic default
blob name (which CdnController already answers with the RRO-editable
blob) and blob/reset clears it back to scene-only. IsRRO is derived from
IsAGRoom, so the client keeps loading the baked map.
Docs: README section, docs/admin.md procedure, docs/recroom-2023-room-save.md,
new docs/recroom-2023-scene-moods.md with the ISIL offsets behind all of it.
Tests: RroRoomBlobTests.
This commit is contained in:
@@ -6,8 +6,9 @@
|
||||
// 2. TLS trust bypass — make BouncyCastle never reject server certs
|
||||
//
|
||||
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
|
||||
// needed so a modded client isn't kicked, and two opt-in features that
|
||||
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size).
|
||||
// needed so a modded client isn't kicked, and three opt-in features that
|
||||
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size,
|
||||
// Rec Royale night mode).
|
||||
//
|
||||
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
|
||||
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
|
||||
@@ -109,6 +110,52 @@ public class Mod : MelonMod
|
||||
// generic "Quest" config is intentionally NOT in the default list.
|
||||
public static string[] QuestMaxTeamSizeRooms =
|
||||
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
|
||||
|
||||
// ── Rec Royale night mode (client-side visual) ──
|
||||
// Re-skins Rec Royale with one of the game's own shipped night
|
||||
// MoodSetting assets (sky dome, sun, fog, bloom, colour grading)
|
||||
// through SceneMoodSettings' apply path, and unlocks the weapon
|
||||
// flashlight prefab while it is on. Purely local: the networked room
|
||||
// mood index is never touched. See RecRoyaleNight.
|
||||
public static bool RecRoyaleNightMode = false;
|
||||
// Resources asset name of the MoodSetting to apply. Shipped options:
|
||||
// Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
|
||||
// Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
|
||||
public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood";
|
||||
// Unity scene names (substring, case-insensitive) that count as Rec
|
||||
// Royale. 2023-03-21 has a single map: RecRoyale_Frontier.
|
||||
public static string[] RecRoyaleNightScenes = { "RecRoyale" };
|
||||
// Force WeaponFlashlightController.FlashlightAllowed while night is on.
|
||||
public static bool RecRoyaleNightFlashlights = true;
|
||||
// Apply the mood's fog block too. Off = the mood's FogSettings are
|
||||
// swapped for a block captured from the scene, so fog stays as-is.
|
||||
public static bool RecRoyaleNightFog = false;
|
||||
// Darkening knobs applied to the mood data before it is used. Baked
|
||||
// lightmaps can't be dimmed, so post-exposure (EV, added to the mood's
|
||||
// ColorGradingSettings.exposure) is what actually darkens the whole
|
||||
// frame; the sun/ambient multipliers tone down realtime lighting.
|
||||
public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default)
|
||||
public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged)
|
||||
public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged)
|
||||
// Post-exposure only renders if the scene Volume is on and the camera
|
||||
// renders post-processing; force both while night mode is active.
|
||||
public static bool RecRoyaleNightForcePostProcessing = true;
|
||||
// The engine only drives RenderSettings.sun (Frontier: the sun-disc
|
||||
// light). Copy its rotation/colour onto every other directional light
|
||||
// and scale their intensity by RecRoyaleNightSunScale.
|
||||
public static bool RecRoyaleNightMatchSunLights = true;
|
||||
// Turn fog off entirely while night is on (RenderSettings.fog=false and
|
||||
// the mood's fog block pushed out of range). Ignored when
|
||||
// RecRoyaleNightFog is true.
|
||||
public static bool RecRoyaleNightDisableFog = false;
|
||||
// Renderers whose GameObject or material name contains any of these
|
||||
// (case-insensitive) are hidden while night is on. Frontier's daytime
|
||||
// cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat.
|
||||
public static string[] RecRoyaleNightHideObjects = { "Cloud" };
|
||||
// Optional UnityEngine.KeyCode name to toggle night at runtime (e.g.
|
||||
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
||||
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
||||
public static string RecRoyaleNightToggleKey = "";
|
||||
}
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
@@ -178,6 +225,19 @@ public class Mod : MelonMod
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
}
|
||||
|
||||
// Rec Royale night mode: armed when enabled OR when a hotkey is set
|
||||
// (so it can be switched on in-game). Scene detection + mood apply
|
||||
// run from OnSceneWasLoaded / OnUpdate below.
|
||||
if (Cfg.RecRoyaleNightMode || !string.IsNullOrWhiteSpace(Cfg.RecRoyaleNightToggleKey))
|
||||
{
|
||||
_recRoyaleNightArmed = true;
|
||||
RecRoyaleNight.Register(HarmonyInstance);
|
||||
}
|
||||
}
|
||||
private bool _recRoyaleNightArmed;
|
||||
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
|
||||
{
|
||||
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
|
||||
}
|
||||
private bool PatchFileHashCheckerCallback()
|
||||
{
|
||||
@@ -359,6 +419,8 @@ public class Mod : MelonMod
|
||||
}
|
||||
public override void OnUpdate()
|
||||
{
|
||||
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
|
||||
|
||||
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
|
||||
{
|
||||
_antiTamperCallbackRetryFrame = 0;
|
||||
@@ -404,6 +466,33 @@ public class Mod : MelonMod
|
||||
rooms.Add(e.GetString()!);
|
||||
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
|
||||
}
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightMode", out v)) Cfg.RecRoyaleNightMode = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightMood", out v)) Cfg.RecRoyaleNightMood = v.GetString() ?? Cfg.RecRoyaleNightMood;
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightFlashlights", out v)) Cfg.RecRoyaleNightFlashlights = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightFog", out v)) Cfg.RecRoyaleNightFog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightExposure", out v)) Cfg.RecRoyaleNightExposure = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightSunScale", out v)) Cfg.RecRoyaleNightSunScale = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightAmbientScale", out v)) Cfg.RecRoyaleNightAmbientScale = (float)v.GetDouble();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightForcePostProcessing", out v)) Cfg.RecRoyaleNightForcePostProcessing = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightMatchSunLights", out v)) Cfg.RecRoyaleNightMatchSunLights = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightDisableFog", out v)) Cfg.RecRoyaleNightDisableFog = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightHideObjects", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
names.Add(e.GetString()!);
|
||||
Cfg.RecRoyaleNightHideObjects = names.ToArray(); // explicit (incl. empty = hide nothing)
|
||||
}
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightToggleKey", out v)) Cfg.RecRoyaleNightToggleKey = v.GetString() ?? "";
|
||||
if (TryGetConfigValue(r, "RecRoyaleNightScenes", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var scenes = new List<string>();
|
||||
foreach (var e in v.EnumerateArray())
|
||||
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
|
||||
scenes.Add(e.GetString()!);
|
||||
if (scenes.Count > 0) Cfg.RecRoyaleNightScenes = scenes.ToArray();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -573,7 +662,7 @@ public class Mod : MelonMod
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in the core + feature patch holder classes.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
|
||||
@@ -0,0 +1,1401 @@
|
||||
// Rec Royale night mode — client-side, opt-in.
|
||||
//
|
||||
// Rec Royale never shipped a night variant, but the March-2023 client
|
||||
// carries everything needed to build one from the game's own tooling:
|
||||
//
|
||||
// • Every room scene owns a RecRoomSceneManager (name-preserved field
|
||||
// `sceneMoodSettings` → RecRoom.Core.Scene.SceneMoodSettings). That
|
||||
// object is the room "mood" engine behind the Maker Pen sky/sun/fog
|
||||
// settings: it holds `availableMoods` (MoodSetting[]), `defaultMood`
|
||||
// and `CurrentMood`, plus blenders for skybox, sun, fog, bloom and
|
||||
// colour grading.
|
||||
// • SceneMoodSettings.MLLBALMKEGJ(MoodSetting) is the apply step
|
||||
// (ISIL: SceneMoodSettings.txt:3025-3581). It pushes the mood's six
|
||||
// settings blocks into the blenders (ALCMNGKOEBK), swaps the URP
|
||||
// VolumeProfile (OEKNGEMDMDB), sets RenderSettings.skybox to the
|
||||
// global MoodConfig skybox material (LNMDIIFOFPD.NHAEHKJINFH, shader
|
||||
// "Custom/SkyBox"), re-aims RenderSettings.sun, sets every
|
||||
// ReflectionProbe.customBakedTexture to the mood's baked cubemap and
|
||||
// force-updates the blenders. It does NOT touch the networked mood
|
||||
// index, so calling it is purely local.
|
||||
// • The build ships ready-made night moods as Resources assets
|
||||
// (resources.assets; ResourceManager table in globalgamemanagers):
|
||||
// Night_Calm_Outdoor_Mood ("Calm Night"), Night_Spooky_Outdoor_Mood
|
||||
// ("Spooky Night"), Night_Wild_Outdoor_Mood ("Wild Night"),
|
||||
// Night_StuntRunner_Outdoor_Mood and OuterSpace_Mood ("Outer Space").
|
||||
// • The only Rec Royale map in this build is
|
||||
// Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity.
|
||||
//
|
||||
// RRO SCENES HAVE NO ROOM DATA BLOB. The mood engine's `isEnabled` flag
|
||||
// (SceneMoodSettings+0x38) is only switched on by the room-data loader
|
||||
// (EIMFCPIHEPF.JPKEMACDNCF, ISIL :2136-2310), which runs for custom rooms
|
||||
// only. While it is off, get_CurrentMood returns null and nothing ticks.
|
||||
// The loader's bring-up sequence is:
|
||||
// LKIKMLNPCFK(false) // SetEnabled(false): applies a null mood
|
||||
// LIEDFOLBGHL() // capture the scene's own skybox/sun/fog as the
|
||||
// // blenders' "previous" data (checks shader name
|
||||
// // "Custom/SkyBox" → HasRecRoomSkyboxShader)
|
||||
// LKIKMLNPCFK(true) // SetEnabled(true)
|
||||
// This mod replays exactly that before applying the night mood.
|
||||
//
|
||||
// Deliberately NOT used: SceneMoodSettings.IPAMPMBDPNB(Guid) /
|
||||
// set_CurrentRoomMoodIndex. `_currentRoomMoodIndex` is a
|
||||
// RecRoom.Networking.SynchronizedFields DFAOKLDJPEL<int>, i.e. it
|
||||
// replicates to every player in the instance. Applying the mood directly
|
||||
// keeps night mode a per-client visual choice.
|
||||
//
|
||||
// Weapon flashlights: WeaponFlashlightController.get_FlashlightAllowed
|
||||
// (ISIL: WeaponFlashlightController.txt:3-75) is
|
||||
// `Matchmaking.FBELDNLEKAC() != null && [+0x28] == 15` — a game-mode
|
||||
// gate for an activity that isn't Rec Royale. A Harmony postfix forces it
|
||||
// true while night mode is active, so weapons picked up in the dark get
|
||||
// the game's own flashlight prefab.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
internal static class RecRoyaleNight
|
||||
{
|
||||
private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
|
||||
// Runtime state. `Active` starts from Cfg.RecRoyaleNightMode and flips
|
||||
// with the hotkey; `_inRoyale` tracks whether the current scene is one
|
||||
// of Cfg.RecRoyaleNightScenes.
|
||||
public static bool Active { get; private set; }
|
||||
private static bool _inRoyale;
|
||||
private static string _sceneName = "";
|
||||
private static bool _appliedThisScene;
|
||||
private static int _frame;
|
||||
private static int _sceneFrames; // frames since the Royale scene loaded
|
||||
private static int _reassertFrames; // frames since the last re-apply
|
||||
|
||||
// Per-scene bring-up bookkeeping so toggling off can put things back.
|
||||
private static bool _engineWasDisabled; // we enabled the mood engine ourselves
|
||||
private static object? _originalSkybox; // RenderSettings.skybox before we touched it
|
||||
private static bool _sunWasNull; // we assigned RenderSettings.sun ourselves
|
||||
private static object? _originalFog; // MoodSetting.FogSettings before we swapped it
|
||||
private static object? _fogPatchedMood; // the mood asset whose FogSettings we replaced
|
||||
private static object? _darkPatchedMood; // the mood asset whose exposure/sun/ambient we changed
|
||||
private static float? _origExposure, _origSunIntensity, _origAmbient;
|
||||
private static bool _diagLogged;
|
||||
|
||||
private const int PollEveryFrames = 30; // ~0.5 s @ 60 fps
|
||||
private const int SettleFrames = 90; // let the scene's own lighting settle first
|
||||
private const int PollGiveUpFrames = 60 * 120; // stop polling after ~2 min
|
||||
private const int ReassertEveryFrames = 60 * 10; // re-apply every ~10 s
|
||||
|
||||
// Resolved reflection handles (lazy, cached).
|
||||
private static bool _resolved;
|
||||
private static Type? _sceneManagerType;
|
||||
private static PropertyInfo? _sceneManagerInstance;
|
||||
private static FieldInfo? _sceneMoodSettingsField;
|
||||
private static PropertyInfo? _sceneMoodSettingsProp;
|
||||
private static Type? _moodSettingType;
|
||||
private static MethodInfo? _applyMood; // MLLBALMKEGJ(MoodSetting)
|
||||
private static MethodInfo? _setEnabled; // LKIKMLNPCFK(bool)
|
||||
private static MethodInfo? _capture; // LIEDFOLBGHL()
|
||||
private static FieldInfo? _isEnabled; // isEnabled
|
||||
private static PropertyInfo? _roomMoodsEnabled;
|
||||
private static PropertyInfo? _hasSkyShader;
|
||||
private static PropertyInfo? _hasSunSource;
|
||||
private static PropertyInfo? _volume;
|
||||
private static FieldInfo? _availableMoods;
|
||||
private static PropertyInfo? _currentMood;
|
||||
private static PropertyInfo? _friendlyName;
|
||||
private static PropertyInfo? _renderSkybox; // UnityEngine.RenderSettings.skybox
|
||||
private static PropertyInfo? _renderSun; // UnityEngine.RenderSettings.sun
|
||||
private static Type? _lightType; // UnityEngine.Light
|
||||
private static Type? _fogSettingsType; // FogMoodSettings
|
||||
private static MethodInfo? _fogCapture; // FogMoodSettings.NLDDIDEIEFH() — read RenderSettings fog
|
||||
private static PropertyInfo? _moodFog; // MoodSetting.FogSettings
|
||||
private static Type? _materialType; // UnityEngine.Material
|
||||
private static Type? _shaderType; // UnityEngine.Shader
|
||||
private static Type? _moodConfigHolder; // LNMDIIFOFPD (static MoodConfig holder)
|
||||
private static Type? _cameraDataType; // UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
|
||||
private static object? _ourSkybox; // Material we created for the mood skybox
|
||||
private static object? _nightMood; // MoodSetting interop wrapper
|
||||
private static string _nightMoodLoadedName = "";
|
||||
|
||||
// Hotkey
|
||||
private static bool _inputResolved;
|
||||
private static MethodInfo? _getKeyDown;
|
||||
private static object? _toggleKey;
|
||||
|
||||
// ── registration ──────────────────────────────────────────────────
|
||||
public static void Register(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
Active = Mod.Cfg.RecRoyaleNightMode;
|
||||
Mod.Log.Msg($"[royale-night] enabled={Active} mood={Mod.Cfg.RecRoyaleNightMood} " +
|
||||
$"scenes=[{string.Join(",", Mod.Cfg.RecRoyaleNightScenes)}] " +
|
||||
$"flashlights={Mod.Cfg.RecRoyaleNightFlashlights} " +
|
||||
$"hotkey={(string.IsNullOrWhiteSpace(Mod.Cfg.RecRoyaleNightToggleKey) ? "<none>" : Mod.Cfg.RecRoyaleNightToggleKey)}");
|
||||
|
||||
if (Mod.Cfg.RecRoyaleNightFlashlights)
|
||||
TryPatchFlashlightGate(harmony);
|
||||
}
|
||||
|
||||
// POSTFIX on WeaponFlashlightController.get_FlashlightAllowed. The
|
||||
// interop property carries the obfuscated name (KIMMMCCKMJL in the
|
||||
// 2023-03-21 build) while the il2cpp method keeps `get_FlashlightAllowed`,
|
||||
// so resolve by name first and fall back to "the only read-only bool
|
||||
// property declared on the type".
|
||||
private static void TryPatchFlashlightGate(HarmonyLib.Harmony harmony)
|
||||
{
|
||||
try
|
||||
{
|
||||
var type = Mod.ResolveType("WeaponFlashlightController");
|
||||
if (type is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] WeaponFlashlightController: type not found");
|
||||
return;
|
||||
}
|
||||
|
||||
MethodInfo? getter = AccessTools.PropertyGetter(type, "FlashlightAllowed")
|
||||
?? AccessTools.PropertyGetter(type, "KIMMMCCKMJL");
|
||||
if (getter is null)
|
||||
{
|
||||
PropertyInfo? only = null;
|
||||
int count = 0;
|
||||
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (p.PropertyType != typeof(bool) || !p.CanRead || p.CanWrite || p.GetIndexParameters().Length != 0) continue;
|
||||
only = p; count++;
|
||||
}
|
||||
if (count == 1) getter = only!.GetGetMethod(true);
|
||||
}
|
||||
if (getter is null)
|
||||
{
|
||||
Mod.Log.Warning("[patch-miss] WeaponFlashlightController.get_FlashlightAllowed");
|
||||
return;
|
||||
}
|
||||
|
||||
var postfix = typeof(RecRoyaleNight).GetMethod(nameof(FlashlightAllowed_Postfix), BindingFlags.Public | BindingFlags.Static)!;
|
||||
harmony.Patch(getter, postfix: new HarmonyMethod(postfix));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Error($"[patch-fail] WeaponFlashlightController.get_FlashlightAllowed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void FlashlightAllowed_Postfix(ref bool __result)
|
||||
{
|
||||
if (Active && _inRoyale) __result = true;
|
||||
}
|
||||
|
||||
// ── lifecycle (called from Mod) ───────────────────────────────────
|
||||
public static void OnSceneLoaded(string sceneName)
|
||||
{
|
||||
_sceneName = sceneName ?? "";
|
||||
var was = _inRoyale;
|
||||
_inRoyale = MatchesScene(_sceneName);
|
||||
if (_inRoyale)
|
||||
{
|
||||
_appliedThisScene = false;
|
||||
_sceneFrames = 0;
|
||||
_reassertFrames = 0;
|
||||
_engineWasDisabled = false;
|
||||
_originalSkybox = null;
|
||||
_sunWasNull = false;
|
||||
_diagLogged = false;
|
||||
RestoreMoodPatches();
|
||||
Mod.Log.Msg($"[royale-night] scene '{_sceneName}' loaded — night mode {(Active ? "will apply" : "is off (hotkey to enable)")}");
|
||||
}
|
||||
else if (was)
|
||||
{
|
||||
_appliedThisScene = false;
|
||||
_engineWasDisabled = false;
|
||||
_originalSkybox = null;
|
||||
_sunWasNull = false;
|
||||
RestoreMoodPatches();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
_frame++;
|
||||
PollHotkey();
|
||||
|
||||
if (!_inRoyale || !Active) return;
|
||||
_sceneFrames++;
|
||||
if (_frame % PollEveryFrames != 0) return;
|
||||
|
||||
if (!_appliedThisScene)
|
||||
{
|
||||
if (_sceneFrames < SettleFrames || _sceneFrames > PollGiveUpFrames) return;
|
||||
if (TryApply(true)) _appliedThisScene = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cheap safeguard: the game re-applies its own mood on quality
|
||||
// changes / studio events. Re-assert every ~10 s; when the blenders
|
||||
// are already at target this is visually a no-op.
|
||||
_reassertFrames += PollEveryFrames;
|
||||
if (_reassertFrames >= ReassertEveryFrames)
|
||||
{
|
||||
_reassertFrames = 0;
|
||||
TryApply(true);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Toggle()
|
||||
{
|
||||
Active = !Active;
|
||||
Mod.Log.Msg($"[royale-night] toggled {(Active ? "ON" : "OFF")}");
|
||||
if (!_inRoyale) return;
|
||||
if (TryApply(Active)) _appliedThisScene = Active;
|
||||
else if (!Active) _appliedThisScene = false;
|
||||
}
|
||||
|
||||
// ── the actual apply ──────────────────────────────────────────────
|
||||
// night=true → bring the mood engine up if needed, apply the night mood
|
||||
// night=false → restore: scene mood if the engine was already running,
|
||||
// otherwise disable the engine again + put the skybox back
|
||||
private static bool TryApply(bool night)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_resolved && !Resolve()) return false;
|
||||
if (_applyMood is null) return false;
|
||||
|
||||
var manager = GetSceneManager();
|
||||
if (manager is null) { Diag("scene manager instance is null"); return false; }
|
||||
|
||||
var moods = _sceneMoodSettingsField?.GetValue(manager) ?? _sceneMoodSettingsProp?.GetValue(manager);
|
||||
if (moods is null) { Diag("sceneMoodSettings is null"); return false; }
|
||||
|
||||
// `isEnabled` reads differently through the interop property vs the
|
||||
// field (seen True/False for the same state), so don't gate on it.
|
||||
// RoomMoodsEnabled (= isEnabled && defaultMood != null) is computed
|
||||
// game-side and is false for every RRO scene; bring the engine up
|
||||
// exactly once per Royale scene whenever it is not true.
|
||||
var enabled = GetMember(moods, "isEnabled") is true;
|
||||
var roomMoodsEnabled = Read(_roomMoodsEnabled, moods) is true;
|
||||
LogState(moods, enabled);
|
||||
|
||||
if (night)
|
||||
{
|
||||
var target = GetNightMood();
|
||||
if (target is null) return false;
|
||||
|
||||
if (!roomMoodsEnabled && !_engineWasDisabled)
|
||||
{
|
||||
// Replay the room-data loader's bring-up. Remember the
|
||||
// scene's own skybox so toggle-off can restore it.
|
||||
if (_originalSkybox is null && _renderSkybox is not null)
|
||||
_originalSkybox = _renderSkybox.GetValue(null);
|
||||
if (_setEnabled is null || _capture is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] mood engine is disabled in this scene and SetEnabled/Capture are unresolved — cannot bring it up");
|
||||
return false;
|
||||
}
|
||||
|
||||
// RRO scenes leave RenderSettings.sun unset, and the engine
|
||||
// skips every sun change (direction, colour, intensity) when
|
||||
// HasSunSource is false. Point it at the scene's directional
|
||||
// light BEFORE capture so the flag comes out true.
|
||||
EnsureSun();
|
||||
|
||||
// Keep the scene's own fog unless asked otherwise: replace
|
||||
// the mood's FogSettings with a block captured from the
|
||||
// current RenderSettings before anything changes them.
|
||||
if (!Mod.Cfg.RecRoyaleNightFog) PreserveFog(target);
|
||||
|
||||
// Baked lightmaps can't be dimmed, so darken the frame via
|
||||
// the mood's own post-exposure and tone down sun/ambient.
|
||||
PatchDarkness(target);
|
||||
|
||||
_setEnabled.Invoke(moods, new object[] { false });
|
||||
_capture.Invoke(moods, null);
|
||||
|
||||
// The per-frame update (CEKLEBHDAHA, ISIL :3895-4031) returns
|
||||
// at once while get_CurrentMood() is null, and the one-shot
|
||||
// apply only sets blend TARGETS — the update is what writes
|
||||
// sky colours, rotates the sun and pushes exposure. In RRO
|
||||
// scenes CurrentMood is null because defaultMood is unset.
|
||||
// Register the night mood as this scene's defaultMood and
|
||||
// sole availableMoods entry so the engine runs like a
|
||||
// one-mood custom room.
|
||||
InstallSceneMood(moods, target);
|
||||
|
||||
_setEnabled.Invoke(moods, new object[] { true });
|
||||
_engineWasDisabled = true;
|
||||
ProbeCircuitsSupport();
|
||||
var cm = _currentMood?.GetValue(moods);
|
||||
Mod.Log.Msg($"[royale-night] mood engine brought up (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)} " +
|
||||
$"roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} currentMood={(cm is null || IsUnityNull(cm) ? "<null>" : Describe(cm))})");
|
||||
|
||||
// First apply swaps RenderSettings.skybox to
|
||||
// MoodConfig.SkyBoxMaterial. In RRO scenes that material is
|
||||
// not loaded (it lives in the custom-room scene's shared
|
||||
// assets), so the skybox ends up null → black sky and the
|
||||
// engine's HasRecRoomSkyboxShader / HasSunSource stay false
|
||||
// (both need shader "Custom/SkyBox" on RenderSettings.skybox).
|
||||
// Supply a material built from that shader, re-capture so
|
||||
// the flags flip, then fall through to the real apply.
|
||||
_applyMood.Invoke(moods, new[] { target });
|
||||
EnsureMoodSkybox();
|
||||
_capture.Invoke(moods, null);
|
||||
Mod.Log.Msg($"[royale-night] re-captured (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} skybox={DescribeSkybox()})");
|
||||
EnsurePostProcessing(moods);
|
||||
}
|
||||
|
||||
_applyMood.Invoke(moods, new[] { target });
|
||||
if (Mod.Cfg.RecRoyaleNightMatchSunLights) MatchOtherSunLights();
|
||||
if (Mod.Cfg.RecRoyaleNightDisableFog && !Mod.Cfg.RecRoyaleNightFog) ForceFogOff();
|
||||
HideObjects();
|
||||
Mod.Log.Msg($"[royale-night] applied mood '{Describe(target)}' in '{_sceneName}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
// restore
|
||||
if (_engineWasDisabled)
|
||||
{
|
||||
RestoreOtherSunLights();
|
||||
ShowObjects();
|
||||
RestoreFogState();
|
||||
_setEnabled?.Invoke(moods, new object[] { false }); // applies the captured "previous" data
|
||||
UninstallSceneMood(moods);
|
||||
if (_originalSkybox is not null && _renderSkybox is not null)
|
||||
_renderSkybox.SetValue(null, _originalSkybox);
|
||||
if (_sunWasNull && _renderSun is not null)
|
||||
_renderSun.SetValue(null, null);
|
||||
RestoreMoodPatches();
|
||||
_engineWasDisabled = false;
|
||||
_originalSkybox = null;
|
||||
_sunWasNull = false;
|
||||
Mod.Log.Msg($"[royale-night] mood engine disabled again, scene skybox/sun restored in '{_sceneName}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
var current = _currentMood?.GetValue(moods);
|
||||
if (current is null || IsUnityNull(current)) { Diag("no CurrentMood to restore"); return false; }
|
||||
_applyMood.Invoke(moods, new[] { current });
|
||||
Mod.Log.Msg($"[royale-night] restored scene mood '{Describe(current)}' in '{_sceneName}'");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] apply failed: {(ex.InnerException ?? ex).Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogState(object moods, bool enabled)
|
||||
{
|
||||
if (_diagLogged) return;
|
||||
_diagLogged = true;
|
||||
try
|
||||
{
|
||||
var arr = GetMember(moods, "availableMoods");
|
||||
var cur = _currentMood?.GetValue(moods);
|
||||
Mod.Log.Msg($"[royale-night] engine state: isEnabled={enabled} roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} " +
|
||||
$"availableMoods={Count(arr)} currentMood={(cur is null || IsUnityNull(cur) ? "<null>" : Describe(cur))} " +
|
||||
$"skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] state log failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static object? Read(PropertyInfo? p, object o)
|
||||
{
|
||||
try { return p?.GetValue(o); } catch { return null; }
|
||||
}
|
||||
|
||||
private static void Diag(string why)
|
||||
{
|
||||
if (_frame % (PollEveryFrames * 20) == 0) Mod.Log.Msg($"[royale-night] waiting: {why}");
|
||||
}
|
||||
|
||||
private static bool Resolve()
|
||||
{
|
||||
_resolved = true;
|
||||
|
||||
_sceneManagerType = Mod.ResolveType("RecRoomSceneManager");
|
||||
var moodSettingsType = Mod.ResolveType("RecRoom.Core.Scene.SceneMoodSettings");
|
||||
_moodSettingType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodSetting");
|
||||
if (_sceneManagerType is null || moodSettingsType is null || _moodSettingType is null)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] types missing — sceneManager={_sceneManagerType is not null} " +
|
||||
$"sceneMoodSettings={moodSettingsType is not null} moodSetting={_moodSettingType is not null}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// RecRoomSceneManager.Instance: the il2cpp getter is get_Instance but
|
||||
// the interop property name is obfuscated (PDCOCJBICGC in 2023-03-21).
|
||||
// Pick the static property whose type is RecRoomSceneManager.
|
||||
_sceneManagerInstance = AccessTools.Property(_sceneManagerType, "Instance");
|
||||
if (_sceneManagerInstance is null)
|
||||
{
|
||||
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (p.PropertyType == _sceneManagerType && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ _sceneManagerInstance = p; break; }
|
||||
}
|
||||
}
|
||||
|
||||
_sceneMoodSettingsField = AccessTools.Field(_sceneManagerType, "sceneMoodSettings");
|
||||
if (_sceneMoodSettingsField is null)
|
||||
{
|
||||
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
if (p.PropertyType == moodSettingsType && p.CanRead) { _sceneMoodSettingsProp = p; break; }
|
||||
}
|
||||
|
||||
_currentMood = AccessTools.Property(moodSettingsType, "CurrentMood");
|
||||
_roomMoodsEnabled = AccessTools.Property(moodSettingsType, "RoomMoodsEnabled");
|
||||
_hasSkyShader = AccessTools.Property(moodSettingsType, "HasRecRoomSkyboxShader");
|
||||
_hasSunSource = AccessTools.Property(moodSettingsType, "HasSunSource");
|
||||
_volume = AccessTools.Property(moodSettingsType, "Volume");
|
||||
_availableMoods = AccessTools.Field(moodSettingsType, "availableMoods");
|
||||
_isEnabled = AccessTools.Field(moodSettingsType, "isEnabled");
|
||||
_friendlyName = AccessTools.Property(_moodSettingType, "FriendlyName");
|
||||
|
||||
// Apply step: MLLBALMKEGJ(MoodSetting) in 2023-03-21. Fallback: the
|
||||
// only non-setter void method taking exactly one MoodSetting.
|
||||
_applyMood = AccessTools.Method(moodSettingsType, "MLLBALMKEGJ", new[] { _moodSettingType });
|
||||
if (_applyMood is null)
|
||||
{
|
||||
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType == _moodSettingType) { _applyMood = m; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled(bool): LKIKMLNPCFK in 2023-03-21. Fallback: the only
|
||||
// public non-setter void(bool) declared on the type.
|
||||
_setEnabled = AccessTools.Method(moodSettingsType, "LKIKMLNPCFK", new[] { typeof(bool) });
|
||||
if (_setEnabled is null)
|
||||
{
|
||||
MethodInfo? only = null; int n = 0;
|
||||
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType == typeof(bool)) { only = m; n++; }
|
||||
}
|
||||
if (n == 1) _setEnabled = only;
|
||||
}
|
||||
|
||||
// Capture current scene lighting: LIEDFOLBGHL() in 2023-03-21. Too
|
||||
// many void() methods on the type to pick by shape — name only.
|
||||
_capture = AccessTools.Method(moodSettingsType, "LIEDFOLBGHL", Type.EmptyTypes);
|
||||
|
||||
var renderSettings = Mod.ResolveType("UnityEngine.RenderSettings");
|
||||
_renderSkybox = renderSettings is null ? null : AccessTools.Property(renderSettings, "skybox");
|
||||
_renderSun = renderSettings is null ? null : AccessTools.Property(renderSettings, "sun");
|
||||
_lightType = Mod.ResolveType("UnityEngine.Light");
|
||||
|
||||
// Fog preservation: FogMoodSettings() + NLDDIDEIEFH() reads
|
||||
// RenderSettings.fog/fogStartDistance/fogEndDistance/fogColor into the
|
||||
// block (ISIL FogMoodSettings.txt, called right after the ctor in
|
||||
// LIEDFOLBGHL). MoodSetting.FogSettings is the serialized reference
|
||||
// the apply step hands to the fog blender.
|
||||
_fogSettingsType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodBlendData.FogMoodSettings");
|
||||
_fogCapture = _fogSettingsType is null ? null : AccessTools.Method(_fogSettingsType, "NLDDIDEIEFH", Type.EmptyTypes);
|
||||
_moodFog = AccessTools.Property(_moodSettingType, "FogSettings");
|
||||
|
||||
_materialType = Mod.ResolveType("UnityEngine.Material");
|
||||
_shaderType = Mod.ResolveType("UnityEngine.Shader");
|
||||
_moodConfigHolder = Mod.ResolveType("LNMDIIFOFPD");
|
||||
_cameraDataType = Mod.ResolveType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData");
|
||||
|
||||
var ok = _sceneManagerInstance is not null
|
||||
&& (_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null)
|
||||
&& _applyMood is not null;
|
||||
Mod.Log.Msg($"[royale-night] resolved: instance={_sceneManagerInstance is not null} " +
|
||||
$"moodSettings={_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null} " +
|
||||
$"apply={_applyMood is not null} setEnabled={_setEnabled is not null} capture={_capture is not null} " +
|
||||
$"isEnabled={_isEnabled is not null} currentMood={_currentMood is not null} skybox={_renderSkybox is not null} " +
|
||||
$"sun={_renderSun is not null} light={_lightType is not null} fogCapture={_fogCapture is not null} moodFog={_moodFog is not null}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
// RenderSettings.sun ← the scene's directional Light when unset. Frontier
|
||||
// ships without the reference ("Sun Source reference has not been set in
|
||||
// Lighting settings" is the engine's own warning), so nothing sun-related
|
||||
// from the mood would otherwise apply.
|
||||
private static void EnsureSun()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderSun is null || _lightType is null) return;
|
||||
var current = _renderSun.GetValue(null);
|
||||
if (current is not null && !IsUnityNull(current))
|
||||
{
|
||||
Mod.Log.Msg($"[royale-night] RenderSettings.sun already set: '{ObjectName(current)}'");
|
||||
return;
|
||||
}
|
||||
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppLight = Il2CppTypeOf(_lightType);
|
||||
if (unityObject is null || il2cppLight is null) return;
|
||||
|
||||
object? directional = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1) continue;
|
||||
var arr = m.Invoke(null, new object[] { il2cppLight });
|
||||
var n = Count(arr);
|
||||
float best = -1f;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var light = AsType(item, _lightType);
|
||||
if (light is null) continue;
|
||||
// LightType.Directional == 1
|
||||
var kind = GetMember(light, "type");
|
||||
if (kind is null || Convert.ToInt32(kind) != 1) continue;
|
||||
var intensity = GetMember(light, "intensity") is float f ? f : 0f;
|
||||
if (intensity > best) { best = intensity; directional = light; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (directional is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] no directional Light found in scene — sun direction/colour will not change");
|
||||
return;
|
||||
}
|
||||
_renderSun.SetValue(null, directional);
|
||||
_sunWasNull = true;
|
||||
Mod.Log.Msg($"[royale-night] RenderSettings.sun ← '{ObjectName(directional)}'");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureSun failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
// Swap the mood asset's FogSettings for a block captured from the
|
||||
// scene's current RenderSettings so applying the mood leaves fog as-is.
|
||||
// The asset is shared for the session, so RestoreFog puts the original
|
||||
// block back when we leave or toggle off.
|
||||
private static void PreserveFog(object mood)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fogSettingsType is null || _fogCapture is null || _moodFog is null || !_moodFog.CanWrite)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] fog preservation unavailable (FogMoodSettings/NLDDIDEIEFH/FogSettings unresolved) — mood fog will apply");
|
||||
return;
|
||||
}
|
||||
if (ReferenceEquals(_fogPatchedMood, mood) && _originalFog is not null) return;
|
||||
|
||||
var captured = Activator.CreateInstance(_fogSettingsType);
|
||||
if (captured is null) return;
|
||||
_fogCapture.Invoke(captured, null);
|
||||
if (Mod.Cfg.RecRoyaleNightDisableFog)
|
||||
{
|
||||
// The block has no exposed enabled flag; push it out of range so
|
||||
// even if the blender re-enables fog each frame it is invisible.
|
||||
SetMember(captured, "startDistance", 1_000_000f);
|
||||
SetMember(captured, "fadeDistance", 1_000_000f);
|
||||
}
|
||||
|
||||
_originalFog = _moodFog.GetValue(mood);
|
||||
_fogPatchedMood = mood;
|
||||
_moodFog.SetValue(mood, captured);
|
||||
Mod.Log.Msg("[royale-night] mood fog replaced with the scene's own fog settings");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PreserveFog failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreFog()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fogPatchedMood is not null && _originalFog is not null && _moodFog is not null && _moodFog.CanWrite)
|
||||
_moodFog.SetValue(_fogPatchedMood, _originalFog);
|
||||
}
|
||||
catch { }
|
||||
_fogPatchedMood = null;
|
||||
_originalFog = null;
|
||||
}
|
||||
|
||||
private static void RestoreMoodPatches()
|
||||
{
|
||||
RestoreFog();
|
||||
RestoreDarkness();
|
||||
RestoreOtherSunLights();
|
||||
// Scene objects die with the scene; just forget them.
|
||||
_probed = false;
|
||||
_hidden.Clear();
|
||||
_hideScanned = false;
|
||||
_fogStateSaved = false;
|
||||
_origFogEnabled = null;
|
||||
// The scene's SceneMoodSettings dies with the scene; just forget it.
|
||||
_sceneMoodInstalled = false;
|
||||
_sceneMoodsObj = null;
|
||||
_origDefaultMood = _origAvailableMoods = null;
|
||||
}
|
||||
|
||||
// The engine drives exactly one light: RenderSettings.sun (Frontier:
|
||||
// "SkyboxLight", the sun-disc light). The map's actual key light is a
|
||||
// separate directional Light it never touches, so daylight keeps
|
||||
// casting. Copy the engine-driven sun's rotation and colour onto every
|
||||
// other directional light and scale their intensity down. Originals are
|
||||
// kept per light and restored on toggle-off / scene change.
|
||||
private sealed class LightState
|
||||
{
|
||||
public object Light = null!;
|
||||
public object? Intensity, Color, Rotation, ShadowStrength;
|
||||
}
|
||||
private static readonly List<LightState> _otherSuns = new();
|
||||
|
||||
private static void MatchOtherSunLights()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderSun is null || _lightType is null) return;
|
||||
var sun = _renderSun.GetValue(null);
|
||||
if (sun is null || IsUnityNull(sun)) return;
|
||||
var sunPtr = (sun as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
|
||||
var sunTransform = GetMember(sun, "transform");
|
||||
var sunRotation = sunTransform is null ? null : GetMember(sunTransform, "rotation");
|
||||
var sunColor = GetMember(sun, "color");
|
||||
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppLight = Il2CppTypeOf(_lightType);
|
||||
if (unityObject is null || il2cppLight is null) return;
|
||||
|
||||
MethodInfo? findAll = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||
if (findAll is null) return;
|
||||
|
||||
var arr = findAll.Invoke(null, new object[] { il2cppLight });
|
||||
var n = Count(arr);
|
||||
var touched = new List<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var light = AsType(item, _lightType);
|
||||
if (light is null) continue;
|
||||
var kind = GetMember(light, "type");
|
||||
if (kind is null || Convert.ToInt32(kind) != 1) continue; // Directional only
|
||||
var ptr = (light as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
|
||||
if (ptr != IntPtr.Zero && ptr == sunPtr) continue; // engine already drives this one
|
||||
|
||||
var state = _otherSuns.Find(s => (s.Light as Il2CppObjectBase)?.Pointer == ptr);
|
||||
if (state is null)
|
||||
{
|
||||
state = new LightState
|
||||
{
|
||||
Light = light,
|
||||
Intensity = GetMember(light, "intensity"),
|
||||
Color = GetMember(light, "color"),
|
||||
ShadowStrength = GetMember(light, "shadowStrength"),
|
||||
Rotation = GetMember(light, "transform") is object tr ? GetMember(tr, "rotation") : null,
|
||||
};
|
||||
_otherSuns.Add(state);
|
||||
}
|
||||
|
||||
if (state.Intensity is float orig)
|
||||
SetMember(light, "intensity", orig * Mod.Cfg.RecRoyaleNightSunScale);
|
||||
if (sunColor is not null) SetMember(light, "color", sunColor);
|
||||
if (sunRotation is not null && GetMember(light, "transform") is object t)
|
||||
SetMember(t, "rotation", sunRotation);
|
||||
touched.Add(ObjectName(light));
|
||||
}
|
||||
if (touched.Count > 0 && !_otherSunsLogged)
|
||||
{
|
||||
_otherSunsLogged = true;
|
||||
Mod.Log.Msg($"[royale-night] matched {touched.Count} other directional light(s) to the mood sun: {string.Join(", ", touched)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] MatchOtherSunLights failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
private static bool _otherSunsLogged;
|
||||
|
||||
// Register the mood as the scene's defaultMood + sole availableMoods entry
|
||||
// (get_CurrentMood returns availableMoods[index] when the synced index is
|
||||
// set, else defaultMood — either way our mood). Originals are kept so
|
||||
// UninstallSceneMood can put the scene back to "no room moods".
|
||||
private static object? _sceneMoodsObj;
|
||||
private static object? _origDefaultMood, _origAvailableMoods;
|
||||
private static bool _sceneMoodInstalled;
|
||||
|
||||
private static void InstallSceneMood(object moods, object mood)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_sceneMoodInstalled && ReferenceEquals(_sceneMoodsObj, moods)) return;
|
||||
_sceneMoodsObj = moods;
|
||||
_origDefaultMood = GetMember(moods, "defaultMood");
|
||||
_origAvailableMoods = GetMember(moods, "availableMoods");
|
||||
|
||||
var okDefault = SetMember(moods, "defaultMood", mood);
|
||||
|
||||
// availableMoods is an Il2CppReferenceArray<MoodSetting>; build a
|
||||
// one-element array of the same interop type and fill slot 0.
|
||||
var okArray = false;
|
||||
var arrProp = moods.GetType().GetProperty("availableMoods", Any);
|
||||
var arrType = arrProp?.PropertyType ?? _origAvailableMoods?.GetType();
|
||||
if (arrType is not null)
|
||||
{
|
||||
object? arr = null;
|
||||
foreach (var sig in new[] { new[] { typeof(long) }, new[] { typeof(int) } })
|
||||
{
|
||||
var ctor = arrType.GetConstructor(sig);
|
||||
if (ctor is null) continue;
|
||||
try { arr = ctor.Invoke(new object[] { sig[0] == typeof(long) ? 1L : 1 }); break; } catch { }
|
||||
}
|
||||
if (arr is not null)
|
||||
{
|
||||
var set = arrType.GetMethod("set_Item", new[] { typeof(int), _moodSettingType! });
|
||||
if (set is null)
|
||||
{
|
||||
foreach (var m in arrType.GetMethods())
|
||||
if (m.Name == "set_Item" && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(int)) { set = m; break; }
|
||||
}
|
||||
try { set?.Invoke(arr, new[] { 0, mood }); okArray = set is not null && SetMember(moods, "availableMoods", arr); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
_sceneMoodInstalled = okDefault || okArray;
|
||||
Mod.Log.Msg($"[royale-night] installed mood as scene default (defaultMood={okDefault} availableMoods={okArray})");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] InstallSceneMood failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void UninstallSceneMood(object moods)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_sceneMoodInstalled) return;
|
||||
if (_origDefaultMood is not null) SetMember(moods, "defaultMood", _origDefaultMood);
|
||||
else SetMember(moods, "defaultMood", null!);
|
||||
if (_origAvailableMoods is not null) SetMember(moods, "availableMoods", _origAvailableMoods);
|
||||
}
|
||||
catch { }
|
||||
_sceneMoodInstalled = false;
|
||||
_sceneMoodsObj = null;
|
||||
_origDefaultMood = _origAvailableMoods = null;
|
||||
}
|
||||
|
||||
// ── fog off / hide objects ────────────────────────────────────────
|
||||
private static bool _fogStateSaved;
|
||||
private static object? _origFogEnabled;
|
||||
private static Type? _renderSettingsType;
|
||||
|
||||
private static void ForceFogOff()
|
||||
{
|
||||
try
|
||||
{
|
||||
_renderSettingsType ??= Mod.ResolveType("UnityEngine.RenderSettings");
|
||||
if (_renderSettingsType is null) return;
|
||||
var p = _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static);
|
||||
if (p is null) return;
|
||||
if (!_fogStateSaved) { _origFogEnabled = p.GetValue(null); _fogStateSaved = true; }
|
||||
if (p.GetValue(null) is true) p.SetValue(null, false);
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] ForceFogOff failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreFogState()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_fogStateSaved && _renderSettingsType is not null && _origFogEnabled is bool b)
|
||||
_renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static)?.SetValue(null, b);
|
||||
}
|
||||
catch { }
|
||||
_fogStateSaved = false;
|
||||
_origFogEnabled = null;
|
||||
}
|
||||
|
||||
// Disable every Renderer whose GameObject or material name contains one
|
||||
// of Cfg.RecRoyaleNightHideObjects. Runs each apply cycle but only scans
|
||||
// once per scene (the matches are static scenery); ShowObjects re-enables.
|
||||
private static readonly List<object> _hidden = new();
|
||||
private static bool _hideScanned;
|
||||
|
||||
private static void HideObjects()
|
||||
{
|
||||
if (_hideScanned) return;
|
||||
_hideScanned = true;
|
||||
try
|
||||
{
|
||||
var patterns = new List<string>();
|
||||
foreach (var s in Mod.Cfg.RecRoyaleNightHideObjects)
|
||||
if (!string.IsNullOrWhiteSpace(s)) patterns.Add(s.Trim());
|
||||
if (patterns.Count == 0) return;
|
||||
|
||||
var rendererType = Mod.ResolveType("UnityEngine.Renderer");
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppRenderer = rendererType is null ? null : Il2CppTypeOf(rendererType);
|
||||
if (rendererType is null || unityObject is null || il2cppRenderer is null) return;
|
||||
|
||||
MethodInfo? findAll = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||
if (findAll is null) return;
|
||||
|
||||
var arr = findAll.Invoke(null, new object[] { il2cppRenderer });
|
||||
var n = Count(arr);
|
||||
var names = new HashSet<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var r = AsType(item, rendererType);
|
||||
if (r is null) continue;
|
||||
var name = ObjectName(r);
|
||||
var matName = "";
|
||||
try { var mat = GetMember(r, "sharedMaterial"); if (mat is not null && !IsUnityNull(mat)) matName = ObjectName(mat); } catch { }
|
||||
var hit = false;
|
||||
foreach (var p in patterns)
|
||||
if (name.Contains(p, StringComparison.OrdinalIgnoreCase) || matName.Contains(p, StringComparison.OrdinalIgnoreCase)) { hit = true; break; }
|
||||
if (!hit) continue;
|
||||
if (GetMember(r, "enabled") is true && SetMember(r, "enabled", false))
|
||||
{
|
||||
_hidden.Add(r);
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
Mod.Log.Msg($"[royale-night] hid {_hidden.Count} renderer(s) matching [{string.Join(",", patterns)}]: {string.Join(", ", names)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] HideObjects failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void ShowObjects()
|
||||
{
|
||||
foreach (var r in _hidden)
|
||||
{
|
||||
try { if (!IsUnityNull(r)) SetMember(r, "enabled", true); } catch { }
|
||||
}
|
||||
_hidden.Clear();
|
||||
_hideScanned = false;
|
||||
}
|
||||
|
||||
// ── CV2 room-settings probe ───────────────────────────────────────
|
||||
// The Maker Pen mood chips ("Skydome Constant", "Room Skydome Modify",
|
||||
// Sun/Fog/Background Objects equivalents) drive RoomSkydome / RoomSun /
|
||||
// RoomFog components that hang off RoomElementSettingsManager, which
|
||||
// CircuitsV2Manager brings up. Log whether any of that exists in this
|
||||
// scene so we know what a chips-in-Rec-Royale build would have to add.
|
||||
private static bool _probed;
|
||||
private static void ProbeCircuitsSupport()
|
||||
{
|
||||
if (_probed) return;
|
||||
_probed = true;
|
||||
try
|
||||
{
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
if (unityObject is null) return;
|
||||
MethodInfo? findAll = null;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
|
||||
if (findAll is null) return;
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"RecRoom.CircuitsV2.CircuitsV2Manager",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomElementSettingsManager",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomSkydome",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomSun",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomFog",
|
||||
"RecRoom.CircuitsV2.RoomSettings.RoomBackgroundObjects",
|
||||
"MakerPen",
|
||||
})
|
||||
{
|
||||
var t = Mod.ResolveType(name);
|
||||
if (t is null) { parts.Add($"{name.Substring(name.LastIndexOf('.') + 1)}=<type missing>"); continue; }
|
||||
var il2 = Il2CppTypeOf(t);
|
||||
if (il2 is null) { parts.Add($"{t.Name}=?"); continue; }
|
||||
int n = 0;
|
||||
try { n = Count(findAll.Invoke(null, new object[] { il2 })); } catch (Exception ex) { parts.Add($"{t.Name}=err:{(ex.InnerException ?? ex).Message}"); continue; }
|
||||
parts.Add($"{t.Name}={n}");
|
||||
}
|
||||
Mod.Log.Msg($"[royale-night] CV2 probe in '{_sceneName}': {string.Join(" ", parts)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] CV2 probe failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreOtherSunLights()
|
||||
{
|
||||
foreach (var s in _otherSuns)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsUnityNull(s.Light)) continue;
|
||||
if (s.Intensity is not null) SetMember(s.Light, "intensity", s.Intensity);
|
||||
if (s.Color is not null) SetMember(s.Light, "color", s.Color);
|
||||
if (s.ShadowStrength is not null) SetMember(s.Light, "shadowStrength", s.ShadowStrength);
|
||||
if (s.Rotation is not null && GetMember(s.Light, "transform") is object t) SetMember(t, "rotation", s.Rotation);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
_otherSuns.Clear();
|
||||
_otherSunsLogged = false;
|
||||
}
|
||||
|
||||
// Make sure RenderSettings.skybox is a material on the "Custom/SkyBox"
|
||||
// shader. The apply step assigns MoodConfig.SkyBoxMaterial
|
||||
// (LNMDIIFOFPD.NHAEHKJINFH → SkyBoxMaterial); in RRO scenes that
|
||||
// reference resolves to nothing, leaving the skybox null. Prefer any
|
||||
// already-loaded material on that shader, else create one, and also
|
||||
// write it into MoodConfig so later applies keep using it.
|
||||
private static void EnsureMoodSkybox()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_renderSkybox is null || _materialType is null || _shaderType is null) return;
|
||||
|
||||
var current = _renderSkybox.GetValue(null);
|
||||
var currentShader = current is null || IsUnityNull(current) ? "" : ShaderNameOf(current);
|
||||
if (currentShader == "Custom/SkyBox") return;
|
||||
|
||||
object? mat = _ourSkybox is not null && !IsUnityNull(_ourSkybox) ? _ourSkybox : null;
|
||||
|
||||
// 1. Any loaded material already on the shader (e.g. the real
|
||||
// Skybox_Moods_Sunny_Default_Mat if some scene brought it in).
|
||||
if (mat is null)
|
||||
{
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var il2cppMat = Il2CppTypeOf(_materialType);
|
||||
if (resources is not null && il2cppMat is not null)
|
||||
{
|
||||
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
|
||||
var arr = m.Invoke(null, new object[] { il2cppMat });
|
||||
var n = Count(arr);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var wrapped = AsType(item, _materialType);
|
||||
if (wrapped is not null && ShaderNameOf(wrapped) == "Custom/SkyBox") { mat = wrapped; break; }
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mat is not null) Mod.Log.Msg($"[royale-night] using loaded skybox material '{ObjectName(mat)}'");
|
||||
}
|
||||
|
||||
// 2. Build one: new Material(Shader.Find("Custom/SkyBox")).
|
||||
if (mat is null)
|
||||
{
|
||||
var find = AccessTools.Method(_shaderType, "Find", new[] { typeof(string) });
|
||||
var shader = find?.Invoke(null, new object[] { "Custom/SkyBox" });
|
||||
if (shader is null || IsUnityNull(shader))
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] Shader.Find(\"Custom/SkyBox\") returned nothing — sky colours will not apply");
|
||||
return;
|
||||
}
|
||||
var ctor = _materialType.GetConstructor(new[] { _shaderType }) ?? _materialType.GetConstructor(new[] { shader.GetType() });
|
||||
if (ctor is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] Material(Shader) constructor not found");
|
||||
return;
|
||||
}
|
||||
mat = ctor.Invoke(new[] { shader });
|
||||
SetMember(mat, "name", "DorkNet_RecRoyaleNight_Skybox");
|
||||
Mod.Log.Msg("[royale-night] created skybox material on Custom/SkyBox");
|
||||
}
|
||||
|
||||
_ourSkybox = mat;
|
||||
_renderSkybox.SetValue(null, mat);
|
||||
|
||||
// Keep the engine's own apply path pointing at it too.
|
||||
if (_moodConfigHolder is not null)
|
||||
{
|
||||
foreach (var p in _moodConfigHolder.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (!p.CanRead || p.GetIndexParameters().Length != 0) continue;
|
||||
var cfg = p.GetValue(null);
|
||||
if (cfg is null || IsUnityNull(cfg)) continue;
|
||||
if (SetMember(cfg, "SkyBoxMaterial", mat)) Mod.Log.Msg("[royale-night] MoodConfig.SkyBoxMaterial ← our material");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureMoodSkybox failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static string ShaderNameOf(object material)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shader = GetMember(material, "shader");
|
||||
return shader is null || IsUnityNull(shader) ? "" : ObjectName(shader);
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static string DescribeSkybox()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sky = _renderSkybox?.GetValue(null);
|
||||
if (sky is null || IsUnityNull(sky)) return "<null>";
|
||||
return $"'{ObjectName(sky)}' [{ShaderNameOf(sky)}]";
|
||||
}
|
||||
catch { return "?"; }
|
||||
}
|
||||
|
||||
// Post-exposure lives in the URP Volume the mood engine drives. It only
|
||||
// shows if the Volume is enabled with weight and the camera renders
|
||||
// post-processing (Rec Room's graphics settings can turn that off).
|
||||
private static void EnsurePostProcessing(object moods)
|
||||
{
|
||||
try
|
||||
{
|
||||
var volume = Read(_volume, moods);
|
||||
if (volume is not null && !IsUnityNull(volume))
|
||||
{
|
||||
var enabled = GetMember(volume, "enabled");
|
||||
var weight = GetMember(volume, "weight");
|
||||
var isGlobal = GetMember(volume, "isGlobal");
|
||||
var profile = GetMember(volume, "profile");
|
||||
Mod.Log.Msg($"[royale-night] volume: enabled={enabled} weight={weight} global={isGlobal} profile={(profile is null ? "<null>" : ObjectName(profile))}");
|
||||
if (Mod.Cfg.RecRoyaleNightForcePostProcessing)
|
||||
{
|
||||
if (enabled is false) SetMember(volume, "enabled", true);
|
||||
if (weight is float w && w < 1f) SetMember(volume, "weight", 1f);
|
||||
}
|
||||
}
|
||||
else Mod.Log.Warning("[royale-night] scene mood Volume is null — post-exposure darkening unavailable");
|
||||
|
||||
if (_cameraDataType is null) { Mod.Log.Warning("[royale-night] UniversalAdditionalCameraData type not found"); return; }
|
||||
var unityObject = Mod.ResolveType("UnityEngine.Object");
|
||||
var il2cppCam = Il2CppTypeOf(_cameraDataType);
|
||||
if (unityObject is null || il2cppCam is null) return;
|
||||
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
|
||||
var arr = m.Invoke(null, new object[] { il2cppCam });
|
||||
var n = Count(arr);
|
||||
int forced = 0;
|
||||
var states = new List<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var cam = AsType(item, _cameraDataType);
|
||||
if (cam is null) continue;
|
||||
var pp = GetMember(cam, "renderPostProcessing");
|
||||
states.Add($"{ObjectName(cam)}={pp}");
|
||||
if (pp is false && Mod.Cfg.RecRoyaleNightForcePostProcessing && SetMember(cam, "renderPostProcessing", true)) forced++;
|
||||
}
|
||||
Mod.Log.Msg($"[royale-night] camera post-processing: {string.Join(", ", states)}{(forced > 0 ? $" → forced on ({forced})" : "")}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsurePostProcessing failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
// Darken the mood data in place before it is applied:
|
||||
// ColorGradingSettings.exposure += RecRoyaleNightExposure (EV; the
|
||||
// ColorGrading blender writes it into ColorAdjustments.postExposure
|
||||
// via VolumeParameter.Override — the only knob that also dims baked
|
||||
// lightmaps, since it's a full-frame post effect)
|
||||
// SunSettings.sunIntensity *= RecRoyaleNightSunScale
|
||||
// SkyBoxSettings.ambientLightIntensity *= RecRoyaleNightAmbientScale
|
||||
// Originals are kept and restored by RestoreDarkness so the shared asset
|
||||
// is clean again for any custom room that uses the same mood.
|
||||
private static void PatchDarkness(object mood)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ReferenceEquals(_darkPatchedMood, mood)) return;
|
||||
RestoreDarkness();
|
||||
|
||||
var grading = GetMember(mood, "ColorGradingSettings");
|
||||
var sun = GetMember(mood, "SunSettings");
|
||||
var sky = GetMember(mood, "SkyBoxSettings");
|
||||
|
||||
if (grading is not null && Mod.Cfg.RecRoyaleNightExposure != 0f && GetMember(grading, "exposure") is float ev)
|
||||
{
|
||||
_origExposure = ev;
|
||||
SetMember(grading, "exposure", ev + Mod.Cfg.RecRoyaleNightExposure);
|
||||
}
|
||||
if (sun is not null && Mod.Cfg.RecRoyaleNightSunScale != 1f && GetMember(sun, "sunIntensity") is float si)
|
||||
{
|
||||
_origSunIntensity = si;
|
||||
SetMember(sun, "sunIntensity", si * Mod.Cfg.RecRoyaleNightSunScale);
|
||||
}
|
||||
if (sky is not null && Mod.Cfg.RecRoyaleNightAmbientScale != 1f && GetMember(sky, "ambientLightIntensity") is float ai)
|
||||
{
|
||||
_origAmbient = ai;
|
||||
SetMember(sky, "ambientLightIntensity", ai * Mod.Cfg.RecRoyaleNightAmbientScale);
|
||||
}
|
||||
_darkPatchedMood = mood;
|
||||
Mod.Log.Msg($"[royale-night] darkened mood: exposure {_origExposure?.ToString("0.##") ?? "?"}→{(_origExposure + Mod.Cfg.RecRoyaleNightExposure)?.ToString("0.##") ?? "?"} EV, " +
|
||||
$"sun ×{Mod.Cfg.RecRoyaleNightSunScale} ({_origSunIntensity?.ToString("0.##") ?? "?"}), ambient ×{Mod.Cfg.RecRoyaleNightAmbientScale} ({_origAmbient?.ToString("0.##") ?? "?"})");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PatchDarkness failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
private static void RestoreDarkness()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_darkPatchedMood is not null)
|
||||
{
|
||||
var grading = GetMember(_darkPatchedMood, "ColorGradingSettings");
|
||||
var sun = GetMember(_darkPatchedMood, "SunSettings");
|
||||
var sky = GetMember(_darkPatchedMood, "SkyBoxSettings");
|
||||
if (grading is not null && _origExposure is float e) SetMember(grading, "exposure", e);
|
||||
if (sun is not null && _origSunIntensity is float s) SetMember(sun, "sunIntensity", s);
|
||||
if (sky is not null && _origAmbient is float a) SetMember(sky, "ambientLightIntensity", a);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_darkPatchedMood = null;
|
||||
_origExposure = _origSunIntensity = _origAmbient = null;
|
||||
}
|
||||
|
||||
private static bool SetMember(object o, string name, object value)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, Any);
|
||||
if (p is not null && p.CanWrite) { try { p.SetValue(o, value); return true; } catch { } }
|
||||
var f = t.GetField(name, Any);
|
||||
if (f is not null) { try { f.SetValue(o, value); return true; } catch { } }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Il2CppInterop exposes il2cpp fields as properties (and, on some
|
||||
// versions, as fields). Read either.
|
||||
private static object? GetMember(object o, string name)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, Any);
|
||||
if (p is not null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return p.GetValue(o); } catch { } }
|
||||
var f = t.GetField(name, Any);
|
||||
if (f is not null) { try { return f.GetValue(o); } catch { } }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Il2CppType.From(Type) → Il2CppSystem.Type, via reflection so this
|
||||
// project keeps not referencing Il2Cppmscorlib at compile time.
|
||||
private static object? Il2CppTypeOf(Type type)
|
||||
{
|
||||
foreach (var m in typeof(Il2CppType).GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "From" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length >= 1 && ps[0].ParameterType == typeof(Type))
|
||||
{
|
||||
var args = new object?[ps.Length];
|
||||
args[0] = type;
|
||||
for (int i = 1; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null);
|
||||
var r = m.Invoke(null, args);
|
||||
if (r is not null) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? GetSceneManager()
|
||||
{
|
||||
var inst = _sceneManagerInstance?.GetValue(null);
|
||||
if (inst is null || IsUnityNull(inst)) return null;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// Loads the configured MoodSetting asset. Resources.Load(path, Type)
|
||||
// pulls it out of resources.assets by name (the ResourceManager table
|
||||
// keys are the lower-cased asset names, no folder). Fallback:
|
||||
// Resources.FindObjectsOfTypeAll(MoodSetting) by name, which only sees
|
||||
// assets already in memory.
|
||||
private static object? GetNightMood()
|
||||
{
|
||||
var wanted = (Mod.Cfg.RecRoyaleNightMood ?? "").Trim();
|
||||
if (wanted.Length == 0) wanted = "Night_Calm_Outdoor_Mood";
|
||||
if (_nightMood is not null && _nightMoodLoadedName == wanted && !IsUnityNull(_nightMood))
|
||||
return _nightMood;
|
||||
|
||||
_nightMood = null;
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
if (resources is null || _moodSettingType is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] UnityEngine.Resources not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var il2cppType = Il2CppTypeOf(_moodSettingType);
|
||||
if (il2cppType is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] Il2CppType.From(Type) not found");
|
||||
return null;
|
||||
}
|
||||
object? found = null;
|
||||
|
||||
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "Load" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 2 || ps[0].ParameterType != typeof(string)) continue;
|
||||
try
|
||||
{
|
||||
found = m.Invoke(null, new object[] { wanted, il2cppType });
|
||||
if (found is not null && !IsUnityNull(found)) break;
|
||||
found = null;
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] Resources.Load failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
}
|
||||
|
||||
if (found is null)
|
||||
{
|
||||
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1) continue;
|
||||
try
|
||||
{
|
||||
var arr = m.Invoke(null, new object[] { il2cppType });
|
||||
var n = Count(arr);
|
||||
var names = new List<string>();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var item = Item(arr, i);
|
||||
if (item is null) continue;
|
||||
var name = ObjectName(item);
|
||||
names.Add(name);
|
||||
if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) { found = item; break; }
|
||||
}
|
||||
if (found is null)
|
||||
Mod.Log.Warning($"[royale-night] mood '{wanted}' not in memory; loaded moods: {string.Join(", ", names)}");
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[royale-night] FindObjectsOfTypeAll failed: {(ex.InnerException ?? ex).Message}"); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found is null)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] could not load MoodSetting '{wanted}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resources.Load hands back a UnityEngine.Object wrapper; re-wrap the
|
||||
// same il2cpp pointer as MoodSetting so the apply method accepts it.
|
||||
_nightMood = AsType(found, _moodSettingType);
|
||||
_nightMoodLoadedName = wanted;
|
||||
return _nightMood;
|
||||
}
|
||||
|
||||
private static object? AsType(object obj, Type target)
|
||||
{
|
||||
if (target.IsInstanceOfType(obj)) return obj;
|
||||
if (obj is Il2CppObjectBase il2cpp)
|
||||
{
|
||||
var ctor = target.GetConstructor(new[] { typeof(IntPtr) });
|
||||
if (ctor is not null) return ctor.Invoke(new object[] { il2cpp.Pointer });
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ── hotkey ────────────────────────────────────────────────────────
|
||||
private static void PollHotkey()
|
||||
{
|
||||
var key = Mod.Cfg.RecRoyaleNightToggleKey;
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
try
|
||||
{
|
||||
if (!_inputResolved) ResolveInput(key);
|
||||
if (_getKeyDown is null || _toggleKey is null) return;
|
||||
if (_getKeyDown.Invoke(null, new[] { _toggleKey }) is true) Toggle();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] hotkey poll failed: {ex.Message}");
|
||||
_getKeyDown = null; // stop spamming
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveInput(string key)
|
||||
{
|
||||
_inputResolved = true;
|
||||
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
|
||||
var inputType = Mod.ResolveType("UnityEngine.Input");
|
||||
if (keyCodeType is null || inputType is null)
|
||||
{
|
||||
Mod.Log.Warning("[royale-night] UnityEngine.Input/KeyCode not found — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
try { _toggleKey = Enum.Parse(keyCodeType, key, ignoreCase: true); }
|
||||
catch
|
||||
{
|
||||
Mod.Log.Warning($"[royale-night] '{key}' is not a UnityEngine.KeyCode — hotkey disabled");
|
||||
return;
|
||||
}
|
||||
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
|
||||
if (_getKeyDown is null) Mod.Log.Warning("[royale-night] Input.GetKeyDown(KeyCode) not found — hotkey disabled");
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────
|
||||
private static bool MatchesScene(string sceneName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sceneName)) return false;
|
||||
foreach (var s in Mod.Cfg.RecRoyaleNightScenes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) continue;
|
||||
if (sceneName.Contains(s.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// UnityEngine.Object wrappers are non-null C# objects even when the
|
||||
// native object is destroyed/missing; `== null` is overloaded in the
|
||||
// interop, so route through the static op_Equality when present.
|
||||
private static bool IsUnityNull(object obj)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = obj.GetType();
|
||||
var eq = t.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
|
||||
if (eq is not null && eq.GetParameters().Length == 2)
|
||||
return eq.Invoke(null, new[] { obj, null }) is true;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ObjectName(object obj)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = obj.GetType().GetProperty("name", Any);
|
||||
return p?.GetValue(obj) as string ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static string Describe(object mood)
|
||||
{
|
||||
var friendly = "";
|
||||
try { friendly = _friendlyName?.GetValue(mood) as string ?? ""; } catch { }
|
||||
var name = ObjectName(mood);
|
||||
return friendly.Length > 0 ? $"{friendly} ({name})" : name;
|
||||
}
|
||||
|
||||
private static int Count(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var p = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (p?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? Item(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,18 @@
|
||||
"GoldenTrophy",
|
||||
"IsleOfLostSkulls",
|
||||
"TheRiseofJumbotron"
|
||||
]
|
||||
],
|
||||
"RecRoyaleNightMode": false,
|
||||
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
|
||||
"RecRoyaleNightScenes": ["RecRoyale"],
|
||||
"RecRoyaleNightFlashlights": true,
|
||||
"RecRoyaleNightFog": false,
|
||||
"RecRoyaleNightExposure": -3.5,
|
||||
"RecRoyaleNightSunScale": 0.15,
|
||||
"RecRoyaleNightAmbientScale": 0.2,
|
||||
"RecRoyaleNightForcePostProcessing": true,
|
||||
"RecRoyaleNightMatchSunLights": true,
|
||||
"RecRoyaleNightDisableFog": false,
|
||||
"RecRoyaleNightHideObjects": ["Cloud"],
|
||||
"RecRoyaleNightToggleKey": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
/// <summary>
|
||||
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
|
||||
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
|
||||
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
|
||||
/// reads that as "no room data at all": no persistence views, no room-role
|
||||
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
|
||||
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
|
||||
/// in the scene — so DorkNet needs a switch that gives such a room one.
|
||||
///
|
||||
/// The property pinned here: the admin "enable blob" action stamps the
|
||||
/// synthetic default name, that name flows through the game-client details
|
||||
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
|
||||
/// map, and only additionally fetches the blob), the CDN answers that name
|
||||
/// with the RRO-editable blob (permissive room roles, no objects), and
|
||||
/// "reset" puts everything back to scene-only.
|
||||
/// </summary>
|
||||
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
|
||||
// Shape of a seeded baked original: system-owned, AG-flagged,
|
||||
// tagged recroomoriginal, no blob anywhere.
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"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<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Saved{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
CurrentDataBlobName = saved,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.False(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
}
|
||||
|
||||
private static JsonElement SubRoomZero(JsonElement details) =>
|
||||
details.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.PostAsync(path, content: null);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
|
||||
/// editing: the synthetic default for the entry scene (the same name the
|
||||
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
|
||||
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
|
||||
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
|
||||
subRoomId == 0
|
||||
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
|
||||
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
|
||||
/// — give a baked Rec Room Original sub-room a data blob so the client
|
||||
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
|
||||
///
|
||||
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
|
||||
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
|
||||
/// builder + matchmaking resolver turns that into an empty
|
||||
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
|
||||
/// no persistence views, no room-role overlay, no Maker Pen, no room
|
||||
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
|
||||
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
|
||||
/// it, not in the scene). Stamping the synthetic default name is enough:
|
||||
/// every resolver returns it verbatim, and <c>CdnController</c> already
|
||||
/// answers that name for AG rooms with the RRO-editable blob (permissive
|
||||
/// room roles, no objects) when nothing has been saved yet. The first
|
||||
/// in-game save then replaces the name with a real upload.
|
||||
///
|
||||
/// No-op when the sub-room already has a blob. Idempotent.</summary>
|
||||
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
|
||||
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||
if (scene is null) 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));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
|
||||
/// — clear the sub-room's data blob reference. For a baked Rec Room
|
||||
/// Original that puts the room back to "scene only, no blob" (the escape
|
||||
/// hatch if a save left the room unplayable); for any other room the
|
||||
/// resolvers fall back to the synthetic default, i.e. an empty room.
|
||||
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
|
||||
/// the in-game restore list can bring an old save back.</summary>
|
||||
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
|
||||
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||
if (scene is null) 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);
|
||||
|
||||
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
|
||||
|
||||
@@ -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<SubRoom[]>(`/rooms/${roomId}/subrooms`);
|
||||
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||
const [saving, setSaving] = useState<number | null>(null);
|
||||
const [blobBusy, setBlobBusy] = useState<number | null>(null);
|
||||
const [resetArmed, setResetArmed] = useState<number | null>(null);
|
||||
|
||||
// Baked Rec Room Originals ship with no data blob, so the client never
|
||||
// runs its room-data path there (no Maker Pen, no CV2 chips, no saves).
|
||||
// "Enable" stamps the synthetic default name the CDN already answers with
|
||||
// the RRO-editable blob; "Reset" clears the pointers back to scene-only.
|
||||
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 && <span className="badge-neutral">Sandbox</span>}
|
||||
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
|
||||
</div>
|
||||
{sub.dataBlobName && (
|
||||
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
|
||||
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">
|
||||
{sub.effectiveBlobName
|
||||
? sub.effectiveBlobName
|
||||
: sub.isBakedOriginal
|
||||
? 'no data blob — baked scene only (no Maker Pen / chips)'
|
||||
: '(synthetic default blob)'}
|
||||
</div>
|
||||
{sub.isBakedOriginal && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
{!sub.dataBlobName ? (
|
||||
<button
|
||||
onClick={() => setBlob(sub, 'enable')}
|
||||
disabled={blobBusy === sub.subRoomId}
|
||||
className="btn-secondary text-xs"
|
||||
title="Serve a room data blob for this baked sub-room so Maker Pen, CV2 chips and saves work. The first in-game save replaces it with a real upload."
|
||||
>
|
||||
{blobBusy === sub.subRoomId ? 'Enabling…' : 'Enable Maker Pen blob'}
|
||||
</button>
|
||||
) : resetArmed === sub.subRoomId ? (
|
||||
<>
|
||||
<span className="text-xs text-danger">Drops the blob pointer; saved blobs stay restorable.</span>
|
||||
<button
|
||||
onClick={() => setBlob(sub, 'reset')}
|
||||
disabled={blobBusy === sub.subRoomId}
|
||||
className="btn-danger text-xs"
|
||||
>
|
||||
{blobBusy === sub.subRoomId ? 'Resetting…' : 'Confirm reset'}
|
||||
</button>
|
||||
<button onClick={() => setResetArmed(null)} className="btn-secondary text-xs">Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setResetArmed(sub.subRoomId)}
|
||||
className="btn-secondary text-xs"
|
||||
title="Go back to the baked scene with no data blob (escape hatch if a save broke the room)."
|
||||
>
|
||||
Reset to baked scene
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# Rec Room 2023-03-21 — scene moods (sky dome / sun / fog) and Rec Royale night mode
|
||||
|
||||
Client-side reference for the room "mood" engine the March-2023 client uses
|
||||
for Maker Pen sky/sun/fog settings, and how `DorkNet.ClientMod`
|
||||
(`RecRoyaleNight.cs`) reuses it to give Rec Royale a night variant the game
|
||||
never shipped. Everything below was read from the Cpp2IL ISIL dump
|
||||
(`C:\tmp\recroom-2023-03-21-isil`), the interop stubs
|
||||
(`C:\tmp\recroom-2023-03-21-decompiled`) and the built game data
|
||||
(`RecRoom_Data\resources.assets`, `globalgamemanagers`).
|
||||
|
||||
## Ownership chain
|
||||
|
||||
| Object | Where | Notes |
|
||||
|---|---|---|
|
||||
| `RecRoomSceneManager` | `MonoBehaviourPun`, one per room scene | static `get_Instance` (interop property name obfuscated: `PDCOCJBICGC`); name-preserved field `sceneMoodSettings` |
|
||||
| `RecRoom.Core.Scene.SceneMoodSettings` | plain class held by the scene manager | initialised by `PCJGOACJEDL(RecRoomSceneManager)` at scene start |
|
||||
| `RecRoom.Core.Creation.SceneMoods.MoodSetting` | `ScriptableObject` asset | `FriendlyName`, `SkyBoxSettings`, `SunSettings`, `FogSettings`, `BloomSettings`, `ColorGradingSettings`, `BackgroundObjectsSettings`, `VolumeProfile`, `BakedReflection`, `guid` |
|
||||
|
||||
`SceneMoodSettings` name-preserved members: `availableMoods`
|
||||
(`MoodSetting[]`), `defaultMood`, `CurrentMood`, `PrevMood`,
|
||||
`CurrentRoomMoodIndex`, `SupportsMoodCircuits`, `HasRecRoomSkyboxShader`,
|
||||
`HasSunSource`, `Volume`, `DefaultVolumeProfile`, `ReflectionIntensity`,
|
||||
`AmbientLightIntensity`, plus one `PFFMDAJPHJO<T>` blender per settings
|
||||
block (`SkyBoxMoodSettingsBlender`, `SunMoodSettingsBlender`, …).
|
||||
|
||||
## Apply path (what actually changes the picture)
|
||||
|
||||
`SceneMoodSettings.MLLBALMKEGJ(MoodSetting)` — ISIL
|
||||
`RecRoom/Core/Scene/SceneMoodSettings.txt:3025-3581`:
|
||||
|
||||
1. `ALCMNGKOEBK()` — pushes the mood's six settings blocks into the blenders
|
||||
as the "top" data (6× generic blender set call).
|
||||
2. `OEKNGEMDMDB(VolumeProfile)` — swaps the URP post-processing profile.
|
||||
3. `RenderSettings.set_skybox` — the shared Rec Room skybox material; the
|
||||
`SkyBoxMoodSettings` blender then writes `_SkyTopColor`-style shader
|
||||
props into it (`SkyBoxMoodSettings.FPKCBLPBOFN(Material)`).
|
||||
4. `RenderSettings.get_sun` → re-aims/recolours the sun (`SunMoodSettings`,
|
||||
only when `HasSunSource`).
|
||||
5. `ReflectionProbe.set_customBakedTexture` ← `MoodSetting.BakedReflection`.
|
||||
6. 6× blender force-update.
|
||||
|
||||
It does **not** write `CurrentRoomMoodIndex`, so calling it is local only.
|
||||
|
||||
### RRO scenes: the engine starts disabled
|
||||
|
||||
`SceneMoodSettings.isEnabled` (+0x38) gates everything: `get_CurrentMood`
|
||||
returns null while it is false, `get_RoomMoodsEnabled` is
|
||||
`isEnabled && defaultMood != null`, and nothing ticks. The flag is only
|
||||
switched on by the room data-blob loader (`EIMFCPIHEPF.JPKEMACDNCF`, ISIL
|
||||
`EIMFCPIHEPF.txt:2136-2310`), which custom rooms run and RRO scenes (no
|
||||
data blob) never do. Its bring-up sequence:
|
||||
|
||||
| Call | Meaning |
|
||||
|---|---|
|
||||
| `LKIKMLNPCFK(false)` | SetEnabled(false): applies a null mood (blenders reset, default VolumeProfile back) and toggles `BackgroundObjectsRoot` |
|
||||
| `LIEDFOLBGHL()` | Capture: builds the blenders' "previous" data from `RenderSettings.skybox` / `sun` / fog; sets `hasRecRoomSkyboxShader` = skybox shader name `== "Custom/SkyBox"` |
|
||||
| `LKIKMLNPCFK(true)` | SetEnabled(true) |
|
||||
|
||||
The apply step then swaps `RenderSettings.skybox` to the global
|
||||
`MoodConfig` skybox material (`LNMDIIFOFPD.NHAEHKJINFH`, +0x18).
|
||||
The ClientMod replays this exact sequence when it finds `isEnabled` false.
|
||||
|
||||
**The apply step only sets blend targets.** The per-frame update
|
||||
`CEKLEBHDAHA` (ISIL :3895-4031) is what actually writes sky colours,
|
||||
rotates/recolours the sun, blends fog and background objects and (via
|
||||
`CPJBHLMCBBC`) pushes bloom/colour grading into the Volume — and it
|
||||
returns immediately while `get_CurrentMood()` is null. `get_CurrentMood`
|
||||
(ISIL :297-457) returns null when `isEnabled` is false, else
|
||||
`_studioRoomUnityMood` if set, else `availableMoods[index]` when the
|
||||
synced index is set, else `defaultMood`. RRO scenes have `defaultMood`
|
||||
null and `availableMoods` empty, so even a fully enabled engine with a
|
||||
successful apply shows only the ungated background objects (moon, stars).
|
||||
The ClientMod therefore writes the night mood into `defaultMood` and a
|
||||
one-element `availableMoods` before enabling the engine.
|
||||
|
||||
Two flags captured by `LIEDFOLBGHL` gate what the apply step and the
|
||||
per-frame update are allowed to touch:
|
||||
|
||||
| Flag | Set by capture from | Gates |
|
||||
|---|---|---|
|
||||
| `hasRecRoomSkyboxShader` (+0xA8) | `RenderSettings.skybox.shader.name == "Custom/SkyBox"` | sky colour blender apply |
|
||||
| `hasSunSource` (+0xA9) | `RenderSettings.sun != null` | sun direction / colour / intensity |
|
||||
|
||||
Note the sun flag is nested inside the sky check: `hasSunSource` can only
|
||||
become true when the skybox shader test already passed (ISIL :2629-2705).
|
||||
|
||||
`RecRoyale_Frontier` fails both at capture time: its skybox is not the
|
||||
mood shader. The observable result of a naive apply is exactly "moon and
|
||||
stars appear, sky and sun unchanged" — the mood's
|
||||
`BackgroundObjectsSettings` are not gated.
|
||||
|
||||
Worse, the apply step's own skybox swap does not help in RRO scenes: it
|
||||
assigns `MoodConfig.SkyBoxMaterial`, and that material
|
||||
(`Skybox_Moods_Sunny_Default_Mat`, sharedassets77.assets — a custom-room
|
||||
scene's shared assets) is not loaded there, so `RenderSettings.skybox`
|
||||
becomes null (plain black sky) and both flags stay false forever. The
|
||||
`Custom/SkyBox` shader itself is in resources.assets, so
|
||||
`new Material(Shader.Find("Custom/SkyBox"))` works anywhere.
|
||||
|
||||
Workarounds used by the ClientMod: assign the scene's directional `Light`
|
||||
to `RenderSettings.sun` if unset; apply once; then put a material on the
|
||||
`Custom/SkyBox` shader into `RenderSettings.skybox` and
|
||||
`MoodConfig.SkyBoxMaterial`; capture again so both flags flip true; then
|
||||
apply for real. Post-exposure additionally needs the scene `Volume`
|
||||
enabled with weight 1 and `UniversalAdditionalCameraData.renderPostProcessing`
|
||||
on (`RecRoyaleNightForcePostProcessing`). `LILOMACBFPI()` (:2789-2817) force-sets
|
||||
both flags to true (writes 0x0101 at +0xA8) and clears
|
||||
`_studioRoomUnityMood`; it is the Studio path and not used here because
|
||||
a true `hasSunSource` with a null sun would NRE in the update loop.
|
||||
|
||||
Fog: `FogMoodSettings.NLDDIDEIEFH()` reads `RenderSettings.fog`,
|
||||
`fogStartDistance`, `fogEndDistance`, `fogColor` into the block;
|
||||
`KGBEBONMIII()` writes them back. Swapping `MoodSetting.FogSettings` for
|
||||
a freshly captured block before the first apply keeps the scene's fog
|
||||
(`RecRoyaleNightFog: false`). The block exposes only `color`,
|
||||
`startDistance`, `fadeDistance` (no enabled flag), so "fog off"
|
||||
(`RecRoyaleNightDisableFog`) pushes both distances to 1e6 and forces
|
||||
`RenderSettings.fog = false` after each apply.
|
||||
|
||||
Frontier's daytime clouds are scene meshes, not mood background objects:
|
||||
`CloudPlane` ×19 under `Clouds`, static-batched as
|
||||
`StaticObjectsStaticBatch_Cloud_RecRoyale_Frontier_Mat` (level106).
|
||||
`RecRoyaleNightHideObjects` disables renderers whose object or
|
||||
`sharedMaterial` name contains a pattern (default `Cloud`).
|
||||
|
||||
Brightness: the mood blocks are plain data the ClientMod edits in place
|
||||
before the apply (originals restored on scene exit / toggle-off):
|
||||
|
||||
| Block · field | Applied as | ClientMod knob |
|
||||
|---|---|---|
|
||||
| `ColorGradingMoodSettings.exposure` | `ColorAdjustments.postExposure` via `VolumeParameter<float>.Override` in `KGBEBONMIII` | `RecRoyaleNightExposure` (EV offset) — the only knob that dims baked lightmaps |
|
||||
| `SunMoodSettings.sunIntensity` | `RenderSettings.sun.intensity` (needs `hasSunSource`) | `RecRoyaleNightSunScale` (×) |
|
||||
| — | other directional `Light`s (never touched by the engine; Frontier's `RenderSettings.sun` is `SkyboxLight`, the sun-disc light, not the key light) | `RecRoyaleNightMatchSunLights`: copy the driven sun's rotation/colour, intensity × `RecRoyaleNightSunScale` |
|
||||
| `SkyBoxMoodSettings.ambientLightIntensity` | `RenderSettings.ambientIntensity` | `RecRoyaleNightAmbientScale` (×) |
|
||||
|
||||
Other `ColorGradingMoodSettings` fields: `color`, `temperature`, `tint`,
|
||||
`hueShift`, `saturation`, `contrast`, `colorFilterIntensity`.
|
||||
`BloomMoodSettings`: `color`, `diffusion`, `intensity`, `threshold`.
|
||||
|
||||
### Networked path (do not use for a client-side effect)
|
||||
|
||||
`SceneMoodSettings.IPAMPMBDPNB(Guid)` scans `availableMoods` for the GUID
|
||||
and calls `set_CurrentRoomMoodIndex`. `_currentRoomMoodIndex` is a
|
||||
`DFAOKLDJPEL<int>` from `RecRoom.Networking.SynchronizedFields.Runtime`,
|
||||
i.e. a synchronized field that replicates to everyone in the instance.
|
||||
CircuitsV2 room-settings chips (`RecRoom.CircuitsV2.RoomSettings.RoomSkydome`,
|
||||
`RoomSun`, `RoomFog`, all `RoomMoodBase<T,U,V>`) drive the same engine
|
||||
through `ModifyMood(...)` and are also networked.
|
||||
|
||||
## Shipped mood assets
|
||||
|
||||
All live in `resources.assets` and are registered in the `globalgamemanagers`
|
||||
ResourceManager table under their lower-cased asset name (no folder), so
|
||||
`Resources.Load("<name>", typeof(MoodSetting))` works.
|
||||
|
||||
| Asset | `FriendlyName` |
|
||||
|---|---|
|
||||
| `Night_Calm_Outdoor_Mood` | Calm Night |
|
||||
| `Night_Spooky_Outdoor_Mood` | Spooky Night |
|
||||
| `Night_Wild_Outdoor_Mood` | Wild Night |
|
||||
| `Night_StuntRunner_Outdoor_Mood` | (Stunt Runner night) |
|
||||
| `OuterSpace_Mood` | Outer Space |
|
||||
| `Sunset_Cozy_Outdoor_Mood` | Cozy Sunset |
|
||||
| `Sunset_Burning_Mood` | Burning Sunset |
|
||||
| `Sunset_Tropical_Outdoor_Mood` | Tropical Sunset |
|
||||
| `Sunrise_Misty_Outdoor_Mood` | (misty sunrise) |
|
||||
|
||||
Reflection cubemaps ship alongside (`Moods_WildNight_ReflectionProbe`,
|
||||
`Moods_SunnyDay_Skybox_Reflection_Baked`, …) and are referenced from each
|
||||
`MoodSetting.BakedReflection`.
|
||||
|
||||
## Rec Royale specifics
|
||||
|
||||
* Only one Rec Royale map in this build:
|
||||
`Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity`
|
||||
(Unity scene name `RecRoyale_Frontier`). The `"recroyale"` literal in
|
||||
`BattleRoyaleManager` is only `get_RuleTypeAutoTag`, not a scene name.
|
||||
* `BattleRoyaleManager` has no static instance accessor; scene-name
|
||||
matching via MelonLoader `OnSceneWasLoaded` is the detection choke point.
|
||||
* Weapon flashlights already exist: `WeaponFlashlightController` (fields
|
||||
`weapon`, `flashlightPrefab`, `flashlightRoot`) spawns
|
||||
`WeaponFlashlight` when `get_FlashlightAllowed` is true. That getter
|
||||
(ISIL `WeaponFlashlightController.txt:3-75`) is
|
||||
`Matchmaking.FBELDNLEKAC() != null && [obj+0x28] == 15` — a game-mode
|
||||
gate for a different activity. Interop property name: `KIMMMCCKMJL`.
|
||||
|
||||
## Real CV2 mood chips in an RRO scene
|
||||
|
||||
The Maker Pen chips ("Skydome Constant", "Room Skydome Modify/Reset", and
|
||||
the Sun / Fog / Background Objects trios) drive `RoomSkydome`, `RoomSun`,
|
||||
`RoomFog`, `RoomBackgroundObjects` (`RecRoom.CircuitsV2.RoomSettings`,
|
||||
all `RoomMoodBase<T,U,V>`), which hang off `RoomElementSettingsManager` (a
|
||||
`NetworkedSingletonMonoBehaviour` that `CircuitsV2Manager` brings up in
|
||||
`MPDIMHOMIDP`). Their state persists in the room blob
|
||||
(`PersistedRoomData` fields 32-35: `room_fog_data`, `room_mood_sun_data`,
|
||||
`room_mood_background_objects`, `room_mood_skydome`). None of that runs in
|
||||
a room the client believes has no data blob, so the prerequisite for chips
|
||||
in Rec Royale is server-side: give the room a blob (`docs/admin.md` → "RRO
|
||||
room data blob"). The ClientMod probe line
|
||||
`[royale-night] CV2 probe in 'RecRoyale_Frontier': …` logs instance counts
|
||||
for `CircuitsV2Manager`, `RoomElementSettingsManager`, `RoomSkydome`,
|
||||
`RoomSun`, `RoomFog`, `RoomBackgroundObjects` and `MakerPen` so the effect
|
||||
of the blob can be verified in one join.
|
||||
|
||||
## How the ClientMod uses this
|
||||
|
||||
`DorkNet.ClientMod/RecRoyaleNight.cs`:
|
||||
|
||||
1. `OnSceneWasLoaded` → scene name contains any `RecRoyaleNightScenes`
|
||||
entry (default `RecRoyale`) ⇒ arm.
|
||||
2. `OnUpdate` every 30 frames: `RecRoomSceneManager.Instance.sceneMoodSettings`
|
||||
present and `CurrentMood` non-null (engine initialised) ⇒
|
||||
`Resources.Load(RecRoyaleNightMood, MoodSetting)` ⇒
|
||||
`MLLBALMKEGJ(mood)`. Re-asserted every ~10 s (the engine re-applies its
|
||||
own mood on quality-level changes).
|
||||
3. Hotkey (`RecRoyaleNightToggleKey`, a `UnityEngine.KeyCode` name) toggles;
|
||||
off re-applies `CurrentMood`, i.e. the scene's own default.
|
||||
4. Harmony postfix on `get_FlashlightAllowed` returns `true` while active
|
||||
and in a Rec Royale scene (`RecRoyaleNightFlashlights`).
|
||||
|
||||
Limits: baked lightmaps are untouched (interiors keep their daytime bake);
|
||||
darkening comes from the mood's colour grading, sun and ambient settings.
|
||||
Purely per-client — other players see whatever their own client applies.
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user