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"
/>
+