diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 902982c..be69e8a 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -80,6 +80,13 @@ public class Mod : MelonMod // See RegistrationPatches and the registration site in // OnLateInitializeMelon. public static bool TraceRegistrationDialog = true; + // Force-enable the client's built-in RecRoom.Debugging.DebugConsole + // (normally dev-gated) and silence CheatManager's tamper detectors + // so movement/time commands (SetTimeScale, Fly, Teleport, …) don't + // drop you to the dorm. Default OFF — purely opt-in. The toggle key + // is a UnityEngine.KeyCode name; BackQuote is the `~` key. + public static bool EnableDebugConsole = false; + public static string DebugConsoleToggleKey = "BackQuote"; } public override void OnInitializeMelon() @@ -278,6 +285,17 @@ 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."); + } + RegisterDiagnostics(); Log.Msg("=== Client patches registered ==="); } @@ -474,6 +492,7 @@ public class Mod : MelonMod _firstUpdateLogged = true; Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)"); } + if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey(); if (_diagnosticGameComplete) return; if (_diagnosticRetryFrame++ > 3600) return; if ((_diagnosticRetryFrame % 60) != 0) return; @@ -521,6 +540,8 @@ public class Mod : MelonMod if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (r.TryGetProperty("TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); + if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); + if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; // "InjectAuthValues" key in the template is now ignored — // see attic/AuthValuesInjector.cs.attic. Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "" : "")}, " + @@ -671,7 +692,7 @@ public class Mod : MelonMod { // Look in all three patch holder classes — small enough that a // linear scan is cheaper than per-class lookups. - foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -755,6 +776,202 @@ public class Mod : MelonMod // Harmony invokes. Kept separate from Mod for readability and so the // reflection lookup in GetPatchMethod stays simple. +// ── Debug console access ─────────────────────────────────────────────── +// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game +// dev console (text input + a DebugConsoleCommandConfig command table: +// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily, +// …). Retail gates it behind a developer flag. This holder force-toggles +// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips +// CheatManager's tamper detectors so movement/time commands don't drop you +// to the dorm. +// +// Everything is resolved by reflection at runtime so the SAME code works on +// both client builds despite their differing obfuscation: +// * The show/hide toggle is the one non-compiler-generated private +// instance void(bool) on DebugConsole — ShowInputField on 2020.03, +// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated +// one, then fall back to that structural match. +// * The live component is found via UnityEngine.Object.FindObjectOfType +// (name-stable) rather than the SingletonMonoBehaviour accessor. +// * Execute(string) keeps its readable name on both builds (kept here as +// a scripted-command entry point even though the hotkey drives the UI). +internal static class DebugConsolePatches +{ + private static bool _consoleVisible; + private static bool _pollErrorLogged; + private static int _probeFrame; + private static bool _probeDone; + + private static bool _inputResolved; + private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode) + private static object? _toggleKeyValue; // boxed KeyCode enum value + + private static bool _consoleResolved; + private static Type? _consoleType; + private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool) + private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type) + + // Harmony prefix returning false → skips the original CheatManager + // detector, so a tripped heuristic never runs its punish/drop path. + public static bool SuppressCheatDetected_Prefix() => false; + + // Polled every frame from Mod.OnUpdate while EnableDebugConsole is set. + public static void PollToggleKey() + { + try + { + if (!_inputResolved) ResolveInput(); + // One-shot probe ~3s in (≈180 frames @60fps): log whether the + // console object actually exists, so we learn that even if the + // hotkey never fires. + if (!_probeDone && _probeFrame++ > 180) + { + _probeDone = true; + ProbeConsole(); + } + if (_getKeyDown is null || _toggleKeyValue is null) return; + if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true) + { + Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected"); + Toggle(); + } + } + catch (Exception ex) + { + if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); } + } + } + + // Diagnostic: reports whether a live DebugConsole component exists and + // whether the show/hide method resolved — without needing a keypress. + private static void ProbeConsole() + { + if (!_consoleResolved) ResolveConsole(); + if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; } + try + { + var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType }); + Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "")}, activeInstance={(active is null ? "null" : "found")}"); + } + catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); } + } + + // Runs DebugConsole.Execute(command) directly — bypasses the UI/gate + // entirely. Handy for scripted one-shots; the hotkey path uses the UI. + public static void ExecuteCommand(string command) + { + if (!_consoleResolved) ResolveConsole(); + if (_consoleType is null) return; + try + { + var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) }); + if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; } + execute.Invoke(null, new object[] { command }); + Mod.Log.Msg($"[debugconsole] executed: {command}"); + } + catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); } + } + + private static void ResolveInput() + { + _inputResolved = true; + var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode"); + var inputType = Mod.ResolveType("UnityEngine.Input"); + if (keyCodeType is null || inputType is null) + { + Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled"); + return; + } + try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); } + catch + { + Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote"); + _toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote"); + } + _getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType }); + if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found"); + else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)"); + } + + private static void ResolveConsole() + { + _consoleResolved = true; + _consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole"); + if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; } + + // Prefer the readable 2020.03 name, then the 2020.12 obfuscated + // name, then a structural fallback that fits both builds. + _toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) }) + ?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) }) + ?? FindToggleByShape(_consoleType); + if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found"); + + var objType = Mod.ResolveType("UnityEngine.Object"); + _findObjectOfType = objType is null ? null + : AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) }); + if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found"); + } + + // The console's show/hide is the single private, instance, void method + // taking exactly one bool, excluding compiler-generated members (the + // property backing setter on 2020.12 is also void(bool) but is marked + // [CompilerGenerated]). + 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; // ambiguous — don't guess + found = m; + } + return found; + } + + public static void Toggle() + { + if (!_consoleResolved) ResolveConsole(); + if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return; + try + { + var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType }); + if (instance is null) + { + Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build"); + return; + } + // Best-effort: flip the static "show button" enable so the + // game's own Update keeps the console available instead of + // re-hiding it. Failures are ignored (obfuscated builds). + EnableShowButton(); + _consoleVisible = !_consoleVisible; + _toggleMethod.Invoke(instance, new object[] { _consoleVisible }); + Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}"); + } + catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); } + } + + private static void EnableShowButton() + { + if (_consoleType is null) return; + try + { + var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static); + if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; } + foreach (var name in new[] { "showButton", "IEGCNLHPKFH" }) + { + var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static); + if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; } + } + } + catch { /* best-effort — the toggle still works without it */ } + } +} + internal static class UriPatches { public static void HttpManagerSendRequestString_Prefix(ref string url) diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index 2ac0b86..9ca76cf 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -259,7 +259,7 @@ public class ServerSettingsService(DorkNetDbContext db) .Select((c, i) => new WeeklyChallengeTemplate( i, c.Name.Trim(), - string.IsNullOrWhiteSpace(c.Config) ? "{}" : c.Config.Trim(), + NormalizeWeeklyChallengeConfig(c.Config), c.Description?.Trim() ?? string.Empty, c.Tooltip?.Trim() ?? string.Empty)) .ToList(); @@ -267,6 +267,94 @@ public class ServerSettingsService(DorkNetDbContext db) return normalized.Count > 0 ? normalized : DefaultWeeklyChallenges(); } + private static string NormalizeWeeklyChallengeConfig(string? config) + { + if (TryGetLegacyWeeklyGoal(config, out var legacyGoal)) + return CountedAnyChallengeConfig(legacyGoal); + + if (IsClientWeeklyChallengeConfig(config)) + return config!.Trim(); + + return CountedAnyChallengeConfig(1); + } + + private static bool TryGetLegacyWeeklyGoal(string? config, out int goal) + { + goal = 1; + if (string.IsNullOrWhiteSpace(config)) return false; + + try + { + using var doc = JsonDocument.Parse(config); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + + if (!doc.RootElement.TryGetProperty("Type", out _)) + return false; + + if (doc.RootElement.TryGetProperty("Goal", out var goalElement) + && goalElement.TryGetInt32(out var parsedGoal)) + { + goal = Math.Clamp(parsedGoal, 1, 1000); + } + + return true; + } + catch + { + return false; + } + } + + private static bool IsClientWeeklyChallengeConfig(string? config) + { + if (string.IsNullOrWhiteSpace(config)) return false; + + try + { + using var doc = JsonDocument.Parse(config); + return IsClientWeeklyChallengeConfigElement(doc.RootElement); + } + catch + { + return false; + } + } + + private static bool IsClientWeeklyChallengeConfigElement(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + return false; + + if (!element.TryGetProperty("ct", out var typeElement) || !typeElement.TryGetInt32(out var type)) + return false; + + if (type == 0) + return true; + + if (type != 1) + return false; + + if (!element.TryGetProperty("t", out var targetElement) + || !targetElement.TryGetInt32(out var target) + || target <= 0) + { + return false; + } + + if (!element.TryGetProperty("ctc", out var children) + || children.ValueKind != JsonValueKind.Array + || children.GetArrayLength() == 0) + { + return false; + } + + return children.EnumerateArray().All(IsClientWeeklyChallengeConfigElement); + } + + private static string CountedAnyChallengeConfig(int target) => + "{\"ct\":1,\"ctc\":[{\"ct\":0}],\"t\":" + Math.Clamp(target, 1, 1000) + ",\"cc\":0}"; + private static WeeklyChallengeReward NormalizeWeeklyReward(WeeklyChallengeReward? reward) { reward ??= DefaultWeeklyReward(); @@ -288,19 +376,19 @@ public class ServerSettingsService(DorkNetDbContext db) new( Index: 0, Name: "Play 3 Rec Room Originals", - Config: "{\"Type\":\"CompleteActivity\",\"Goal\":3}", + Config: CountedAnyChallengeConfig(3), Description: "Complete three Rec Room Original activities.", Tooltip: "Play any RRO activity to make progress."), new( Index: 1, Name: "Cheer 5 Players", - Config: "{\"Type\":\"CheerPlayers\",\"Goal\":5}", + Config: CountedAnyChallengeConfig(5), Description: "Send cheers to five players.", Tooltip: "Use the watch to cheer players you meet."), new( Index: 2, Name: "Visit 5 Rooms", - Config: "{\"Type\":\"VisitRooms\",\"Goal\":5}", + Config: CountedAnyChallengeConfig(5), Description: "Visit five rooms this week.", Tooltip: "Any room visit counts toward this challenge."), ]; diff --git a/README.md b/README.md index 12ba9ac..852551b 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,17 @@ self-signed certs work. (First run prints "launch the game once"; do that, then re-run with `-ResumeBuild` to finish — see the script's `-?` help.) +### Debug console (opt-in) + +The 2020 client ships a built-in dev console +(`RecRoom.Debugging.DebugConsole`) with commands like `SetTimeScale`, +`Fly`, `Teleport`, `GoToRoom`, and `KillAllEnemies` — normally locked to +developer accounts. Set `"EnableDebugConsole": true` in +`dorknet-clientmod.json` and relaunch: the mod force-toggles the console +UI on a hotkey (`"DebugConsoleToggleKey"`, default `BackQuote` = the `~` +key) and silences `CheatManager` so the movement/time commands don't drop +you to the dorm. Both default off. + --- ## Code map diff --git a/tools/install-melon.ps1 b/tools/install-melon.ps1 index b82bdae..438f54d 100644 --- a/tools/install-melon.ps1 +++ b/tools/install-melon.ps1 @@ -440,6 +440,12 @@ $cfg = [ordered]@{ PhotonCloudRegion = $PhotonCloudRegion InjectAuthValues = $true EnableTlsTrustBypass = $true + # Opt-in: force-enable the client's built-in debug console (dev gate + # bypassed) + silence CheatManager so SetTimeScale / Fly / Teleport etc. + # don't drop you to the dorm. Flip to $true and relaunch; press the + # toggle key (a UnityEngine.KeyCode name; BackQuote = the `~` key). + EnableDebugConsole = $false + DebugConsoleToggleKey = 'BackQuote' } Write-Step "Writing mod config -> $cfgPath" [System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json -Depth 4))