diff --git a/DorkNet.ClientMod/DorkNet.ClientMod.csproj b/DorkNet.ClientMod/DorkNet.ClientMod.csproj index 188a96b..341adf0 100644 --- a/DorkNet.ClientMod/DorkNet.ClientMod.csproj +++ b/DorkNet.ClientMod/DorkNet.ClientMod.csproj @@ -64,29 +64,4 @@ - - - C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies - - - $(Il2CppAsmDir)\Assembly-CSharp.dllfalse - $(Il2CppAsmDir)\Il2Cppmscorlib.dllfalse - $(Il2CppAsmDir)\UnityEngine.CoreModule.dllfalse - $(Il2CppAsmDir)\UnityEngine.UI.dllfalse - $(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dllfalse - $(Il2CppAsmDir)\Unity.TextMeshPro.dllfalse - $(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dllfalse - $(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dllfalse - - diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index c3d7012..5d06e7f 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -133,6 +133,15 @@ public class Mod : MelonMod // DebugConsole.Execute(command). Edit to match this build's actual // DebugConsoleCommandConfig command names/args. public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" }; + + // Raise RRO quest party size past the baked 4. The cap lives in each + // quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize + // ScriptableObject (baked in the client bundle; no server lever). The + // quest scoreboard + party HUD build rows dynamically, so they + // auto-scale once the team cap is raised. 0 = off (leave at 4). + // Only single-team, quest-named configs are touched, so PvP team + // sizes (paintball/laser tag) are never changed. See QuestTeamSize. + public static int QuestMaxTeamSize = 0; } public override void OnInitializeMelon() @@ -331,17 +340,6 @@ public class Mod : MelonMod RegisterJoinTracePatches(); - // Debug console: skip CheatManager's tamper detectors so the - // console's movement/time commands don't trip a dorm-drop. The - // console UI toggle itself is driven from OnUpdate (hotkey) and - // resolved lazily — only the anti-cheat skip needs a patch. - if (Cfg.EnableDebugConsole) - { - foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" }) - TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix)); - Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console."); - } - // Desktop Screen Sharing FPS override. Only register when a target // is set; the postfix on the gadget's Awake rewrites the baked // refresh-frequency field (and, if enabled, raises the Photon @@ -556,9 +554,12 @@ public class Mod : MelonMod _firstUpdateLogged = true; Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)"); } - if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey(); - if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick(); - if (Cfg.EnableDevWatchButton) DevWatchButton.Tick(); + + // Quest party-size bump runs independent of the diagnostic flow below + // (quest GameConfigurationAssets load when the quest room loads, so we + // re-apply periodically). Gated behind QuestMaxTeamSize > 0. + if (Cfg.QuestMaxTeamSize > 0) QuestTeamSize.Tick(); + if (_diagnosticGameComplete) return; if (_diagnosticRetryFrame++ > 3600) return; if ((_diagnosticRetryFrame % 60) != 0) return; @@ -613,6 +614,7 @@ public class Mod : MelonMod if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); + if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean(); if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array) { diff --git a/DorkNet.ClientMod/QuestTeamSize.cs b/DorkNet.ClientMod/QuestTeamSize.cs new file mode 100644 index 0000000..0de334b --- /dev/null +++ b/DorkNet.ClientMod/QuestTeamSize.cs @@ -0,0 +1,193 @@ +// Raise RRO quest party size past the baked 4. +// +// The cap is NOT in quest-specific code and NOT server-driven — it lives in +// each quest's GameConfigurationAsset.TeamConfigurations[].MaxTeamSize, a +// ScriptableObject baked into the client asset bundle. The quest scoreboard +// (GameScoreboardBodyView instantiates rows into a List) and the party HUD are +// fully dynamic, so they auto-scale once the team cap is raised. +// +// Implemented with reflection (this project references only MelonLoader / +// Harmony / Il2CppInterop.Runtime — NOT the game's interop assemblies), the +// same way DevMenuProbe drives the game. Quest configs load when the quest +// room loads, so we poll from Mod.OnUpdate rather than running once, and only +// touch single-team, quest-named configs so PvP team sizes (paintball/laser +// tag, 2+ teams) are never changed. Every config is logged once so we can +// confirm targeting (these MaxTeamSize values aren't in the static dump). +// Gated behind Cfg.QuestMaxTeamSize. +using System; +using System.Collections.Generic; +using System.Reflection; +using Il2CppInterop.Runtime; + +namespace DorkNet.ClientMod; + +internal static class QuestTeamSize +{ + private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + private static int _frame; + private static readonly HashSet _logged = new(StringComparer.Ordinal); + + public static void Tick() + { + _frame++; + if (_frame < 120 || (_frame % 120) != 0) return; // settle, then ~every 2s + try { Apply(Mod.Cfg.QuestMaxTeamSize); } + catch (Exception ex) { Mod.Log.Warning($"[questsize] failed: {ex.Message}"); } + } + + private static void Apply(int target) + { + if (target <= 0) return; + var assetType = Mod.ResolveType("RecRoom.Core.GameManagement.GameConfigurationAsset"); + if (assetType is null) return; + + // Invoke Il2CppType.From via reflection so the Il2CppSystem.Type + // return value stays boxed as object — calling it directly would + // need a compile reference to Il2Cppmscorlib, which we don't carry. + var il2 = ToIl2CppType(assetType); + if (il2 is null) return; + + var resources = Mod.ResolveType("UnityEngine.Resources"); + var findAll = OneArgStatic(resources, "FindObjectsOfTypeAll", il2); + var all = findAll?.Invoke(null, new[] { il2 }); + int n = ArrLen(all); + for (int i = 0; i < n; i++) + { + var asset = ArrItem(all, i); + if (asset is null) continue; + asset = EnsureConcrete(asset, assetType); + + var name = ReadName(asset); + var teams = GetMember(asset, "TeamConfigurations"); + if (teams is null) continue; + int tn = ArrLen(teams); + + if (_logged.Add(name)) + { + var sizes = new List(tn); + for (int t = 0; t < tn; t++) sizes.Add(ReadInt(ArrItem(teams, t), "MaxTeamSize").ToString()); + Mod.Log.Msg($"[questsize] config '{name}' teams=[{string.Join(",", sizes)}] (n={tn})"); + } + + // Quests are SINGLE-TEAM co-op. That's the reliable signal: PvP + // modes have 2+ teams (Paintball [4,4], StuntRunner [1,1,1,1]) + // and Sandbox has many — only quests (the generic "Quest" config + // AND each per-quest config like "The Rise Of JumboTron") are + // n==1. Name-matching missed the per-quest configs, so we key off + // team count instead and leave every 2+-team mode untouched. + if (tn != 1) continue; + + var changed = false; + for (int t = 0; t < tn; t++) + { + var box = ArrItem(teams, t); + if (box is null || ReadInt(box, "MaxTeamSize") >= target) continue; + if (WriteInt(box, "MaxTeamSize", target) && ArrSet(teams, t, box)) changed = true; + } + if (changed) Mod.Log.Msg($"[questsize] bumped '{name}' → MaxTeamSize {target}"); + } + } + + // FindObjectsOfTypeAll returns base-typed elements; downcast to the + // concrete interop type so reflection sees TeamConfigurations/Name. + private static object EnsureConcrete(object obj, Type target) + { + if (target.IsInstanceOfType(obj)) return obj; + var bt = obj.GetType(); + while (bt != null && bt.Name != "Il2CppObjectBase") bt = bt.BaseType; + var cast = bt?.GetMethod("Cast", BindingFlags.Public | BindingFlags.Instance); + if (cast != null) + { + try { return cast.MakeGenericMethod(target).Invoke(obj, null) ?? obj; } + catch { } + } + return obj; + } + + private static string ReadName(object asset) + { + if (GetMember(asset, "Name") is string n && n.Length > 0) return n; + if (GetMember(asset, "name") is string un && un.Length > 0) return un; + return "?"; + } + + private static object? GetMember(object o, string name) + { + var t = o.GetType(); + var p = t.GetProperty(name, BF); + if (p != null && p.CanRead && p.GetIndexParameters().Length == 0) + { try { return p.GetValue(o); } catch { } } + var f = t.GetField(name, BF); + if (f != null) { try { return f.GetValue(o); } catch { } } + return null; + } + + private static int ReadInt(object? o, string name) + { + if (o is null) return 0; + var v = GetMember(o, name); + if (v is int n) return n; + return v != null && int.TryParse(v.ToString(), out var p) ? p : 0; + } + + private static bool WriteInt(object o, string name, int val) + { + var t = o.GetType(); + var p = t.GetProperty(name, BF); + if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } } + var f = t.GetField(name, BF); + if (f != null) { try { f.SetValue(o, val); return true; } catch { } } + return false; + } + + private static int ArrLen(object? arr) + { + if (arr is null) return 0; + if (arr is Array a) return a.Length; + var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count"); + return (lp?.GetValue(arr) as int?) ?? 0; + } + + private static object? ArrItem(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 }); + } + + private static bool ArrSet(object arr, int i, object val) + { + var si = arr.GetType().GetMethod("set_Item", new[] { typeof(int), val.GetType() }) + ?? arr.GetType().GetMethod("set_Item"); + if (si is null) return false; + try { si.Invoke(arr, new object[] { i, val }); return true; } catch { return false; } + } + + private static object? ToIl2CppType(Type managed) + { + try + { + var t = typeof(Il2CppType); + var from = t.GetMethod("From", new[] { typeof(Type), typeof(bool) }) + ?? t.GetMethod("From", new[] { typeof(Type) }); + if (from is null) return null; + var args = from.GetParameters().Length == 2 + ? new object[] { managed, true } : new object[] { managed }; + return from.Invoke(null, args); + } + catch { return null; } + } + + private static MethodInfo? OneArgStatic(Type? t, string name, object arg) + { + if (t is null) return null; + foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != name) continue; + var ps = m.GetParameters(); + if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m; + } + return null; + } +} diff --git a/DorkNet.DevMenu/DevMenuMod.cs b/DorkNet.DevMenu/DevMenuMod.cs new file mode 100644 index 0000000..c1619ad --- /dev/null +++ b/DorkNet.DevMenu/DevMenuMod.cs @@ -0,0 +1,290 @@ +using HarmonyLib; +using Il2CppRecRoom.Debugging; +using MelonLoader; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.Json; +using UnityEngine; +using UObject = UnityEngine.Object; + +[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")] +[assembly: MelonGame(null, null)] + +namespace DorkNet.DevMenu; + +public class DevMenuMod : MelonMod +{ + internal static MelonLogger.Instance Log => Instance!.LoggerInstance; + internal static DevMenuMod? Instance; + internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance; + private static readonly Dictionary ResolvedTypeCache = new(); + + public static class Cfg + { + public static bool EnableDebugConsole = false; + public static string DebugConsoleToggleKey = "BackQuote"; + public static bool DiagnoseDevMenu = false; + public static bool EnableDevWatchButton = false; + public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" }; + } + + public override void OnInitializeMelon() + { + Instance = this; + Log.Msg("=== DorkNet DevMenu loading ==="); + LoadConfig(); + + if (Cfg.EnableDebugConsole) + { + foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" }) + TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix)); + Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console."); + } + } + + public override void OnUpdate() + { + if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey(); + if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick(); + if (Cfg.EnableDevWatchButton) DevWatchButton.Tick(); + } + + private static void LoadConfig() + { + try + { + var modDll = Assembly.GetExecutingAssembly().Location; + var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty; + var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty; + var userData = Path.Combine(gameDir, "MelonLoader", "UserData"); + Directory.CreateDirectory(userData); + + var devPath = Path.Combine(userData, "dorknet-devmenu.json"); + var legacyPath = Path.Combine(userData, "dorknet-clientmod.json"); + var path = File.Exists(devPath) ? devPath : legacyPath; + if (!File.Exists(path)) + { + Log.Msg($"[config] no dev config found; all dev menu features disabled"); + return; + } + + var doc = JsonDocument.Parse(File.ReadAllText(path)); + var r = doc.RootElement; + if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean(); + if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; + if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean(); + if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean(); + if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array) + { + var list = new List(); + foreach (var e in v.EnumerateArray()) + { + var s = e.GetString(); + if (!string.IsNullOrWhiteSpace(s)) list.Add(s!); + } + if (list.Count > 0) Cfg.DevCommands = list.ToArray(); + } + + Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}"); + } + catch (Exception ex) + { + Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}"); + } + } + + private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null) + { + try + { + var type = ResolveType(typeName); + if (type is null) + { + Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found"); + return false; + } + + var method = args is null + ? AccessTools.Method(type, methodName) + : AccessTools.Method(type, methodName, args); + if (method is null) + { + Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found"); + return false; + } + + HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)); + HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)); + Instance!.PatchHarmony.Patch(method, pre, post); + Log.Msg($"[patch-ok] {typeName}.{methodName}"); + return true; + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + private static MethodInfo GetPatchMethod(string name) + { + foreach (var holder in new[] { typeof(DebugConsolePatches) }) + { + var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); + if (m is not null) return m; + } + throw new MissingMethodException($"patch method '{name}' not found"); + } + + internal static Type? ResolveType(string requested) + { + if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + Type? found = null; + try + { + found = asm.GetType(requested, throwOnError: false) + ?? asm.GetType("Il2Cpp" + requested, throwOnError: false); + if (found is null) + { + foreach (var t in asm.GetTypes()) + { + if (t.FullName == requested || + t.Name == requested || + t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true || + t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true) + { + found = t; + break; + } + } + } + } + catch { } + + if (found is null) continue; + ResolvedTypeCache[requested] = found; + return found; + } + return null; + } +} + +internal static class DebugConsolePatches +{ + private static bool _consoleVisible; + private static bool _warned; + private static KeyCode _toggleKey = KeyCode.BackQuote; + private static bool _keyParsed; + private static MethodInfo? _toggleMethod; + + public static bool SuppressCheatDetected_Prefix() => false; + + public static void PollToggleKey() + { + try + { + if (!_keyParsed) + { + _keyParsed = true; + if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey)) + { + _toggleKey = KeyCode.BackQuote; + DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote"); + } + } + + if (Input.GetKeyDown(_toggleKey)) + { + Toggle(); + } + } + catch (Exception ex) + { + if (!_warned) + { + _warned = true; + DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}"); + } + } + } + + private static void Toggle() + { + try + { + var console = UObject.FindObjectOfType(); + if (console == null) + { + DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene"); + return; + } + + _toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) }) + ?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) }) + ?? FindToggleByShape(typeof(DebugConsole)); + if (_toggleMethod is null) + { + DevMenuMod.Log.Warning("[debugconsole] show/hide method not found"); + return; + } + + _consoleVisible = !_consoleVisible; + _toggleMethod.Invoke(console, new object[] { _consoleVisible }); + DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}"); + } + catch (Exception ex) + { + DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); + } + } + + private static MethodInfo? FindToggleByShape(Type t) + { + MethodInfo? found = null; + foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (m.ReturnType != typeof(void)) continue; + var ps = m.GetParameters(); + if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue; + if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue; + if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue; + if (found is not null) return null; + found = m; + } + return found; + } +} + +internal static class DevMenuProbe +{ + private static int _frame; + private static bool _done; + + public static void Tick() + { + if (_done) return; + _frame++; + if (_frame < 180) return; + _done = true; + + var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow"); + var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole); + DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ==="); + DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}"); + DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}"); + try + { + var console = UObject.FindObjectOfType(); + DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}"); + } + catch (Exception ex) + { + DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}"); + } + DevMenuMod.Log.Msg("=== [devmenu-probe] done ==="); + } +} diff --git a/DorkNet.ClientMod/DevWatchButton.cs b/DorkNet.DevMenu/DevWatchButton.cs similarity index 78% rename from DorkNet.ClientMod/DevWatchButton.cs rename to DorkNet.DevMenu/DevWatchButton.cs index ca8f5b9..c899427 100644 --- a/DorkNet.ClientMod/DevWatchButton.cs +++ b/DorkNet.DevMenu/DevWatchButton.cs @@ -15,7 +15,7 @@ using Il2CppTMPro; using UnityEngine.Events; using UObject = UnityEngine.Object; -namespace DorkNet.ClientMod; +namespace DorkNet.DevMenu; internal static class DevWatchButton { @@ -32,9 +32,9 @@ internal static class DevWatchButton var home = UObject.FindObjectOfType(); if (home == null && _frame < 3600) return; // wait for the watch _done = true; - if (home == null) { Mod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; } + if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; } try { Inject(home); } - catch (Exception ex) { Mod.Log.Warning($"[devbtn] inject failed: {ex}"); } + catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); } } private static void Inject(HomeScreenFlow home) @@ -42,7 +42,7 @@ internal static class DevWatchButton var buttons = home.GetComponentsInChildren(true); if (buttons == null || buttons.Length == 0) { - Mod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow"); + DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow"); return; } @@ -53,7 +53,7 @@ internal static class DevWatchButton if (buttons[i].GetComponentInChildren(true) != null) { src = buttons[i]; break; } bool labeled = src != null; if (src == null) src = buttons[0]; // fall back: inject anyway, just no text - Mod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}"); + DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}"); int n = 0; foreach (var (label, cmd) in ParsePresets()) @@ -72,7 +72,7 @@ internal static class DevWatchButton _clones.Add(clone); n++; } - Mod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them"); + DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them"); DumpCommands(); } @@ -84,34 +84,34 @@ internal static class DevWatchButton try { var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of()); - Mod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded"); + DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded"); for (int c = 0; c < cfgs.Length; c++) { var cfg = cfgs[c].TryCast(); var metas = cfg?.Metas; if (metas == null) continue; - Mod.Log.Msg($"[devcmds] {metas.Count} command(s):"); + DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):"); for (int i = 0; i < metas.Count; i++) { var m = metas[i]; - Mod.Log.Msg($" {m.MethodName} ({m.DeclaringType})"); + DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})"); } } } - catch (Exception ex) { Mod.Log.Warning($"[devcmds] failed: {ex.Message}"); } + catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); } } private static void Run(string label, string command) { - Mod.Log.Msg($"[devbtn] '{label}' → DebugConsole.Execute(\"{command}\")"); + DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")"); try { DebugConsole.Execute(command); } - catch (Exception ex) { Mod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); } + catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); } } // Presets come from Cfg.DevCommands as "Label=command" strings. private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets() { - foreach (var entry in Mod.Cfg.DevCommands) + foreach (var entry in DevMenuMod.Cfg.DevCommands) { if (string.IsNullOrWhiteSpace(entry)) continue; var eq = entry.IndexOf('='); diff --git a/DorkNet.DevMenu/DorkNet.DevMenu.csproj b/DorkNet.DevMenu/DorkNet.DevMenu.csproj new file mode 100644 index 0000000..dd3d467 --- /dev/null +++ b/DorkNet.DevMenu/DorkNet.DevMenu.csproj @@ -0,0 +1,68 @@ + + + + net6.0 + latest + enable + DorkNet.DevMenu + DorkNet.DevMenu + false + false + $(NoWarn);CS8602;CS8604;CS0436 + + + + v0.6.6 + $(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader + $(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip + $(MelonLoaderCacheRoot)\MelonLoader + + + + + + + + + + + $(MelonLoaderDir)\net6\MelonLoader.dll + false + + + $(MelonLoaderDir)\net6\0Harmony.dll + false + + + $(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll + false + + + $(MelonLoaderDir)\net6\Il2CppInterop.Common.dll + false + + + + + C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies + + + $(Il2CppAsmDir)\Assembly-CSharp.dllfalse + $(Il2CppAsmDir)\Il2Cppmscorlib.dllfalse + $(Il2CppAsmDir)\UnityEngine.CoreModule.dllfalse + $(Il2CppAsmDir)\UnityEngine.UI.dllfalse + $(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dllfalse + $(Il2CppAsmDir)\Unity.TextMeshPro.dllfalse + $(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dllfalse + $(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dllfalse + + + diff --git a/DorkNet.DevMenu/dorknet-devmenu.json.template b/DorkNet.DevMenu/dorknet-devmenu.json.template new file mode 100644 index 0000000..4e7a05d --- /dev/null +++ b/DorkNet.DevMenu/dorknet-devmenu.json.template @@ -0,0 +1,11 @@ +{ + "EnableDebugConsole": false, + "DebugConsoleToggleKey": "BackQuote", + "DiagnoseDevMenu": false, + "EnableDevWatchButton": false, + "DevCommands": [ + "Help=help", + "Fly=fly", + "NoClip=noclip" + ] +} diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 7749219..7243e9a 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1200,7 +1200,7 @@ public class RoomsController( Name = "Home", IsSandbox = false, DataBlobName = dataBlobName, - MaxPlayers = 8, + MaxPlayers = room.MaxCapacity, CanMatchmakeInto = true, // Use the room's actual UpdatedAt so the watch's // "is my local cache stale?" check sees an accurate diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index cd070dd..5db95cb 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -903,7 +903,8 @@ public class AdminController( string? TagsCsv, double? HotScore, int? State, - string? ImageName); + string? ImageName, + int? MaxCapacity); /// POST api/admin/v1/rooms/{id}/props — partial /// update of room properties. Only the non-null fields in the @@ -926,6 +927,13 @@ public class AdminController( if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); } if (body.State is int st) { room.State = st; changes.Add($"state={st}"); } if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); } + if (body.MaxCapacity is int cap) + { + // Clamp to a sane range. Note: advertised cap only on the 2020.12 + // client today (no Photon hard-enforce) — see RoomEntity.MaxCapacity. + room.MaxCapacity = Math.Clamp(cap, 1, 80); + changes.Add($"maxCapacity={room.MaxCapacity}"); + } if (changes.Count == 0) return Ok(new { room.Id, unchanged = true }); @@ -989,6 +997,7 @@ public class AdminController( room.SupportsWalkVR, room.SupportsTeleportVR, room.AllowsJuniors, + room.MaxCapacity, room.DisableMicAutoMute, room.RoomWarningMask, room.CustomRoomWarning, diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 85ca118..74faff8 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -1026,6 +1026,7 @@ public class GoToController( // baked-in DormRoom mapping for unknown names. long roomId; string locationId; + var maxCapacity = 8; // admin-adjustable per-room cap; 8 = default/fallback if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) { // Each player has their own dorm row (created at signup, @@ -1038,6 +1039,7 @@ public class GoToController( var personal = await rooms.EnsurePersonalDormAsync(callerPid); roomId = personal.Id; locationId = personal.LocationReplicationId; + maxCapacity = personal.MaxCapacity; } else { @@ -1055,6 +1057,7 @@ public class GoToController( { roomId = room.Id; locationId = room.LocationReplicationId; + maxCapacity = room.MaxCapacity; } else { @@ -1103,7 +1106,7 @@ public class GoToController( PhotonRegionId = photonRegion, PhotonRoomId = photonRoom, Name = dormInstanceName, - MaxCapacity = 8, + MaxCapacity = maxCapacity, IsFull = false, IsPrivate = false, IsInProgress = true, diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index e8cd5f4..bd30b47 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -351,7 +351,7 @@ public class MatchPlayerController( location: location, dataBlob: dataBlob, photonRegion: photonRegion, - maxCapacity: 8, + maxCapacity: personal.MaxCapacity, photonRoomId: photonRoomId); return new RoomInstanceDto @@ -363,7 +363,7 @@ public class MatchPlayerController( PhotonRegionId = photonRegion, PhotonRoomId = photonRoomId, Name = dormInstanceName, - MaxCapacity = 8, + MaxCapacity = personal.MaxCapacity, IsFull = false, IsPrivate = true, IsInProgress = true, diff --git a/DorkNet.Server/Data/Entities/RoomEntity.cs b/DorkNet.Server/Data/Entities/RoomEntity.cs index b6dc876..763685f 100644 --- a/DorkNet.Server/Data/Entities/RoomEntity.cs +++ b/DorkNet.Server/Data/Entities/RoomEntity.cs @@ -54,6 +54,15 @@ public class RoomEntity public bool SupportsWalkVR { get; set; } = true; public bool SupportsTeleportVR { get; set; } = true; public bool AllowsJuniors { get; set; } = true; + + /// Max players the room advertises per instance — flows into the + /// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized + /// Scenes[0].MaxPlayers. Admin-adjustable. NOTE: the 2020.12 client + /// doesn't hard-enforce this (it never sets Photon RoomOptions.MaxPlayers), + /// so today it's the advertised/intended cap; true enforcement needs a + /// ClientMod Photon patch (tracked as a follow-up). + public int MaxCapacity { get; set; } = 8; + public int RoomWarningMask { get; set; } = 0; [MaxLength(512)] diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index a62ed67..314b5d0 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -103,6 +103,7 @@ public static class DatabaseBootstrap await EnsureServerSettingsColumnsAsync(db); await EnsureInventorySchemaAsync(db); await EnsureLeaderboardSchemaAsync(db); + await EnsureRoomColumnsAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder // below assigns CreatorPlayerId=1 to every canonical room @@ -239,6 +240,30 @@ public static class DatabaseBootstrap await db.Database.ExecuteSqlRawAsync(sql); } + /// Add post-Initial columns to the Rooms table. Like the other + /// Ensure*Columns helpers, this exists because the Postgres path is + /// EnsureCreated-only (no migration replay), so a new entity property + /// won't appear on an existing DB without an idempotent ALTER. + private static async Task EnsureRoomColumnsAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxCapacity"" integer NOT NULL DEFAULT 8;"); + return; + } + + try + { + // SQLite has no ADD COLUMN IF NOT EXISTS; ignore the duplicate- + // column error when it already exists. + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;"); + } + catch { } + } + private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db) { var provider = db.Database.ProviderName ?? string.Empty; diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts index d45da60..0269899 100644 --- a/DorkNet.Server/admin-ui/src/lib/types.ts +++ b/DorkNet.Server/admin-ui/src/lib/types.ts @@ -142,6 +142,7 @@ export interface RoomDetail { supportsWalkVR: boolean; supportsTeleportVR: boolean; allowsJuniors: boolean; + maxCapacity: number; disableMicAutoMute: boolean; roomWarningMask: number; customRoomWarning: string; diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 29106ab..57f1b85 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -166,6 +166,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } const [tagsCsv, setTagsCsv] = useState(room.tagsCsv); const [hotScore, setHotScore] = useState(String(room.hotScore)); const [imageName, setImageName] = useState(room.imageName); + const [maxCapacity, setMaxCapacity] = useState(String(room.maxCapacity ?? 8)); const [busy, setBusy] = useState(false); // Re-sync local state if a refresh brings new server values in. @@ -176,12 +177,14 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } setTagsCsv(room.tagsCsv); setHotScore(String(room.hotScore)); setImageName(room.imageName); + setMaxCapacity(String(room.maxCapacity ?? 8)); }, [room.id, room.updatedAt]); const save = async () => { setBusy(true); try { const hot = Number(hotScore); + const cap = parseInt(maxCapacity, 10); await api(`/rooms/${room.id}/props`, { method: 'POST', body: { @@ -191,6 +194,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } TagsCsv: tagsCsv, HotScore: Number.isFinite(hot) ? hot : undefined, ImageName: imageName, + MaxCapacity: Number.isFinite(cap) ? cap : undefined, }, }); toast.push('Saved', 'success'); @@ -236,6 +240,20 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } className="input tabular-nums" /> +