From 38fbd0faf114f125c528927b0494d2665b8a2f35 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:02:13 +0300 Subject: [PATCH] Refactor room data blob handling and permissions - Updated the RoomsController to utilize synthetic CDN keys for data blobs. - Introduced methods for determining current or synthetic data blob names. - Adjusted room role permissions to align with new game role definitions. - Enhanced the SanitizeController with a new endpoint for purity checks. - Modified Match and PlayerPresence services to handle deferred presence commits. - Improved RoomDataBlobService to support new game role and tool tag settings. - Added support for boot dorm grace period in PlayerPresenceService. - Updated protobuf definitions to reflect new room role and game role structures. You are now able to go into dorm and the rec center! --- DorkNet.ClientMod/Mod.cs | 334 +++++++++++++++++- .../API/Rooms/V2/RoomsController.cs | 47 ++- .../API/Sanitize/SanitizeController.cs | 31 ++ .../Controllers/Match/GoToController.cs | 2 +- .../Controllers/Match/MatchController.cs | 66 +++- .../Match/MatchPlayerController.cs | 24 +- DorkNet.Server/Protos/dorknet_room_data.proto | 116 +++++- DorkNet.Server/Services/JoinTimeoutService.cs | 16 +- .../Services/PlayerPresenceService.cs | 70 ++++ .../Services/RoomDataBlobService.cs | 93 +++-- DorkNet.Server/Services/RoomService.cs | 15 +- 11 files changed, 733 insertions(+), 81 deletions(-) diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index ea9e043..0915862 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -49,6 +49,13 @@ public class Mod : MelonMod private readonly HashSet _diagnosticPatchLabels = new(); private bool _networkPatchesRegistered; private bool _joinTracePatchesRegistered; + private bool _quitTraceCoreRegistered; + private bool _quitTraceGameComplete; + private int _quitTraceRetryFrame; + private bool _antiTamperCallbackPatchesComplete; + private int _antiTamperCallbackRetryFrame; + private bool _toxModPatchesComplete; + private int _toxModRetryFrame; private bool _diagnosticCoreRegistered; private bool _diagnosticGameComplete; private bool _firstUpdateLogged; @@ -178,6 +185,9 @@ public class Mod : MelonMod "NotifyServerCertificate", prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); } + RegisterQuitTracePatches(logMisses: true); + RegisterAntiTamperCallbackPatches(logMisses: true); + RegisterToxModPatches(logMisses: true); PatchFileHashCheckerCallback(); // Post-save blob-name refresh. The 2020 watch's // RoomPersistenceManager.RoomDataBlobName is only ever set by @@ -480,6 +490,93 @@ public class Mod : MelonMod return true; } + private void RegisterAntiTamperCallbackPatches(bool logMisses) + { + var complete = true; + complete &= PatchCallbackMethod( + "hile warning callback", + type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"), + method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"), + nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix), + logMisses); + complete &= PatchCallbackMethod( + "unknown DLL callback", + type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"), + method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"), + nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix), + logMisses); + _antiTamperCallbackPatchesComplete = complete; + } + + private void RegisterToxModPatches(bool logMisses) + { + var complete = true; + complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent", + "CanInitializeToxMod", + args: Type.EmptyTypes, + prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent", + "PhotonVoiceCreated", + prefix: nameof(ToxModPatches.PhotonVoiceCreated_Prefix), + logMiss: logMisses); + _toxModPatchesComplete = complete; + } + + private bool PatchCallbackMethod(string label, + Func typePredicate, + Func methodPredicate, + string prefix, + bool logMiss) + { + if (_diagnosticPatchLabels.Contains(label)) + return true; + + var prefixMethod = GetPatchMethod(prefix); + var patched = 0; + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + foreach (var type in GetLoadableTypes(asm)) + { + if (!typePredicate(type)) continue; + foreach (var method in AccessTools.GetDeclaredMethods(type)) + { + if (method.IsAbstract || method.ContainsGenericParameters) continue; + if (!methodPredicate(method)) continue; + try + { + HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod)); + patched++; + Log.Msg($"[patch-ok] {label} {type.FullName}.{method.Name}"); + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}"); + } + } + } + } + + if (patched == 0) + { + if (logMiss) Log.Warning($"[patch-miss] {label}"); + return false; + } + + _diagnosticPatchLabels.Add(label); + return true; + } + + private static bool TypeNameContains(Type type, string value) + { + var fullName = type.FullName ?? type.Name; + return fullName.Contains(value, StringComparison.Ordinal); + } + + private static bool MethodNameContains(MethodInfo method, string value) + => method.Name.Contains(value, StringComparison.Ordinal); + private static bool MatchesFileHashCheckerCallbackType(Type type) { var fullName = type.FullName ?? type.Name; @@ -616,6 +713,24 @@ public class Mod : MelonMod Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)"); } + if (!_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300) + { + _quitTraceRetryFrame = 0; + RegisterQuitTracePatches(logMisses: false); + } + + if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300) + { + _antiTamperCallbackRetryFrame = 0; + RegisterAntiTamperCallbackPatches(logMisses: false); + } + + if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300) + { + _toxModRetryFrame = 0; + RegisterToxModPatches(logMisses: false); + } + } public override void OnApplicationQuit() @@ -921,7 +1036,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(RoomLoadDiagnostics), 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(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), 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; @@ -929,6 +1044,41 @@ public class Mod : MelonMod throw new MissingMethodException($"patch method '{name}' not found on any holder"); } + private void RegisterQuitTracePatches(bool logMisses) + { + if (!_quitTraceCoreRegistered) + { + TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes, + prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix), + logMiss: logMisses); + TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) }, + prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix), + logMiss: logMisses); + _quitTraceCoreRegistered = true; + } + + var complete = true; + complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", + prefix: nameof(QuitTracePatches.SessionManagerTryApplicationQuit_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", + args: new[] { typeof(int) }, + prefix: nameof(QuitTracePatches.SessionManagerTryApplicationQuitInt_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit", + args: new[] { typeof(int), typeof(string) }, + prefix: nameof(QuitTracePatches.SessionManagerFatalApplicationQuit_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM", + prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL", + prefix: nameof(QuitTracePatches.CreateExitTask_Prefix), + logMiss: logMisses); + + _quitTraceGameComplete = complete; + } + private void RegisterDiagnostics() { Log.Msg("=== Registering diagnostics ==="); @@ -1326,7 +1476,10 @@ internal static class RoomLoadDiagnostics continue; } - parts.Add($"{field.Name}={DescribeValue(value, 400)}"); + var described = DescribeValue(value, 400); + if (ShouldExpandField(field, value)) + described += " " + DumpNestedFields(value, 16); + parts.Add($"{field.Name}={described}"); } return $"{type.FullName} {{{string.Join("; ", parts)}}}"; @@ -1337,6 +1490,58 @@ internal static class RoomLoadDiagnostics } } + private static bool ShouldExpandField(FieldInfo field, object? value) + { + if (value is null) return false; + if (value is string) return false; + + var valueType = value.GetType(); + if (valueType.IsPrimitive || valueType.IsEnum || value is decimal) + return false; + if (value is IEnumerable) + return false; + + var fieldName = field.Name ?? string.Empty; + var typeName = valueType.FullName ?? valueType.Name; + return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 || + typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string DumpNestedFields(object value, int maxFields) + { + try + { + var type = value.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? nested; + try { nested = field.GetValue(value); } + catch (Exception ex) + { + parts.Add($"{field.Name}="); + continue; + } + + parts.Add($"{field.Name}={DescribeValue(nested, 220)}"); + } + + return "{nested " + string.Join("; ", parts) + "}"; + } + catch (Exception ex) + { + return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}"; + } + } + private static string DescribeValue(object? value, int limit) { if (value is null) return "null"; @@ -3287,6 +3492,21 @@ internal static class AntiCheatPatches } } +internal static class AntiTamperPatches +{ + public static bool CreateHileWarningCallback_Prefix(bool shouldQuit) + { + Mod.Log.Msg($"[hile] suppressed warning callback shouldQuit={shouldQuit}"); + return false; + } + + public static bool UnknownDllDetectedCallback_Prefix() + { + Mod.Log.Msg("[cheatmanager] suppressed unknown DLL fatal callback"); + return false; + } +} + internal static class FileHashCheckerPatches { public static bool InitializeCallback_Prefix(object? result) @@ -3296,6 +3516,22 @@ internal static class FileHashCheckerPatches } } +internal static class ToxModPatches +{ + public static bool CanInitializeToxMod_Prefix(ref bool __result) + { + __result = false; + Mod.Log.Msg("[toxmod] disabled local ToxMod init"); + return false; + } + + public static bool PhotonVoiceCreated_Prefix() + { + Mod.Log.Msg("[toxmod] skipped PhotonVoiceCreated"); + return false; + } +} + internal static class TlsPatches { // Both BouncyCastle TLS authenticators have the same `void @@ -3309,6 +3545,83 @@ internal static class TlsPatches } } +internal static class QuitTracePatches +{ + public static bool ApplicationQuit_Prefix() + { + Write("[quit-trace] UnityEngine.Application.Quit()"); + WriteStack(); + return true; + } + + public static bool ApplicationQuitInt_Prefix(int exitCode) + { + Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})"); + WriteStack(); + if (exitCode == 1550502249 || exitCode == 533223478) + { + Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}"); + return false; + } + return true; + } + + public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod) + { + Write($"[quit-trace] {FormatOriginal(__originalMethod)}"); + WriteStack(); + return true; + } + + public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod) + { + Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})"); + WriteStack(); + return true; + } + + public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod) + { + Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})"); + WriteStack(); + return true; + } + + public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod) + { + Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered"); + WriteStack(); + } + + public static void CreateExitTask_Prefix(MethodBase __originalMethod) + { + Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task"); + WriteStack(); + } + + private static string FormatOriginal(MethodBase method) + { + return $"{method.DeclaringType?.FullName}.{method.Name}"; + } + + private static void Write(string message) + { + try { Mod.Log.Msg(message); } catch { } + } + + private static void WriteStack() + { + try + { + Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false)); + } + catch (Exception ex) + { + Write("[quit-trace-stack] failed: " + ex.Message); + } + } +} + internal static class DiagnosticPatches { public static void Write(string message) @@ -3334,11 +3647,6 @@ internal static class DiagnosticPatches { Write($"[quit] UnityEngine.Application.Quit({exitCode})"); WriteStack(); - if (exitCode == 1550502249 || exitCode == 533223478) - { - Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}"); - return false; - } return true; } @@ -3356,23 +3664,23 @@ internal static class DiagnosticPatches public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod) { - Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}"); + Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}"); WriteStack(); - return false; + return true; } public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod) { - Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})"); + Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})"); WriteStack(); - return false; + return true; } public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod) { - Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})"); + Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})"); WriteStack(); - return false; + return true; } private static void WriteStack() diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index c20d838..b587e25 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1185,15 +1185,13 @@ public class RoomsController( // proceeds with default-zero permissions. // // If a room has a saved blob, use it. Otherwise point at - // room__v1.dat, which misses S3 and is served by + // a synthetic CDN key, 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 dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) - ? room.CurrentDataBlobName - : $"room_{room.Id}_v1.dat"; + var dataBlobName = CurrentOrSyntheticDataBlobName(room); // Multi-scene rooms (imported via tools/import-room.ps1) carry one // RoomSceneEntity row per sub-room. Emit the full Scenes[] array @@ -1215,9 +1213,7 @@ public class RoomsController( DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && (overrideSceneId == s.OrderIndex || sceneRows.Count == 1) ? overrideDataBlobName - : !string.IsNullOrWhiteSpace(s.DataBlobName) - ? s.DataBlobName - : dataBlobName, + : SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName), MaxPlayers = s.MaxPlayers, CanMatchmakeInto = s.CanMatchmakeInto, DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) @@ -1309,10 +1305,8 @@ public class RoomsController( var roleList = roles ?? Array.Empty(); // 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_{room.Id}_v1.dat"; + // default via the synthetic CDN path. + var dataBlobName = CurrentOrSyntheticDataBlobName(room); var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); @@ -1324,9 +1318,7 @@ public class RoomsController( Name = s.Name, DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 ? overrideDataBlobName - : !string.IsNullOrWhiteSpace(s.DataBlobName) - ? s.DataBlobName - : dataBlobName, + : SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName), DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), IsSandbox = s.IsSandbox, @@ -1352,7 +1344,7 @@ public class RoomsController( var wireRoles = new List { - BuildRoomAccountRoleWire(room.CreatorPlayerId, 255), + BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), }; wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire)); @@ -1522,6 +1514,7 @@ public class RoomsController( [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); + [HttpGet("player_room_data/{roomId:long}")] [HttpGet("rooms/{roomId:long}/playerdata/me")] [Authorize] public async Task PlayerDataForMe(long roomId) @@ -1545,6 +1538,7 @@ public class RoomsController( return Ok(new { + Data = "CAE=", RoomId = room?.Id ?? roomId, PlayerId = pid.Value, Favorite = favorited, @@ -2690,19 +2684,38 @@ public class RoomsController( var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); var list = new List(rows.Count + 1) { - BuildRoomAccountRoleWire(room.CreatorPlayerId, 255), + BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), }; list.AddRange(rows.Select(BuildRoomRoleGrantWire)); return Ok(list); } + private static string CurrentOrSyntheticDataBlobName(RoomEntity room) + { + if (room.IsDormRoom) + return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName); + + return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName) + ? room.CurrentDataBlobName + : RoomService.SyntheticDefaultRoomDataBlobName(room.Id); + } + + private static string SceneOrSyntheticDataBlobName(RoomEntity room, string? sceneBlobName, string fallbackBlobName) + { + if (!string.IsNullOrWhiteSpace(sceneBlobName) && + !(room.IsDormRoom && RoomService.IsLegacySyntheticDefaultRoomDataBlobName(room.Id, sceneBlobName))) + return sceneBlobName; + + return fallbackBlobName; + } + [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] public async Task RoleForPlayer(long roomId, long playerId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId == playerId) - return Ok(BuildRoomAccountRoleWire(playerId, 255)); + return Ok(BuildRoomAccountRoleWire(playerId, 30)); var row = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId); if (row is null) return NotFound(); diff --git a/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs b/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs index 6289c1a..521e455 100644 --- a/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs +++ b/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs @@ -84,6 +84,11 @@ public class SanitizeController(ILogger logger) : Controller public string? Text { get; set; } public string? Input { get; set; } public string? Value { get; set; } + public string? Context { get; set; } + public int? Intent { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ruleset")] + public int? Ruleset { get; set; } } /// POST api/sanitize/v1/purifyString — returns @@ -116,6 +121,32 @@ public class SanitizeController(ILogger logger) : Controller return Ok(ProfanityFilter.IsClean(input)); } + /// POST api/sanitize/v1/isPure — 2023 route used + /// by CV2 maker pen text such as port names. It imports an object + /// field named IsPure. + [HttpPost("api/sanitize/v1/isPure")] + public IActionResult IsPureV1( + [FromBody] SanitizeBody? body, + [FromForm(Name = "Text")] string? textForm, + [FromForm(Name = "Input")] string? inputForm, + [FromForm(Name = "Value")] string? valueForm, + [FromQuery(Name = "text")] string? textQuery) + { + var input = body?.Text ?? body?.Input ?? body?.Value + ?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty; + var isPure = ProfanityFilter.IsClean(input); + logger.LogInformation( + "[sanitize] isPure context={Context} intent={Intent} ruleset={Ruleset} valueLength={ValueLength} isPure={IsPure}", + body?.Context ?? string.Empty, + body?.Intent, + body?.Ruleset, + input.Length, + isPure); + + var json = System.Text.Json.JsonSerializer.Serialize(new { IsPure = isPure }); + return Content(json, "application/json"); + } + /// POST api/sanitize/isPure — December 2020 route /// used by room/settings forms. Its importer reads an IsPure /// object field, unlike the newer bare-bool route above. diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 762713f..4d62ee6 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -1289,7 +1289,7 @@ public class GoToController( if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; - return $"room_{room.Id}_v1.dat"; + return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } /// diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index 967881f..a9caf8d 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -37,8 +37,8 @@ public class MatchController( /// we mostly use this for visibility into Photon-side issues — /// without it, all we see in the catch-all is an opaque "200, empty /// reply" and no clue why a player crashed back to dorm. Failure - /// reports also clear the pending /goto room and push a ModerationKick - /// so the watch leaves the "Joining room" overlay instead of softlocking. + /// reports also clear the pending /goto room so the next matchmaking + /// attempt starts from a clean presence state. [HttpPost("/roominstance/{instanceId:long}/reportjoinresult")] public async Task ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result) { @@ -59,6 +59,7 @@ public class MatchController( { presence.SetRoom(pid, completed.Value.TargetRoom); presence.MarkActive(pid); + presence.EndBootDormGrace(pid); logger.LogInformation( "[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}", pid, @@ -71,6 +72,7 @@ public class MatchController( { presence.MarkActive(pid); presence.TouchRoom(pid); + presence.EndBootDormGrace(pid); } logger.LogInformation( "[joinresult] player={Player} instance={Instance} OK ({Label})", @@ -80,7 +82,7 @@ public class MatchController( { await joinTimeouts.MarkFailedAsync(pid, instanceId, label); logger.LogWarning( - "[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth", + "[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label})", pid, instanceId, code, label); } return Ok(); @@ -92,6 +94,22 @@ public class MatchController( var pid = this.CurrentPlayerId() ?? 0; if (pid == 0) return Unauthorized(); + var pending = joinTimeouts.GetPending(pid); + if (pending is { DeferPresenceCommit: true }) + { + presence.MarkActive(pid); + logger.LogInformation( + "[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence", + pid, + pending.Value.TargetRoom.RoomId, + pending.Value.TargetRoom.RoomInstanceId); + return Ok(new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = pending.Value.TargetRoom, + }); + } + var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid); presence.SetRoom(pid, current); presence.MarkActive(pid); @@ -110,8 +128,22 @@ public class MatchController( var appId = config["Photon:AppId"] ?? string.Empty; var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty; var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); + var currentRoom = presence.GetRoom(pid); + var permissions = BuildPhotonPermissionSeed(); + logger.LogInformation( + "[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}", + pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length); return Ok(new Dictionary { + // 2023 deserializes this endpoint as ALIIIAGAPND before room + // permissions initialization. If Permissions is missing/empty, + // OMJKHJLFOCO falls into the persisted-role migration path that + // currently NREs during first spawn. + ["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L, + ["PhotonAccessToken"] = string.Empty, + ["Permissions"] = permissions, + + // Existing custom-auth/app-id shape kept for Photon auth callers. ["ResultCode"] = 1, ["Message"] = string.Empty, ["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture), @@ -125,6 +157,30 @@ public class MatchController( }); } + private static object[] BuildPhotonPermissionSeed() + => + [ + BuildPhotonPermission(13, 30), // Can Use Maker Pen + BuildPhotonPermission(20, 30), // Can Spawn Inventions + BuildPhotonPermission(21, 30), // Can Spawn Consumables + BuildPhotonPermission(22, 30), // Can Use Room Reset Button + BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle + BuildPhotonPermission(13, 255), // Creator alias + BuildPhotonPermission(20, 255), + BuildPhotonPermission(21, 255), + BuildPhotonPermission(22, 255), + BuildPhotonPermission(23, 255), + ]; + + private static object BuildPhotonPermission(int permission, int role) => new + { + Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture), + Role = role, + Override = true, + Type = 0, + Value = "true", + }; + [HttpPost("/matchmake/dorm")] public async Task MatchmakeDorm() { @@ -132,11 +188,11 @@ public class MatchController( if (pid == 0) return Unauthorized(); var room = await BuildDormRoomInstanceAsync(pid); - presence.SetRoom(pid, room); + joinTimeouts.MarkPending(pid, room, deferPresenceCommit: true); presence.MarkActive(pid); await RecordVisitAsync(pid, room.RoomId); logger.LogInformation( - "[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon}", + "[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon} deferredPresence=True", pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId); return Ok(new MatchmakingResponseDto { diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index f345b9c..70c453d 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -23,6 +23,7 @@ public class MatchPlayerController( NotificationService notifications, RoomService rooms, PrivateInstanceService privateInstances, + JoinTimeoutService joinTimeouts, IConfiguration config, DorkNetDbContext db, ILogger log) : ControllerBase @@ -61,6 +62,8 @@ public class MatchPlayerController( var playerId = TryGetCurrentPlayerId(); if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) { + presence.Clear(playerId); + presence.BeginBootDormGrace(playerId); var replaced = presence.SetLock(playerId, loginLock); if (replaced) { @@ -276,8 +279,27 @@ public class MatchPlayerController( if (playerId == 0) return null; var room = presence.GetRoom(playerId); + var pending = joinTimeouts.GetPending(playerId); + if (pending is { DeferPresenceCommit: true }) + { + log.LogInformation( + "[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; returning null until join result", + playerId, + pending.Value.TargetRoom.RoomId, + pending.Value.TargetRoom.RoomInstanceId); + return null; + } + if (room is null) { + if (presence.IsBootDormGraceActive(playerId)) + { + log.LogInformation( + "[player-heartbeat] player={PlayerId} in boot dorm grace; returning null roomInstance", + playerId); + return null; + } + // Boot-time race: the watch hits /player/heartbeat before // /goto/room/DormRoom has finished recording presence. The // watch's OnPresenceHeartbeatResponse compares @@ -434,7 +456,7 @@ public class MatchPlayerController( if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; - return $"room_{room.Id}_v1.dat"; + return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); } } diff --git a/DorkNet.Server/Protos/dorknet_room_data.proto b/DorkNet.Server/Protos/dorknet_room_data.proto index e720c9d..4a85ed8 100644 --- a/DorkNet.Server/Protos/dorknet_room_data.proto +++ b/DorkNet.Server/Protos/dorknet_room_data.proto @@ -35,6 +35,25 @@ message OverridableListStringData { repeated string inner_value = 2; } +// ── Game role collection (rec_room/player_roles/game_role_collection_data.proto) + +message PlayerGameRoleData { +} + +message GameRoleCollectionData { + repeated PlayerGameRoleData DEPRECATED_game_roles = 1; +} + +// ── Tool tag settings (rec_room/tools/tool_tag_settings_data.proto) + +message TagData { + repeated string tags = 1; +} + +message ToolTagSettingsData { + TagData runtime_tag_data = 1; +} + // ── Per-role permission grants (rec_room/player_roles/player_room_role_data.proto) // // Field numbers verified against the 2023 WriteTo disassembly in @@ -70,31 +89,100 @@ 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; + OverridableBoolData can_spawn_inventions = 28; + OverridableBoolData can_spawn_consumables = 29; + OverridableBoolData can_use_room_reset_button = 30; + OverridableBoolData can_use_play_gizmos_toggle = 31; } // ── Role collection (rec_room/player_roles/room_role_collection_data.proto) +enum MigratedToRecNetVersion { + NotYetMigrated = 0; + MigratedToRecNet = 1; +} + message RoomRoleCollectionData { repeated PlayerRoomRoleData room_roles = 1; - int32 rec_net_migration_version = 2; + MigratedToRecNetVersion rec_net_migration_version = 2; } // ── PersistedRoomData top-level (rec_room/persistence/persisted_room_data.proto) // -// The full schema has many more fields — we only set the fields the -// December 2020 client needs for an editable minimal blob. Its -// RoomPersistenceManager checks field 1 against a minimum editable -// persistence version of 20, so generated blobs must carry a current -// version or room editors see the native "save data is out-of-date" prompt. +enum DEPRECATED_RoomPersistenceVersion { + option allow_alias = true; + V0NewRoomPersistenceVersion = 0; + V1Initial = 1; + V2ExtrusionsCenterOfMass = 2; + V3PersistentHostSettings = 3; + V4RadioPersistence = 4; + V5ExtrusionsPhysicsMode = 5; + V6CustomGameSettings = 6; + V7NewSandboxButton = 7; + V8CircuitChipOrientation = 8; + V9NewTriggerVolume = 9; + V10RoomRoles = 10; + V11SubextrusionsPersistedIds = 11; + V12EverythingARoom = 12; + V13CreationObjectAdded = 13; + V14InventionPieceAdded = 14; + V15GameConfigurationAdded = 15; + V16EraserMarkersFixed = 16; + V17GrabbableAdded = 17; + V18ManualSpawnPointsAdded = 18; + V19StuntRunnerButtonNodeChange = 19; + V20GameRolesInChips = 20; + V21PlayerAttractorConfigurable = 21; + V22AutosaveValidation = 22; + V23TriggerVolumeNoShapeContainers = 23; + V24GadgetVisibilityMovedToSubroomSetting = 24; + V25RoomKeysBeta = 25; + V26DirectionalVoiceChat = 26; + V27JuniorEnabledObjectFlag = 27; + V28SfxPlayForAll = 28; + V29UVManipulationAdded = 29; + V30HomeValuesAdded = 30; + V31SeatCollisionToggleAdded = 31; + V32StoringRotationAsVector4 = 32; + V33LightValuesFloat = 33; + V34OptimizeCollidersToggle = 34; + V35AudioPlayerProperties = 35; + V36BackpackToolsForbiddenInSpawner = 36; + V37ToolHandednessAdded = 37; + V38ProjectileSpreadFloat = 38; + LatestRoomPersistenceVersion = 38; +} + +enum PersistedRoomVersion { + option allow_alias = true; + V0New = 0; + V1SerializeDefaultTags = 1; + V2ProgressionEventPopupShop = 2; + V3SandboxGunHandleCanReload = 3; + V4StudioAssetBundleChanges = 4; + V5ParticleEmitterSettings = 5; + V6HolotarProjectorCV2 = 6; + V7LaserPointer = 7; + V8TextScreenRelease = 8; + V9AudioPlayerRolloff = 9; + V7SunDirection = 10; + V11StudioSingleAudioMixer = 11; + V12StudioExposedProperties = 12; + V13AuthorizeRestrictedTools = 13; + V14LaserDirection = 14; + V15StudioAudioClipRegistry = 15; + V16NewUGCShapes = 16; + V17TriggerHandleControlLabel = 17; + V18StudioObjectCanvasInteraction = 18; + V19February23BetaRelease = 19; + LatestVersion = 19; +} message PersistedRoomData { - int32 DEPRECATED_version = 1; - // field 2 (persistence_views) — repeated, default empty list. - // fields 3..22 — left at defaults. + DEPRECATED_RoomPersistenceVersion DEPRECATED_version = 1; + ToolTagSettingsData tool_tag_settings_data = 20; + GameRoleCollectionData game_role_data = 22; RoomRoleCollectionData room_role_data = 23; - // field 24+ — left at defaults. + PersistedRoomVersion version = 30; + bool creative_roles_enabled = 31; } diff --git a/DorkNet.Server/Services/JoinTimeoutService.cs b/DorkNet.Server/Services/JoinTimeoutService.cs index db0b751..9bf4f09 100644 --- a/DorkNet.Server/Services/JoinTimeoutService.cs +++ b/DorkNet.Server/Services/JoinTimeoutService.cs @@ -19,7 +19,7 @@ public sealed class JoinTimeoutService( { if (room is null) return; - if (IsDorm(room)) + if (IsDorm(room) && !deferPresenceCommit) { ClearForPlayer(playerId, "dorm"); return; @@ -40,6 +40,12 @@ public sealed class JoinTimeoutService( _ = WatchAsync(playerId, pending); } + public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId) + { + if (!_pending.TryGetValue(playerId, out var pending)) return null; + return (pending.TargetRoom, pending.DeferPresenceCommit); + } + public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? MarkCompleted(long playerId, long instanceId, string outcome) { if (!_pending.TryGetValue(playerId, out var pending) || pending.InstanceId != instanceId) @@ -55,7 +61,7 @@ public sealed class JoinTimeoutService( return (pending.TargetRoom, pending.DeferPresenceCommit); } - public async Task MarkFailedAsync(long playerId, long instanceId, string outcome) + public Task MarkFailedAsync(long playerId, long instanceId, string outcome) { var completed = MarkCompleted(playerId, instanceId, outcome); @@ -66,14 +72,14 @@ public sealed class JoinTimeoutService( logger.LogInformation( "[join-watchdog] failure ignored player={PlayerId} instance={InstanceId} current={CurrentInstance}", playerId, instanceId, current?.RoomInstanceId); - return; + return Task.CompletedTask; } presence.Clear(playerId); - await notifications.KickPlayerAsync(playerId, "Room join failed. Returning to dorm."); logger.LogWarning( - "[join-watchdog] kicked failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}", + "[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}", playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome); + return Task.CompletedTask; } private async Task WatchAsync(long playerId, PendingJoin pending) diff --git a/DorkNet.Server/Services/PlayerPresenceService.cs b/DorkNet.Server/Services/PlayerPresenceService.cs index df2de05..12d89bc 100644 --- a/DorkNet.Server/Services/PlayerPresenceService.cs +++ b/DorkNet.Server/Services/PlayerPresenceService.cs @@ -47,6 +47,7 @@ public class PlayerPresenceService private readonly ConcurrentDictionary _local = new(); private readonly ConcurrentDictionary _localRoomExpires = new(); private readonly ConcurrentDictionary _localActiveExpires = new(); + private readonly ConcurrentDictionary _localBootDormGraceExpires = new(); private readonly IConnectionMultiplexer? _redis; private readonly ILogger? _log; @@ -68,6 +69,15 @@ public class PlayerPresenceService /// public static TimeSpan ActivityTtl { get; } = TimeSpan.FromSeconds(90); + /// + /// Right after login the 2023 watch may boot into its cached offline + /// DormRoom before the live Photon join finishes. During that window + /// a null roomInstance is safer than returning stale/live presence: + /// the client treats the offline placeholder as invalid and accepts + /// null, but a live private dorm forces a resync. + /// + public static TimeSpan BootDormGraceTtl { get; } = TimeSpan.FromSeconds(45); + private static readonly JsonSerializerOptions JsonOpts = new() { // Match the wire format used by the matchmaking response so @@ -150,6 +160,63 @@ public class PlayerPresenceService _localActiveExpires[playerId] = DateTimeOffset.UtcNow.Add(ActivityTtl); } + public void BeginBootDormGrace(long playerId) + { + if (_redis is { } mux) + { + try + { + mux.GetDatabase().StringSet(BootDormGraceKey(playerId), "1", BootDormGraceTtl); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis boot grace SET failed for player={PlayerId}", playerId); + } + return; + } + + _localBootDormGraceExpires[playerId] = DateTimeOffset.UtcNow.Add(BootDormGraceTtl); + } + + public bool IsBootDormGraceActive(long playerId) + { + if (_redis is { } mux) + { + try + { + return mux.GetDatabase().KeyExists(BootDormGraceKey(playerId)); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis boot grace GET failed for player={PlayerId}", playerId); + return false; + } + } + + if (!_localBootDormGraceExpires.TryGetValue(playerId, out var expires)) return false; + if (expires > DateTimeOffset.UtcNow) return true; + _localBootDormGraceExpires.TryRemove(playerId, out _); + return false; + } + + public void EndBootDormGrace(long playerId) + { + if (_redis is { } mux) + { + try + { + mux.GetDatabase().KeyDelete(BootDormGraceKey(playerId)); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis boot grace DEL failed for player={PlayerId}", playerId); + } + return; + } + + _localBootDormGraceExpires.TryRemove(playerId, out _); + } + public IReadOnlyCollection RecentlyActivePlayerIds() { if (_redis is { } mux) @@ -265,6 +332,7 @@ public class PlayerPresenceService var db = mux.GetDatabase(); db.KeyDelete(Key(playerId)); db.KeyDelete(ActiveKey(playerId)); + db.KeyDelete(BootDormGraceKey(playerId)); db.SetRemove(ActiveSetKey, playerId); } else @@ -272,6 +340,7 @@ public class PlayerPresenceService _local.TryRemove(playerId, out _); _localRoomExpires.TryRemove(playerId, out _); _localActiveExpires.TryRemove(playerId, out _); + _localBootDormGraceExpires.TryRemove(playerId, out _); } } @@ -316,6 +385,7 @@ public class PlayerPresenceService private static string Key(long playerId) => $"presence:{playerId}"; private static string ActiveKey(long playerId) => $"presence-active:{playerId}"; + private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}"; private const string ActiveSetKey = "presence-active"; // ── LoginLock single-session enforcement ───────────────────────── diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 7792045..19ba290 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -12,7 +12,11 @@ namespace DorkNet.Server.Services; /// public class RoomDataBlobService { - private const int December2020PersistenceVersion = 26; + private const DEPRECATED_RoomPersistenceVersion LatestDeprecatedPersistenceVersion = + DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion; + + private const PersistedRoomVersion LatestPersistenceVersion = + PersistedRoomVersion.LatestVersion; private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData(); private readonly byte[] _rroEditableBlob = BuildRroEditableBlob(); @@ -46,8 +50,10 @@ public class RoomDataBlobService if (bytes.Length > 0) { var msg = PersistedRoomData.Parser.ParseFrom(StripTopLevelField(bytes, 2)); - EnsureEditablePersistenceVersion(msg); + Ensure2023PersistenceHeader(msg); msg.RoomRoleData = BuildDefaultRoomRoleData(); + msg.GameRoleData ??= BuildDefaultGameRoleData(); + msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData(); return msg.ToByteArray(); } } @@ -118,16 +124,20 @@ public class RoomDataBlobService public byte[] OverlayAllPermsRoleData(byte[] existingBlob) { var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); - EnsureEditablePersistenceVersion(msg); + Ensure2023PersistenceHeader(msg); msg.RoomRoleData = _allPermsRoleData.Clone(); + msg.GameRoleData ??= BuildDefaultGameRoleData(); + msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData(); return msg.ToByteArray(); } public byte[] OverlayRroEditableRoleData(byte[] existingBlob) { var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); - EnsureEditablePersistenceVersion(msg); + Ensure2023PersistenceHeader(msg); msg.RoomRoleData = _rroEditableRoleData.Clone(); + msg.GameRoleData ??= BuildDefaultGameRoleData(); + msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData(); return msg.ToByteArray(); } @@ -135,7 +145,11 @@ public class RoomDataBlobService { var msg = new PersistedRoomData { - DEPRECATEDVersion = December2020PersistenceVersion, + DEPRECATEDVersion = LatestDeprecatedPersistenceVersion, + Version = LatestPersistenceVersion, + CreativeRolesEnabled = true, + ToolTagSettingsData = BuildDefaultToolTagSettingsData(), + GameRoleData = BuildDefaultGameRoleData(), RoomRoleData = BuildAllPermsRoleData(), }; @@ -146,7 +160,11 @@ public class RoomDataBlobService { var msg = new PersistedRoomData { - DEPRECATEDVersion = December2020PersistenceVersion, + DEPRECATEDVersion = LatestDeprecatedPersistenceVersion, + Version = LatestPersistenceVersion, + CreativeRolesEnabled = true, + ToolTagSettingsData = BuildDefaultToolTagSettingsData(), + GameRoleData = BuildDefaultGameRoleData(), RoomRoleData = BuildDefaultRoomRoleData(), }; @@ -157,7 +175,7 @@ public class RoomDataBlobService { var collection = new RoomRoleCollectionData { - RecNetMigrationVersion = 1, + RecNetMigrationVersion = MigratedToRecNetVersion.MigratedToRecNet, }; collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator")); @@ -166,20 +184,18 @@ public class RoomDataBlobService private static RoomRoleCollectionData BuildDefaultRoomRoleData() { - var collection = new RoomRoleCollectionData - { - RecNetMigrationVersion = 1, - }; - collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR - - return collection; + return BuildRroEditableRoleData(); } private static byte[] BuildRroEditableBlob() { var msg = new PersistedRoomData { - DEPRECATEDVersion = December2020PersistenceVersion, + DEPRECATEDVersion = LatestDeprecatedPersistenceVersion, + Version = LatestPersistenceVersion, + CreativeRolesEnabled = true, + ToolTagSettingsData = BuildDefaultToolTagSettingsData(), + GameRoleData = BuildDefaultGameRoleData(), RoomRoleData = BuildRroEditableRoleData(), }; @@ -190,8 +206,18 @@ public class RoomDataBlobService { var collection = new RoomRoleCollectionData { - RecNetMigrationVersion = 1, + RecNetMigrationVersion = MigratedToRecNetVersion.MigratedToRecNet, }; + + // The 2023 room-permissions runtime looks up roles by RecNet account-role + // values during load. Keep the AG role bitmasks below, but also provide + // compatibility aliases for the keys OMJKHJLFOCO.ONECLLALJEO can return. + collection.RoomRoles.Add(BuildPermissiveRole(0, 10, "Default")); + collection.RoomRoles.Add(BuildPermissiveRole(10, 60, "Player")); + collection.RoomRoles.Add(BuildPermissiveRole(20, 80, "Host")); + collection.RoomRoles.Add(BuildPermissiveRole(30, 90, "Co-owner")); + collection.RoomRoles.Add(BuildPermissiveRole(255, 100, "Creator")); + 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 @@ -250,10 +276,10 @@ public class RoomDataBlobService CanSaveInventions = OverrideTrue(), DisableMicAutoMute = OverrideTrue(), CanUseShareCam = OverrideTrue(), - UnknownPermission20 = OverrideTrue(), - UnknownPermission21 = OverrideTrue(), - UnknownPermission22 = OverrideTrue(), - UnknownPermission23 = OverrideTrue(), + CanSpawnInventions = OverrideTrue(), + CanSpawnConsumables = OverrideTrue(), + CanUseRoomResetButton = OverrideTrue(), + CanUsePlayGizmosToggle = OverrideTrue(), // Vote-kick permission — int field, set to a permissive value. // 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via @@ -268,12 +294,29 @@ public class RoomDataBlobService }; } - private static void EnsureEditablePersistenceVersion(PersistedRoomData msg) + private static GameRoleCollectionData BuildDefaultGameRoleData() { - if (msg.DEPRECATEDVersion < 20) + return new GameRoleCollectionData(); + } + + private static ToolTagSettingsData BuildDefaultToolTagSettingsData() + { + return new ToolTagSettingsData { - msg.DEPRECATEDVersion = December2020PersistenceVersion; + RuntimeTagData = new TagData(), + }; + } + + private static void Ensure2023PersistenceHeader(PersistedRoomData msg) + { + if ((int)msg.DEPRECATEDVersion < 20) + { + msg.DEPRECATEDVersion = LatestDeprecatedPersistenceVersion; } + + msg.Version = LatestPersistenceVersion; + msg.CreativeRolesEnabled = true; + msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData(); } private static string StableRoleGuid(int roleId) => @@ -284,6 +327,10 @@ public class RoomDataBlobService 8_388_608 => "BD0F2F3A-F931-419E-B50C-ECDFF3F56B52", 16_777_216 => "32300035-3BEA-457E-95C0-1630AFDFA6BD", 0 => Guid.Empty.ToString(), + 10 => "3C66F53A-6B76-4DB1-A93F-76F1F59E03B8", + 20 => "1E8890ED-D729-446B-835B-3C96D8C7D939", + 30 => "FA1825F9-8F41-54E8-8DB4-530C6B24B3E5", + 255 => "6F31F2B8-AC6E-549F-8F8C-F8344C9BAE1E", _ => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(), }; } diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index 4d0b36c..3ae0613 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -577,7 +577,7 @@ public class RoomService(DorkNetDbContext db) /// Resolve the saved room-data blob for a personal dorm and repair stale /// pointers left by older builds. Some existing dorm rows only have the /// real save in RoomDataBlobs or RoomScenes while DormStates/Rooms still - /// point at the synthetic room_{id}_v1.dat fallback. + /// point at an old synthetic fallback blob. /// public async Task ResolveDormDataBlobNameAsync(long playerId, long dormRoomId) { @@ -627,9 +627,20 @@ public class RoomService(DorkNetDbContext db) return latestDormBlob; } - return string.Empty; + return SyntheticDefaultRoomDataBlobName(dormRoomId); } + public static string SyntheticDefaultRoomDataBlobName(long roomId) => + $"room_{roomId}_dorknet_v8.dat"; + + public static bool IsLegacySyntheticDefaultRoomDataBlobName(long roomId, string? blobName) => + string.Equals(blobName, $"room_{roomId}_v1.dat", StringComparison.OrdinalIgnoreCase); + + public static string ResolveWireRoomDataBlobName(long roomId, string? blobName) => + !string.IsNullOrWhiteSpace(blobName) && !IsLegacySyntheticDefaultRoomDataBlobName(roomId, blobName) + ? blobName + : SyntheticDefaultRoomDataBlobName(roomId); + private async Task IsUsableDormBlobNameAsync(string? blobName, long dormRoomId) { if (string.IsNullOrWhiteSpace(blobName)) return false;