diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 5605ddf..ac313ad 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -20,6 +20,8 @@ // Il2CppAssemblies directory just to write the source. using HarmonyLib; +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes; using MelonLoader; using System; using System.Collections; @@ -30,6 +32,7 @@ using System.Reflection; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading.Tasks; [assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")] [assembly: MelonGame(null, null)] @@ -49,6 +52,7 @@ public class Mod : MelonMod private bool _diagnosticGameComplete; private int _diagnosticRetryFrame; private bool _firstUpdateLogged; + private bool _processExceptionHandlersRegistered; // Config backed by a plain JSON file under MelonLoader/UserData. // The defaults below match the recommended install, so dropping this @@ -143,6 +147,7 @@ public class Mod : MelonMod Instance = this; Log.Msg("=== DorkNet ClientMod loading ==="); LoadConfig(); + RegisterProcessExceptionHandlers(); RegisterNetworkPatches(); DiagnosticPatches.Write("[lifecycle] OnInitializeMelon"); } @@ -577,6 +582,9 @@ public class Mod : MelonMod TryPatchByName("BestHTTP.HTTPRequest", "AddHeader", args: new[] { typeof(string), typeof(string) }, prefix: nameof(HttpTracePatches.HeaderSet_Prefix)); + TryPatchByName("BestHTTP.HTTPRequest", "CallCallback", + prefix: nameof(HttpTracePatches.CallCallback_Prefix), + postfix: nameof(HttpTracePatches.CallCallback_Postfix)); complete &= TryPatchHttpManagerStringSendRequestOverloads(); complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest", args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) }, @@ -595,7 +603,11 @@ public class Mod : MelonMod var recNetServiceType = ResolveType("GJDLNNLKDIJ") ?? typeof(object); TryPatchByName("BNDIAONDFFF", "ctor", args: new[] { httpMethodsType, recNetServiceType, typeof(string) }, - prefix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)); + prefix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix), + postfix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)); + TryPatchByName("BNDIAONDFFF", "FGHNOKLDOKO", + prefix: nameof(HttpTracePatches.RecNetResponseTuple_Prefix), + postfix: nameof(HttpTracePatches.RecNetResponseTuple_Postfix)); _networkPatchesRegistered = complete; } @@ -1002,7 +1014,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(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(FileHashCheckerPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(FileHashCheckerPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -1028,8 +1040,6 @@ public class Mod : MelonMod private bool RegisterGameDiagnostics(bool logMisses) { - var complete = true; - // CheatManager fires these when its anti-cheat heuristics trip — // logging them surfaces "client thinks it's been tampered with", // a common cause of silent dorm-drops / kicks on a modded client. @@ -1046,22 +1056,26 @@ public class Mod : MelonMod "OnAdvancedMovementCheatDetected", }) { - complete &= TryPatchDiagnosticByName("CheatManager", method, - prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix), - logMiss: logMisses); + TryPatchDiagnosticByName("CheatManager", method, + prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix), + logMiss: logMisses); } - complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", - prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), - logMiss: logMisses); - complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", - args: new[] { typeof(int) }, - prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuitInt_Prefix), - logMiss: logMisses); - complete &= TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit", - args: new[] { typeof(int), typeof(string) }, - prefix: nameof(DiagnosticPatches.SessionManagerFatalApplicationQuit_Prefix), - logMiss: logMisses); + TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", + prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), + logMiss: logMisses); + TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", + args: new[] { typeof(int) }, + prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuitInt_Prefix), + logMiss: logMisses); + TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit", + args: new[] { typeof(int), typeof(string) }, + prefix: nameof(DiagnosticPatches.SessionManagerFatalApplicationQuit_Prefix), + logMiss: logMisses); + TryPatchDiagnosticAllOverloads("BNDIAONDFFF", "FGHNOKLDOKO", + finalizer: nameof(HttpTracePatches.RecNetResponseTuple_Finalizer), + logMiss: logMisses); + RegisterDeepRoomDiagnostics(logMisses); // SteamManager.Awake + SteamPlatformManager.*LoginInitialize were // pure-logging diagnostics that fired during Unity's very first // wake-up tick. On december (EAC build, IL2CPP wrapped Steam @@ -1070,7 +1084,7 @@ public class Mod : MelonMod // hooks gave us no behavioral leverage — only a `[call] …` log // line — so they're disabled. If anyone needs Steam-side visibility // later, route it through the game's own log lines instead. - return complete; + return true; } private bool TryPatchDiagnosticByName(string typeName, string methodName, @@ -1087,6 +1101,190 @@ public class Mod : MelonMod _diagnosticPatchLabels.Add(label); return true; } + + private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName, + string? prefix = null, string? postfix = null, + string? finalizer = null, bool logMiss = true) + { + var label = $"{typeName}.{methodName}(*)"; + if (_diagnosticPatchLabels.Contains(label)) + return true; + + var type = ResolveType(typeName); + if (type is null) + { + if (logMiss) Log.Warning($"[patch-miss] {label}: type not found"); + return false; + } + + var patched = 0; + foreach (var method in AccessTools.GetDeclaredMethods(type)) + { + if (method.Name != methodName) continue; + if (method.IsAbstract || method.ContainsGenericParameters) continue; + try + { + HarmonyInstance.Patch(method, + prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)), + postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)), + finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer))); + patched++; + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}"); + } + } + + if (patched == 0) + { + if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched"); + return false; + } + + _diagnosticPatchLabels.Add(label); + Log.Msg($"[patch-ok] {label} x{patched}"); + return true; + } + + private bool RegisterDeepRoomDiagnostics(bool logMisses) + { + var complete = true; + + complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError", + args: new[] { typeof(object) }, + prefix: nameof(RoomLoadDiagnostics.LogError_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException", + args: new[] { typeof(Exception) }, + prefix: nameof(RoomLoadDiagnostics.LogException_Prefix), + logMiss: logMisses); + + foreach (var method in new[] + { + "ConnectUsingSettings", + "ConnectToRegion", + "JoinRoom", + "JoinOrCreateRoom", + "CreateRoom", + "LeaveRoom", + "Disconnect", + }) + { + complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method, + prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix), + finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer), + logMiss: logMisses); + } + + foreach (var item in new (string Type, string Method)[] + { + ("CMMIJKCDKPL", "CDNPLAGEDBK"), + ("KGHEDLNPANK", "CDNPLAGEDBK"), + ("HMPNINFEDFB", "IJIOJAIBPKE"), + ("PhotonNetwork", "Disconnect"), + }) + { + complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method, + prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix), + finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer), + logMiss: logMisses); + } + + complete &= PatchKnownRoomLoadMoveNext(logMisses); + return true; + } + + private bool PatchKnownRoomLoadMoveNext(bool logMiss) + { + const string label = "room-load-state-machines.MoveNext"; + if (_diagnosticPatchLabels.Contains(label)) + return true; + + var nameNeedles = new[] + { + "OMJKHJLFOCO", "AFMPFLKGABO", + "AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI", + "MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM", + "JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG", + "IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL", + "BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM", + }; + + var patched = 0; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + foreach (var type in GetLoadableTypes(asm)) + { + var fullName = type.FullName ?? type.Name; + var match = false; + foreach (var needle in nameNeedles) + { + if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0) + { + match = true; + break; + } + } + if (!match) continue; + + IEnumerable methods; + try { methods = AccessTools.GetDeclaredMethods(type); } + catch { continue; } + + foreach (var method in methods) + { + if (method.Name != "MoveNext") continue; + if (method.IsAbstract || method.ContainsGenericParameters) continue; + try + { + HarmonyInstance.Patch(method, + finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer)))); + patched++; + Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}"); + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}"); + } + } + } + } + + if (patched == 0) + { + if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks"); + return false; + } + + _diagnosticPatchLabels.Add(label); + return true; + } + + private void RegisterProcessExceptionHandlers() + { + if (_processExceptionHandlersRegistered) return; + _processExceptionHandlersRegistered = true; + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + try + { + var ex = e.ExceptionObject as Exception; + DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}"); + } + catch { } + }; + + TaskScheduler.UnobservedTaskException += (_, e) => + { + try + { + DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}"); + } + catch { } + }; + } } // ── Patch holders ───────────────────────────────────────────────────── @@ -1094,6 +1292,228 @@ public class Mod : MelonMod // Harmony invokes. Kept separate from Mod for readability and so the // reflection lookup in GetPatchMethod stays simple. +internal static class RoomLoadDiagnostics +{ + private static readonly Dictionary Counts = new(); + private static int _globalCount; + + public static void LogError_Prefix(object? message) + { + DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "", 1600)}"); + WriteStack("unity-error"); + } + + public static void LogException_Prefix(Exception exception) + { + DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}"); + WriteStack("unity-exception"); + } + + public static void NamedCall_Prefix(MethodBase __originalMethod) + { + var name = FullMethodName(__originalMethod); + if (!ShouldLog("call:" + name, 80)) return; + + DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]"); + if (IsImportantCall(name)) + WriteStack("runtime-call"); + } + + public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod) + { + if (__exception is not null) + { + DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}"); + WriteStack("runtime-call-exception"); + } + return __exception; + } + + public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod) + { + var name = FullMethodName(__originalMethod); + if (!ShouldLog("enter:" + name, 120)) return; + DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}"); + } + + public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod) + { + var name = FullMethodName(__originalMethod); + if (!ShouldLog("exit:" + name, 120)) return; + DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}"); + } + + public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod) + { + if (__exception is not null) + { + DiagnosticPatches.Write( + $"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " + + $"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}"); + WriteStack("room-load-exception"); + } + return __exception; + } + + public static string FormatException(Exception? ex, object? fallback) + { + if (ex is null) return fallback?.ToString() ?? ""; + var builder = new StringBuilder(); + AppendException(builder, ex, 0); + return Sanitize(builder.ToString(), 5000); + } + + private static void AppendException(StringBuilder builder, Exception ex, int depth) + { + if (depth > 4) return; + if (builder.Length > 0) builder.Append(" | "); + builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message); + if (!string.IsNullOrWhiteSpace(ex.StackTrace)) + builder.Append(" stack=").Append(ex.StackTrace); + if (ex is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + AppendException(builder, inner, depth + 1); + } + else if (ex.InnerException is not null) + { + AppendException(builder, ex.InnerException, depth + 1); + } + } + + private static bool ShouldLog(string key, int perKeyLimit) + { + lock (Counts) + { + _globalCount++; + if (_globalCount > 12000) + return false; + + Counts.TryGetValue(key, out var count); + if (count >= perKeyLimit) + return false; + + Counts[key] = count + 1; + return true; + } + } + + private static string DumpInstanceFields(object? instance, int maxFields) + { + if (instance is null) return ""; + try + { + var type = instance.GetType(); + var parts = new List(); + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + foreach (var field in type.GetFields(flags)) + { + if (parts.Count >= maxFields) + { + parts.Add("..."); + break; + } + + object? value; + try { value = field.GetValue(instance); } + catch (Exception ex) + { + parts.Add($"{field.Name}="); + continue; + } + + parts.Add($"{field.Name}={DescribeValue(value, 400)}"); + } + + return $"{type.FullName} {{{string.Join("; ", parts)}}}"; + } + catch (Exception ex) + { + return $""; + } + } + + private static string DescribeValue(object? value, int limit) + { + if (value is null) return "null"; + + var type = value.GetType(); + if (value is string text) return "\"" + Sanitize(text, limit) + "\""; + if (value is Exception ex) return FormatException(ex, ex); + if (type.IsPrimitive || type.IsEnum || value is decimal) + return Sanitize(value.ToString() ?? "", limit); + if (value is IEnumerable enumerable && value is not string) + return DescribeEnumerable(enumerable, type, limit); + + var rendered = value.ToString(); + if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName) + return "<" + type.FullName + ">"; + return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">"; + } + + private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit) + { + var parts = new List(); + var count = 0; + foreach (var item in enumerable) + { + if (count++ >= 6) + { + parts.Add("..."); + break; + } + parts.Add(DescribeValue(item, 120)); + } + return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>"; + } + + private static string Signature(MethodBase method) + { + try + { + var parameters = method.GetParameters(); + var parts = new List(parameters.Length); + foreach (var parameter in parameters) + parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name); + return string.Join(", ", parts); + } + catch + { + return ""; + } + } + + private static string FullMethodName(MethodBase method) => + $"{method.DeclaringType?.FullName ?? ""}.{method.Name}"; + + private static bool IsImportantCall(string name) => + name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 || + name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0; + + private static void WriteStack(string tag) + { + try + { + DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false)); + } + catch (Exception ex) + { + DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}"); + } + } + + private static string Sanitize(string value, int limit) + { + var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1"); + redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1="); + redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); + return redacted.Length <= limit ? redacted : redacted[..limit] + "..."; + } +} + // ── Debug console access ─────────────────────────────────────────────── // The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game // dev console (text input + a DebugConsoleCommandConfig command table: @@ -1876,8 +2296,12 @@ internal static class HttpTracePatches private static readonly Regex BearerRegex = new( @"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+", RegexOptions.Compiled); + private static readonly Regex SensitiveHeaderRegex = new( + @"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+", + RegexOptions.Compiled); private static int _requestId; private static int _failureLogCount; + private static int _responseTupleLogCount; public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod) { @@ -1892,6 +2316,49 @@ internal static class HttpTracePatches $"service={SanitizeInline(__1?.ToString() ?? "")} path={SanitizeInline(__2?.ToString() ?? "")}"); } + public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod) + { + if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return; + + try + { + DiagnosticPatches.Write( + $"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + + DumpObjectFields(__instance, 20, 600)); + } + catch (Exception ex) + { + LogFailure("recnet-response-map-enter", ex); + } + } + + public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod) + { + if (_responseTupleLogCount > 80) return; + + try + { + DiagnosticPatches.Write( + $"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + + DumpObjectFields(__instance, 20, 600)); + } + catch (Exception ex) + { + LogFailure("recnet-response-map-exit", ex); + } + } + + public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod) + { + if (__exception is not null) + { + DiagnosticPatches.Write( + $"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + + $"{RoomLoadDiagnostics.FormatException(__exception, __exception)} state={DumpObjectFields(__instance, 36, 1200)}"); + } + return __exception; + } + public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod) { try @@ -1909,6 +2376,34 @@ internal static class HttpTracePatches } } + public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod) + { + try + { + DiagnosticPatches.Write( + $"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + + $"{DescribeRequestAndResponse(__instance)}"); + } + catch (Exception ex) + { + LogFailure("callback-enter", ex); + } + } + + public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod) + { + try + { + DiagnosticPatches.Write( + $"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " + + $"{DescribeRequestAndResponse(__instance)}"); + } + catch (Exception ex) + { + LogFailure("callback-exit", ex); + } + } + public static void TraceUrl(string? url, string source) { if (string.IsNullOrWhiteSpace(url)) return; @@ -2091,6 +2586,134 @@ internal static class HttpTracePatches return ""; } + private static string DescribeRequestAndResponse(object request) + { + var uri = TryReadRequestUri(request)?.ToString() ?? ""; + var method = TryReadHttpMethod(request); + var response = TryReadResponseObject(request); + var responseText = response is null ? "" : DescribeResponseObject(response); + var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested"); + return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}"; + } + + private static object? TryReadResponseObject(object request) + { + foreach (var name in new[] { "Response", "response", "_response", "k__BackingField" }) + { + if (TryReadMemberValue(request, name, out var value) && value is not null) + return value; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + foreach (var prop in request.GetType().GetProperties(flags)) + { + if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue; + if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue; + try + { + var value = prop.GetValue(request); + if (value is not null) return value; + } + catch { } + } + + foreach (var field in request.GetType().GetFields(flags)) + { + if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue; + try + { + var value = field.GetValue(request); + if (value is not null) return value; + } + catch { } + } + + return null; + } + + private static string DescribeResponseObject(object response) + { + var known = DescribeKnownMembers(response, + "StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error"); + return $"{response.GetType().FullName} {{{known}}}"; + } + + private static string DescribeKnownMembers(object obj, params string[] names) + { + var parts = new List(); + foreach (var name in names) + { + if (!TryReadMemberValue(obj, name, out var value)) continue; + parts.Add($"{name}={FormatValue(value, 700)}"); + } + return parts.Count == 0 ? "" : string.Join("; ", parts); + } + + private static string FormatValue(object? value, int limit) + { + if (value is null) return "null"; + if (value is byte[] bytes) + return $"byte[{bytes.Length}]"; + if (value is IEnumerable enumerable && value is not string) + return FormatEnumerable(enumerable, value.GetType(), limit); + var text = value is Exception ex + ? $"{ex.GetType().Name}: {ex.Message}" + : value.ToString() ?? ""; + text = SanitizeInline(text); + return text.Length <= limit ? text : text[..limit] + "..."; + } + + private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit) + { + try + { + var parts = new List(); + var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?"; + var shown = 0; + + foreach (var item in enumerable) + { + if (shown++ >= 6) + { + parts.Add("..."); + break; + } + + parts.Add(FormatValue(item, 180)); + } + + var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]"; + return rendered.Length <= limit ? rendered : rendered[..limit] + "..."; + } + catch (Exception ex) + { + return $"{type.FullName} "; + } + } + + private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count) + { + count = 0; + if (enumerable is ICollection collection) + { + count = collection.Count; + return true; + } + + try + { + var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (prop?.GetValue(enumerable) is int value) + { + count = value; + return true; + } + } + catch { } + + return false; + } + private static byte[]? TryCopyBytes(object? value, int limit) { if (value is null) return null; @@ -2163,6 +2786,43 @@ internal static class HttpTracePatches return false; } + private static string DumpObjectFields(object? instance, int maxFields, int valueLimit) + { + if (instance is null) return ""; + + try + { + var type = instance.GetType(); + var parts = new List(); + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + foreach (var field in type.GetFields(flags)) + { + if (parts.Count >= maxFields) + { + parts.Add("..."); + break; + } + + object? value; + try { value = field.GetValue(instance); } + catch (Exception ex) + { + parts.Add($"{field.Name}="); + continue; + } + + parts.Add($"{field.Name}={FormatValue(value, valueLimit)}"); + } + + return $"{type.FullName} {{{string.Join("; ", parts)}}}"; + } + catch (Exception ex) + { + return $""; + } + } + private static string RedactHeaderValue(string name, string? value) { if (string.IsNullOrWhiteSpace(value)) return ""; @@ -2181,6 +2841,7 @@ internal static class HttpTracePatches { var redacted = SensitivePairRegex.Replace(value, "$1="); redacted = BearerRegex.Replace(redacted, "Bearer "); + redacted = SensitiveHeaderRegex.Replace(redacted, "$1: "); redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); return redacted.Length <= 1200 ? redacted : redacted[..1200] + "..."; } diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index c040d4d..9bc0d00 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -151,9 +151,9 @@ public class PlayerEventsController( [AllowAnonymous] [HttpPost("api/playerevents/v1/bulk")] [HttpGet("api/playerevents/v1/bulk")] - public async Task Bulk([FromBody] EventBulkRequest? body) + public async Task Bulk() { - var ids = await ReadEventIdsAsync(body); + var ids = await ReadEventIdsAsync(); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await db.PlayerEvents .Where(e => ids.Contains(e.Id)) @@ -614,29 +614,73 @@ public class PlayerEventsController( private async Task GetOwnedEventAsync(long eventId) => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); - private async Task> ReadEventIdsAsync(EventBulkRequest? body) + private async Task> ReadEventIdsAsync() { var ids = new List(); - if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds); - if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds); - if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids); foreach (var value in Request.Query.SelectMany(q => q.Value)) - foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + AddDelimitedIds(ids, value); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" }) foreach (var value in form[key]) - foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + AddDelimitedIds(ids, value); + } + else if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" }) + { + if (doc.RootElement.TryGetProperty(key, out var prop)) + AddJsonIds(ids, prop); + } + } + else + { + AddJsonIds(ids, doc.RootElement); + } + } + catch (JsonException) + { + } } return ids.Distinct().Take(200).ToList(); } + private static void AddDelimitedIds(List ids, string? value) + { + foreach (var part in (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + } + + private static void AddJsonIds(List ids, JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + AddJsonIds(ids, item); + break; + case JsonValueKind.Number: + if (element.TryGetInt64(out var id) && id > 0) ids.Add(id); + break; + case JsonValueKind.String: + AddDelimitedIds(ids, element.GetString()); + break; + } + } + private async Task ReadStringFieldAsync(params string[] names) { var fields = await ReadRequestFieldsAsync(); diff --git a/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs b/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs index 9bf2aa3..bb06cab 100644 --- a/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs @@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting; /// [ApiController] [Authorize] -public class ReportsController(DorkNetDbContext db) : ControllerBase +public class ReportsController(DorkNetDbContext db, ILogger logger) : ControllerBase { [HttpGet("api/PlayerReporting/v1/voteToKickReasons")] [AllowAnonymous] @@ -215,7 +215,15 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase GameSessionId = gameSessionId ?? 0, Category = "hile", }); - await db.SaveChangesAsync(); + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex)) + { + db.ChangeTracker.Clear(); + logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy"); + } return HileResult(); } diff --git a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs index ccac2e9..8ad0c62 100644 --- a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs +++ b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs @@ -74,6 +74,38 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ [AllowAnonymous] public IActionResult Active() => Ok(CurrentProgressionEventId()); + /// + /// The current player's progress record for a given progression event. + /// The 2023 client fetches this during InitialRoomLoad + /// (api/progressionEvents/record/{id}, where {id} is the + /// yyyyMMdd event id). A 404 here faults the room-load coroutine + /// with a NullReferenceException and traps the client in a dorm + /// matchmaking loop, so we always return a non-null record. + /// + [HttpGet("api/progressionEvents/record/{progressionEventId:long}")] + [Authorize] + public async Task Record(long progressionEventId) + { + var me = this.RequireCurrentPlayerId(); + + // Count this player's completed progression-event objectives so the + // record reflects real progress rather than a flat zero. + var completed = await db.ObjectiveProgress + .CountAsync(o => o.PlayerId == me + && o.IsCompleted + && o.Key.StartsWith("progressionEvent:")); + + return Ok(new + { + ProgressionEventId = progressionEventId, + Xp = 0, + ClaimedRewardIndex = completed > 0 ? completed - 1 : -1, + PurchasedXpBoostCount = 0, + DailyBoostGameplayMinutes = 0, + XpBoostExpiresAt = (DateTime?)null, + }); + } + private static long CurrentProgressionEventId() { var (start, _) = CurrentWeekWindow(); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 9e01bcd..c20d838 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1184,32 +1184,16 @@ public class RoomsController( // no DeserializeRoomDataBlobCoroutine, the master init // proceeds with default-zero permissions. // - // • Saved dorm / customisable rooms: DataBlobName is the - // latest uploaded blob name (RoomEntity.CurrentDataBlobName, - // written by StorageController on /upload). Unsaved dorms - // deliberately use "" so the baked DormRoom scene loads - // without deserializing the synthetic all-perms blob. The - // role-only default blob made December wait forever on - // StandaloneShapeContainer big-data during first-dorm entry. - // • AG-Original rooms: DataBlobName="" → completed-promise - // short-circuit → no download → no leaked stale-blob - // behaviour → master flow proceeds → spawn fires. Maker - // Pen stays locked, which matches public-server semantics - // (you can't edit the rec center). - // - // Heuristic: "customisable" iff it's the dorm OR a user-created - // room (CreatorPlayerId != 1, the seeded system account that - // owns AG-Originals like the rec center). User clones inherit - // the all-perms default blob until their first save replaces - // it via StorageController; the rec center / paintball / etc. - // serve "" so the persistence flow short-circuits. + // If a room has a saved blob, use it. Otherwise point at + // room__v1.dat, which misses S3 and is served by + // RoomDataBlobService.GetDefaultBlob. That default is a captured + // PersistedRoomData (v38); the older synthetic role-only blob and + // empty DataBlobName path both leave the 2023 room-permissions + // runtime without data it dereferences during spawn. var roleList = roles ?? Array.Empty(); - var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted); - var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName - : room.IsDormRoom ? "" - : customisable ? $"room_{room.Id}_v1.dat" : ""; + : $"room_{room.Id}_v1.dat"; // Multi-scene rooms (imported via tools/import-room.ps1) carry one // RoomSceneEntity row per sub-room. Emit the full Scenes[] array @@ -1231,8 +1215,9 @@ public class RoomsController( DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && (overrideSceneId == s.OrderIndex || sceneRows.Count == 1) ? overrideDataBlobName - : room.IsDormRoom ? dataBlobName - : s.DataBlobName, + : !string.IsNullOrWhiteSpace(s.DataBlobName) + ? s.DataBlobName + : dataBlobName, MaxPlayers = s.MaxPlayers, CanMatchmakeInto = s.CanMatchmakeInto, DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) @@ -1322,12 +1307,12 @@ public class RoomsController( IReadOnlyList? roles = null) { var roleList = roles ?? Array.Empty(); - var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted); - var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; + // 2023's room-permissions runtime needs a real PersistedRoomData + // source during spawn. If no saved blob exists, use the captured + // default via the synthetic room__v1.dat CDN path. var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName - : room.IsDormRoom ? "" - : customisable ? $"room_{room.Id}_v1.dat" : ""; + : $"room_{room.Id}_v1.dat"; var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); @@ -1339,8 +1324,9 @@ public class RoomsController( Name = s.Name, DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 ? overrideDataBlobName - : room.IsDormRoom ? dataBlobName - : s.DataBlobName, + : !string.IsNullOrWhiteSpace(s.DataBlobName) + ? s.DataBlobName + : dataBlobName, DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), IsSandbox = s.IsSandbox, @@ -1364,27 +1350,11 @@ public class RoomsController( }, }; - static int WireRole(int role) => role switch - { - 0 => 30, // CoOwner - 1 => 20, // Moderator - 2 => 10, // Host - _ => 0, - }; var wireRoles = new List { - new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 }, + BuildRoomAccountRoleWire(room.CreatorPlayerId, 255), }; - wireRoles.AddRange(roleList.Select(r => - { - var role = WireRole(r.Role); - return (object)new - { - AccountId = r.PlayerId, - Role = r.Accepted ? role : 0, - InvitedRole = r.Accepted ? 0 : role, - }; - })); + wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire)); var tags = string.IsNullOrEmpty(room.TagsCsv) ? Array.Empty() @@ -2710,9 +2680,8 @@ public class RoomsController( // ── Bare-path roles ──────────────────────────────────────────────── - /// GET rooms/{id}/roles — accepted role grants on - /// the room. Owner is implicit (CreatorPlayerId) and surfaces under - /// Role=30; co-owner/mod/host rows come from RoomRoles. + /// GET rooms/{id}/roles — account role DTOs consumed by + /// 2023's EFHPLDPNGIM deserializer. [HttpGet("rooms/{roomId:long}/roles")] public async Task RolesList(long roomId) { @@ -2721,15 +2690,9 @@ public class RoomsController( var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); var list = new List(rows.Count + 1) { - new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true }, + BuildRoomAccountRoleWire(room.CreatorPlayerId, 255), }; - list.AddRange(rows.Select(r => (object)new - { - RoomId = roomId, - PlayerId = (int)r.PlayerId, - Role = r.Role, - Accepted = r.Accepted, - })); + list.AddRange(rows.Select(BuildRoomRoleGrantWire)); return Ok(list); } @@ -2739,11 +2702,11 @@ public class RoomsController( var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId == playerId) - return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true }); + return Ok(BuildRoomAccountRoleWire(playerId, 255)); var row = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId); if (row is null) return NotFound(); - return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted }); + return Ok(BuildRoomRoleGrantWire(row)); } public sealed class GrantRoleRequest { public int? Role { get; set; } } @@ -2911,6 +2874,44 @@ public class RoomsController( return roomRole >= 0; } + private static object BuildRoomRoleGrantWire(RoomRoleEntity role) + => BuildRoomAccountRoleWire( + role.PlayerId, + ToClientRoomRole(role.Role), + role.Accepted, + role.GrantedByPlayerId); + + private static object BuildRoomAccountRoleWire( + long accountId, + int role, + bool accepted = true, + long? lastChangedByAccountId = null) + => new + { + AccountId = ToClientAccountId(accountId), + Role = accepted ? role : 0, + LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId), + InvitedRole = accepted ? 0 : role, + }; + + private static int ToClientRoomRole(int roomRole) => roomRole switch + { + 0 => 30, // CoOwner + 1 => 20, // Moderator + 2 => 10, // Host + _ => 0, + }; + + private static int ToClientAccountId(long accountId) + => accountId > int.MaxValue + ? int.MaxValue + : accountId < int.MinValue + ? int.MinValue + : (int)accountId; + + private static int? ToClientAccountId(long? accountId) + => accountId.HasValue ? ToClientAccountId(accountId.Value) : null; + private async Task BuildRoomServerDetailsWithRolesAsync(long roomId) { var room = await db.Rooms.FirstAsync(r => r.Id == roomId); diff --git a/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs b/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs index e8948ee..7c34ac7 100644 --- a/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs +++ b/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs @@ -100,15 +100,132 @@ public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase [AllowAnonymous] public async Task Bulk() { - var fields = await ReadFieldsAsync(); - var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"); - if (ids.Count == 0) return Ok(Array.Empty()); + // The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1, + // "itemId":""}]} — Ids is an array of OBJECTS, not flat guid + // strings. The generic field-flattener collapses each object to its + // JSON text, so the old ReadGuidList parsed ZERO guids and this + // endpoint always returned [] — the client's UGC resolver then + // re-queried the same ids forever (the growing-list flood in the + // server log), the referenced items never resolved, and the online + // player/avatar spawn null-reffed on an unresolved custom item + // (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce). + var requested = await ReadBulkIdsAsync(); + if (requested.Count == 0) return Ok(Array.Empty()); + + var ids = requested.Select(r => r.Id).ToList(); var rows = await db.UgcPurchasables .Where(i => !i.IsDeleted && ids.Contains(i.PublicId)) .ToListAsync(); - return Ok(rows.Select(ToWire)); + var byId = rows.ToDictionary(r => r.PublicId); + + // Return an entry for EVERY requested id, in request order. Items not + // in our DB (custom UGC the player owns from real Rec Room that we + // never ingested) get a synthetic placeholder so the client treats + // the reference as resolved instead of spinning. + var result = requested + .Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType)) + .ToList(); + return Ok(result); } + /// Parse the bulk request's item ids. Handles the 2023 client's + /// JSON object-array shape (Ids:[{itemType,itemId}]) as well as + /// flat guid lists in the body/form/query for older callers. + private async Task> ReadBulkIdsAsync() + { + var list = new List<(Guid, int)>(); + var seen = new HashSet(); + void Add(Guid id, int itemType) + { + if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType)); + } + + if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object + && (TryGetPropertyCI(root, "Ids", out var idsEl) + || TryGetPropertyCI(root, "itemIds", out idsEl) + || TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl)) + && idsEl.ValueKind == JsonValueKind.Array) + { + foreach (var el in idsEl.EnumerateArray()) + { + if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat)) + { + Add(flat, 0); + } + else if (el.ValueKind == JsonValueKind.Object) + { + var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0; + if ((TryGetPropertyCI(el, "itemId", out var idEl) + || TryGetPropertyCI(el, "id", out idEl) + || TryGetPropertyCI(el, "ugcPurchasableId", out idEl)) + && Guid.TryParse(idEl.GetString(), out var guid)) + { + Add(guid, itemType); + } + } + } + } + } + catch (JsonException) + { + } + } + + // Fallback: flat guid lists in form/query (older callers). + if (list.Count == 0) + { + var fields = await ReadFieldsAsync(); + foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds")) + Add(id, 0); + } + + return list.Take(200).ToList(); + } + + private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value) + { + foreach (var prop in obj.EnumerateObject()) + { + if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + value = default; + return false; + } + + /// Minimal placeholder for a requested ugc item we don't have a + /// row for. Same wire shape as so the client's + /// deserializer resolves the reference; zero price / empty metadata. + private static object SyntheticWire(Guid id, int itemType) => new + { + UgcPurchasableId = id, + PurchasableItemId = id, + Id = id, + InternalId = 0L, + RoomId = 0L, + CreatorPlayerId = 0, + Name = string.Empty, + Description = string.Empty, + ImageName = string.Empty, + Price = 0, + CurrencyType = 2, + ItemType = itemType, + IsFeatured = false, + SortOrder = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + private async Task FindItemAsync(Dictionary fields) { var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id"); diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 4bd58e5..762713f 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -215,19 +215,7 @@ public class GoToController( // (roomInstanceId differs → reset state machine) plus "new // photon match" (photonRoomId differs → leave/rejoin → // SceneLoaded events that ultimately dismiss the dialog). - resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId; - resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}"; - resp.RoomInstance.Name = subRoomName; - resp.RoomInstance.SubRoomId = subId; - if (subLocation is not null) resp.RoomInstance.Location = subLocation; - // dataBlob on the matchmaking response tells the client which - // PersistedRoomData blob to download for the entered scene. - // RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the - // string-with-default reader (call 0x180EE6210), so empty is - // technically legal — but if it's empty the watch falls back - // to the parent room's RoomDataBlobName, which is the lobby's - // blob, so the sub-scene visually never changes. - if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob; + ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob); // Private/new-instance mutation goes AFTER sub-room mutation so the // PhotonRoomId we register includes the _sub{N} suffix as its @@ -240,6 +228,135 @@ public class GoToController( return Ok(resp); } + /// Mutate in place so it represents + /// the given sub-scene of its parent room. Both PhotonRoomId AND + /// RoomInstanceId must differ from the lobby's or the watch's + /// transition state machine never fires: + /// • Same photon + same roomInstanceId → the new dataBlob downloads + /// but the watch never transitions; "Going to X" hangs forever. + /// • Different photon + same roomInstanceId → the watch sees the id + /// unchanged, treats the response as a no-op presence update and + /// skips the join flow entirely. + /// Folding subId into RoomInstanceId AND forking PhotonRoomId gives + /// the watch both signals it needs ("new instance" + "new photon + /// match"). dataBlob tells the client which PersistedRoomData to + /// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0, + /// reads it with a string-with-default reader so empty is legal) — + /// but an empty blob makes the watch fall back to the parent room's + /// lobby blob, so the sub-scene never changes visually. Only override + /// location/blob when we actually resolved one. + private static void ApplySubRoomMutation( + MatchmakingResponseDto resp, long subId, string subRoomName, + string? subLocation, string? subDataBlob) + { + resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId; + resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}"; + resp.RoomInstance.Name = subRoomName; + resp.RoomInstance.SubRoomId = subId; + if (subLocation is not null) resp.RoomInstance.Location = subLocation; + if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob; + } + + /// POST match.*/matchmake/room/{roomId} — the 2023 + /// client's primary "join this public room" call + /// (RecNet.Matchmaking, format string "matchmake/room/{0}", + /// same sender HHJFFJEHLOB as the working matchmake/dorm, + /// so it's a POST returning ). + /// It navigates by NUMERIC roomId straight from the room tile, NOT by + /// name like the older /goto/room/{name} path. With no route + /// here every public-room join 404s and the watch bounces the player + /// back to their dorm ("Matchmaking request failed + /// (matchmake/room/N): HTTP Error 404"). We resolve the id to its + /// room and reuse so dorm/private/ + /// dataBlob/JoinMode handling stays identical to . + /// GET is accepted too as cheap insurance against a verb mismatch. + [HttpPost("/matchmake/room/{roomId:long}")] + [HttpGet("/matchmake/room/{roomId:long}")] + public async Task> MatchmakeRoom( + long roomId, + [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, + [FromForm(Name = "JoinMode")] int joinMode = 0, + [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) + { + logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId); + // 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows + // its standard "room not found" toast instead of softlocking. + return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null }); + } + + var resp = await BuildResponseAsync(room.Name); + await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode); + await RecordResponseAsync(resp); + await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp); + logger.LogInformation( + "[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}", + this.CurrentPlayerId(), roomId, room.Name, + resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId); + return Ok(resp); + } + + /// POST match.*/matchmake/room/{roomId}/{subRoomId} — + /// sub-room form (RecNet.Matchmaking "matchmake/room/{0}/{1}"). + /// Unlike (which takes a sub-room NAME and + /// fuzzy-matches it) the sub-room arrives here as a numeric + /// , so we resolve the scene + /// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene — + /// the base response already represents it, so we skip the + /// mutation. + [HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")] + [HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")] + public async Task> MatchmakeSubRoom( + long roomId, long subRoomId, + [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, + [FromForm(Name = "JoinMode")] int joinMode = 0, + [FromForm(Name = "AdditionalPlayerIds")] List? additionalPlayerIds = null) + { + var room = await rooms.GetByIdAsync(roomId); + if (room is null) + { + logger.LogWarning( + "[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist", + roomId, subRoomId); + return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null }); + } + + var resp = await BuildResponseAsync(room.Name); + + if (subRoomId != 0) + { + var scene = await db.RoomScenes.AsNoTracking() + .Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId) + .Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName }) + .FirstOrDefaultAsync(); + if (scene is not null) + { + logger.LogInformation( + "[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob}", + roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName); + ApplySubRoomMutation(resp, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName); + } + else + { + logger.LogWarning( + "[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " + + "forking ids onto the lobby scene so the watch still transitions", + roomId, subRoomId); + // Reuse the lobby location/blob (sub-scene won't differ + // visually) but still fold subId in so the watch leaves + // the current match instead of softlocking. + ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null); + } + } + + await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode); + await RecordResponseAsync(resp); + await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp); + return Ok(resp); + } + /// Per-sub-room scene location overrides for baked /// AG-Original rooms with multiple scenes. Each entry maps a /// "{room}/{subRoom}" key (lowercase) to the @@ -1172,10 +1289,7 @@ public class GoToController( if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; - if (room.IsDormRoom) return string.Empty; - - var customisable = room.CreatorPlayerId != 1; - return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; + return $"room_{room.Id}_v1.dat"; } /// diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 362323e..f345b9c 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -434,10 +434,7 @@ public class MatchPlayerController( if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; - if (room.IsDormRoom) return string.Empty; - - var customisable = room.CreatorPlayerId != 1; - return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; + return $"room_{room.Id}_v1.dat"; } } diff --git a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs index e8382a0..8f08672 100644 --- a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs +++ b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs @@ -14,7 +14,7 @@ namespace DorkNet.Server.Controllers.PlayerSettings; /// [ApiController] [Authorize] -public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase +public class PlayerSettingsController(DorkNetDbContext db, ILogger logger) : ControllerBase { private long CurrentPlayerId => this.RequireCurrentPlayerId(); @@ -64,7 +64,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase foreach (var (key, value) in updates) await UpsertSettingAsync(accountId, key, value, save: false); - await db.SaveChangesAsync(); + await SaveSettingsChangesAsync(); return Ok(); } @@ -78,7 +78,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase if (setting is null) return NotFound(); db.PlayerSettings.Remove(setting); - await db.SaveChangesAsync(); + await SaveSettingsChangesAsync(); return Ok(); } @@ -91,7 +91,20 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase else existing.Value = value; - if (save) await db.SaveChangesAsync(); + if (save) await SaveSettingsChangesAsync(); + } + + private async Task SaveSettingsChangesAsync() + { + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex)) + { + db.ChangeTracker.Clear(); + logger.LogWarning(ex, "[playersettings] dropping low-priority setting write because sqlite is busy"); + } } private async Task<(string? Key, string? Value)> ReadSettingKeyValueAsync() diff --git a/DorkNet.Server/Data/SqliteErrors.cs b/DorkNet.Server/Data/SqliteErrors.cs new file mode 100644 index 0000000..3e55147 --- /dev/null +++ b/DorkNet.Server/Data/SqliteErrors.cs @@ -0,0 +1,19 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Data; + +internal static class SqliteErrors +{ + public static bool IsBusy(DbUpdateException ex) + { + for (Exception? current = ex; current is not null; current = current.InnerException) + { + if (current is SqliteException sqlite && + (sqlite.SqliteErrorCode == 5 || sqlite.SqliteErrorCode == 6)) + return true; + } + + return false; + } +} diff --git a/DorkNet.Server/Data/default_room.room b/DorkNet.Server/Data/default_room.room new file mode 100644 index 0000000..d661d09 Binary files /dev/null and b/DorkNet.Server/Data/default_room.room differ diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index cc4e4f9..0435a6b 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -377,6 +377,10 @@ These all live on `match.rec.net` (locally: `match.localhost`). | POST | `goto/player/{playerId}` | REAL | `Match.GoToController.GoToPlayer` — uses `PlayerPresenceService` to find where the target is and routes the caller there | | POST | `goto/instance/{instanceId}` | REAL | `Match.GoToController.GoToInstance` — direct join to a specific room instance | | POST | `goto/invite/{inviteId}` | REAL | `Match.GoToController.GoToInvite` — resolves an invite to its target room/instance | +| POST/GET | `matchmake/room/{roomId}` | REAL | `Match.GoToController.MatchmakeRoom` — the 2023 client's "join this public room" call (navigates by numeric id, not name). Reuses `BuildResponseAsync`; without it every public-room join 404s and the player bounces to dorm | +| POST/GET | `matchmake/room/{roomId}/{subRoomId}` | REAL | `Match.GoToController.MatchmakeSubRoom` — sub-room form; resolves the `RoomScenes` row by `OrderIndex` | +| POST | `matchmake/dorm` | REAL | `Match.MatchController.MatchmakeDorm` — builds the caller's personal-dorm `RoomInstance` | +| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it | | POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case | ## Catch-all wildcards (informational) diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj index f1b8c07..d1e00db 100644 --- a/DorkNet.Server/DorkNet.Server.csproj +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -150,6 +150,13 @@ data\%(Filename)%(Extension) PreserveNewest + + + data\%(Filename)%(Extension) + PreserveNewest + data\images\%(Filename)%(Extension) PreserveNewest diff --git a/DorkNet.Server/Protos/dorknet_room_data.proto b/DorkNet.Server/Protos/dorknet_room_data.proto index c1656f3..e720c9d 100644 --- a/DorkNet.Server/Protos/dorknet_room_data.proto +++ b/DorkNet.Server/Protos/dorknet_room_data.proto @@ -37,8 +37,8 @@ message OverridableListStringData { // ── Per-role permission grants (rec_room/player_roles/player_room_role_data.proto) // -// Field numbers verified against the dump's `*FieldNumber` constants -// (TypeDefIndex 15953-ish in the 2020 build) and against the 2026 .proto. +// Field numbers verified against the 2023 WriteTo disassembly in +// C:\tmp\recroom-2023-03-21-isil\IsilDump\RecRoom.Protobuf.Runtime. message PlayerRoomRoleData { reserved 15; @@ -70,14 +70,17 @@ message PlayerRoomRoleData { OverridableBoolData disable_mic_auto_mute = 25; OverridableBoolData can_use_share_cam = 26; string role_guid = 27; + OverridableBoolData unknown_permission_20 = 28; + OverridableBoolData unknown_permission_21 = 29; + OverridableBoolData unknown_permission_22 = 30; + OverridableBoolData unknown_permission_23 = 31; } // ── Role collection (rec_room/player_roles/room_role_collection_data.proto) message RoomRoleCollectionData { repeated PlayerRoomRoleData room_roles = 1; - // field 2 (rec_net_migration_version) intentionally omitted — its enum - // type doesn't exist in the 2020 build, and proto3 default 0 is fine. + int32 rec_net_migration_version = 2; } // ── PersistedRoomData top-level (rec_room/persistence/persisted_room_data.proto) diff --git a/DorkNet.Server/Services/ImageSignatureService.cs b/DorkNet.Server/Services/ImageSignatureService.cs index 3c97350..9039364 100644 --- a/DorkNet.Server/Services/ImageSignatureService.cs +++ b/DorkNet.Server/Services/ImageSignatureService.cs @@ -17,14 +17,10 @@ public sealed class ImageSignatureService ?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID") ?? "p1"); - // `.rec.net` in the client's `KEY:RSA:p1.rec.net` literal gets byte-rewritten - // by patch-client.ps1 to match the server domain (e.g. `.localhost`). The - // header suffix we emit has to match that, or the client errors as - // "Signature malformed". By default we derive the suffix from the request - // Host, which works for the standard same-domain patcher setup. Override - // with ImageSigning:KeyIdHostSuffix if your client metadata was patched to - // a different value than the server's host (e.g. running the server at - // `dorknet.example` while the client metadata still says `localhost`). + // 2023 compares Content-Signature's key-id against the embedded + // KEY:RSA:p1.rec.net / d1.rec.net literals. If a specific client build + // is bytepatched to another suffix, ImageSigning:KeyIdHostSuffix can + // override this default. configuredKeyIdHostSuffix = config["ImageSigning:KeyIdHostSuffix"] ?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID_SUFFIX"); @@ -272,19 +268,12 @@ public sealed class ImageSignatureService var suffix = configuredKeyIdHostSuffix is { Length: > 0 } ? configuredKeyIdHostSuffix - : ExtractParentDomain(response.HttpContext.Request.Host.Host); + : "rec.net"; response.Headers["Content-Signature"] = $"key-id=KEY:RSA:{headerKeyId}.{suffix}; data={Convert.ToBase64String(signature)}"; } - private static string ExtractParentDomain(string host) - { - if (string.IsNullOrEmpty(host)) return "rec.net"; - var dot = host.IndexOf('.'); - return dot > 0 && dot < host.Length - 1 ? host[(dot + 1)..] : host; - } - private static byte[] PlaceholderSignature(byte[] bytes) { var signature = new byte[128]; diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 15ca63e..7792045 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -1,3 +1,4 @@ +using System.IO; using Google.Protobuf; using RecRoom.Protobuf; @@ -5,40 +6,115 @@ namespace DorkNet.Server.Services; /// /// Builds the binary `PersistedRoomData` protobuf the client downloads when -/// it loads a room scene. The 2020 RecRoom client gates the Maker Pen, -/// invention spawning, room-role editing and a dozen other tools behind -/// `RoomRoleCollectionData.RoomRoles[i].CanUseMakerPen.InnerValue` etc., -/// all of which default to `false` if the bytes we serve are empty. -/// -/// We emit a minimal blob with one PlayerRoomRoleData entry that flips -/// every relevant `OverridableBoolData` to `(overrides=true, inner_value=true)`, -/// which the client picks up regardless of which RoomRoleId the local -/// player ends up assigned to. Net result: the room's creator (and -/// everyone else, since this is a private server) gets all build/admin -/// tools the moment they spawn into the scene. -/// -/// Wire compatibility: our trimmed .proto only defines the messages and -/// field numbers we set — the 2020 client's full PersistedRoomData has -/// many more fields, but proto3 silently ignores unknown wire entries and -/// defaults missing ones, so the bytes we generate deserialize cleanly. +/// it loads a room scene. When a real room blob is missing, serve captured +/// root room data with saved objects stripped and a 2023-compatible role +/// collection instead of fabricating saved objects into the scene. /// public class RoomDataBlobService { private const int December2020PersistenceVersion = 26; - /// - /// Cached bytes of the "all-permissions-on" blob. Identical for every - /// room — there's no per-room state we vary yet, so building once at - /// startup beats rebuilding on every download request. - /// - private readonly byte[] _allPermsBlob = BuildAllPermsBlob(); private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData(); private readonly byte[] _rroEditableBlob = BuildRroEditableBlob(); private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData(); - public byte[] GetDefaultBlob() => _allPermsBlob; + /// The blob served when a room_<id>_v1.dat misses S3 + /// (unsaved dorms / fresh customisable rooms). Uses the captured + /// data/default_room.room as an input, but strips top-level + /// persistence_views before serving it. Those views can reference + /// prefabs absent from older clients and crash spawn with + /// "Invalid Prefab Name: \"\""; room-role data uses the 2023 migration + /// marker so the client does not re-run the legacy role importer. + private readonly byte[] _defaultBlob; + + public RoomDataBlobService() + { + _defaultBlob = LoadDefaultBlob(); + } + + public byte[] GetDefaultBlob() => _defaultBlob; public byte[] GetRroEditableBlob() => _rroEditableBlob; + private static byte[] LoadDefaultBlob() + { + try + { + var path = Path.Combine(AppContext.BaseDirectory, "data", "default_room.room"); + if (File.Exists(path)) + { + var bytes = File.ReadAllBytes(path); + if (bytes.Length > 0) + { + var msg = PersistedRoomData.Parser.ParseFrom(StripTopLevelField(bytes, 2)); + EnsureEditablePersistenceVersion(msg); + msg.RoomRoleData = BuildDefaultRoomRoleData(); + return msg.ToByteArray(); + } + } + } + catch + { + // Fall through to a minimal valid blob — never let a missing/locked + // file take down room loads entirely. + } + return BuildMinimalDefaultBlob(); + } + + private static byte[] StripTopLevelField(byte[] input, int fieldNumberToStrip) + { + using var output = new MemoryStream(input.Length); + var pos = 0; + while (pos < input.Length) + { + var fieldStart = pos; + var tag = ReadVarint(input, ref pos); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + SkipField(input, ref pos, wireType); + if (fieldNumber != fieldNumberToStrip) + output.Write(input, fieldStart, pos - fieldStart); + } + return output.ToArray(); + } + + private static ulong ReadVarint(byte[] input, ref int pos) + { + ulong value = 0; + var shift = 0; + while (pos < input.Length) + { + var b = input[pos++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + if (shift > 63) throw new InvalidDataException("Invalid protobuf varint."); + } + throw new EndOfStreamException("Unexpected end of protobuf varint."); + } + + private static void SkipField(byte[] input, ref int pos, int wireType) + { + switch (wireType) + { + case 0: + _ = ReadVarint(input, ref pos); + break; + case 1: + pos += 8; + break; + case 2: + pos += checked((int)ReadVarint(input, ref pos)); + break; + case 5: + pos += 4; + break; + default: + throw new InvalidDataException($"Unsupported protobuf wire type {wireType}."); + } + + if (pos > input.Length) throw new EndOfStreamException("Unexpected end of protobuf field."); + } + public byte[] OverlayAllPermsRoleData(byte[] existingBlob) { var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); @@ -66,14 +142,39 @@ public class RoomDataBlobService return msg.ToByteArray(); } + private static byte[] BuildMinimalDefaultBlob() + { + var msg = new PersistedRoomData + { + DEPRECATEDVersion = December2020PersistenceVersion, + RoomRoleData = BuildDefaultRoomRoleData(), + }; + + return msg.ToByteArray(); + } + private static RoomRoleCollectionData BuildAllPermsRoleData() { - var collection = new RoomRoleCollectionData(); + var collection = new RoomRoleCollectionData + { + RecNetMigrationVersion = 1, + }; collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator")); return collection; } + private static RoomRoleCollectionData BuildDefaultRoomRoleData() + { + var collection = new RoomRoleCollectionData + { + RecNetMigrationVersion = 1, + }; + collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR + + return collection; + } + private static byte[] BuildRroEditableBlob() { var msg = new PersistedRoomData @@ -87,7 +188,10 @@ public class RoomDataBlobService private static RoomRoleCollectionData BuildRroEditableRoleData() { - var collection = new RoomRoleCollectionData(); + var collection = new RoomRoleCollectionData + { + RecNetMigrationVersion = 1, + }; collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR collection.RoomRoles.Add(BuildPermissiveRole(4_194_304, 90, "Co-owner")); // AG_COOWNER collection.RoomRoles.Add(BuildPermissiveRole(8_388_608, 80, "Host")); // AG_HOST @@ -115,7 +219,7 @@ public class RoomDataBlobService DEPRECATEDIsRoleActive = true, DEPRECATEDIsAgRole = true, RoleName = name, - RoleVersion = 1, + RoleVersion = 20, RoleGuid = StableRoleGuid(roleId), // Display name override — keeps the watch's role-list UI from @@ -133,6 +237,7 @@ public class RoomDataBlobService // perms is false. CanAssignRoles = OverrideTrue(), CanInvite = OverrideTrue(), + DEPRECATEDCanEditCircuits = OverrideTrue(), CanStartGames = OverrideTrue(), CanTalk = OverrideTrue(), CanPrintPhotos = OverrideTrue(), @@ -145,6 +250,10 @@ public class RoomDataBlobService CanSaveInventions = OverrideTrue(), DisableMicAutoMute = OverrideTrue(), CanUseShareCam = OverrideTrue(), + UnknownPermission20 = OverrideTrue(), + UnknownPermission21 = OverrideTrue(), + UnknownPermission22 = OverrideTrue(), + UnknownPermission23 = OverrideTrue(), // Vote-kick permission — int field, set to a permissive value. // 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via @@ -168,7 +277,15 @@ public class RoomDataBlobService } private static string StableRoleGuid(int roleId) => - GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(); + roleId switch + { + 2_097_152 => "D8B12451-23C7-4B1D-B573-8C3717A47915", + 4_194_304 => "88EAECE9-D885-4568-BC96-AF316AD56663", + 8_388_608 => "BD0F2F3A-F931-419E-B50C-ECDFF3F56B52", + 16_777_216 => "32300035-3BEA-457E-95C0-1630AFDFA6BD", + 0 => Guid.Empty.ToString(), + _ => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(), + }; } internal static class GuidUtility diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 5ad4027..eefd2a9 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using System.Data; using DorkNet.Server.Data; using DorkNet.Server.Services; using Serilog; @@ -36,6 +37,7 @@ public static class DatabaseBootstrap if (db.Database.IsSqlite()) { + await ConfigureSqliteForLocalConcurrencyAsync(db); BaselineExistingSchemaIfNeeded(db); db.Database.Migrate(); } @@ -171,6 +173,27 @@ public static class DatabaseBootstrap Controllers.Health.HealthController.MigrationsComplete = true; } + private static async Task ConfigureSqliteForLocalConcurrencyAsync(DorkNetDbContext db) + { + var conn = db.Database.GetDbConnection(); + var closeAfter = conn.State != ConnectionState.Open; + if (closeAfter) await conn.OpenAsync(); + + try + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "PRAGMA journal_mode=WAL;"; + await cmd.ExecuteScalarAsync(); + + cmd.CommandText = "PRAGMA busy_timeout=30000;"; + await cmd.ExecuteNonQueryAsync(); + } + finally + { + if (closeAfter) await conn.CloseAsync(); + } + } + /// Idempotently create the SignupCodes + PendingDevices /// tables on whichever provider is in use. Safe to run every boot: /// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 16c91fc..a28743a 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using DorkNet.Server.Compat; @@ -88,7 +89,14 @@ public static class ServiceCollectionExtensions if (string.IsNullOrWhiteSpace(dbPath)) dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db"); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); - opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server")); + var conn = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Shared, + DefaultTimeout = 30, + }.ToString(); + opt.UseSqlite(conn, lite => lite.MigrationsAssembly("DorkNet.Server")); } }); }