From 690e77f52033fa3ab816d1f574f22f8b776f2e38 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:51:20 +0300 Subject: [PATCH] feat: Enhance room cloning functionality; add support for roomserver routes and blob management for first-party templates --- DorkNet.ProtoDumper/ProtoDumper.cs | 79 +++++++++++++++++-- DorkNet.Server.Tests/RoomSave2023Tests.cs | 74 ++++++++++++++--- .../API/Rooms/V2/RoomsController.cs | 1 + .../API/Rooms/V2/RoomsModerationController.cs | 28 ++++++- docs/recroom-2023-room-save.md | 23 ++++++ 5 files changed, 187 insertions(+), 18 deletions(-) diff --git a/DorkNet.ProtoDumper/ProtoDumper.cs b/DorkNet.ProtoDumper/ProtoDumper.cs index 941e19f..b8cbda6 100644 --- a/DorkNet.ProtoDumper/ProtoDumper.cs +++ b/DorkNet.ProtoDumper/ProtoDumper.cs @@ -51,6 +51,12 @@ public class ProtoDumper : MelonMod private static volatile bool _dirty; private int _frame; + // Interception heartbeat: how many times any hooked method actually ran. + // Lets us distinguish "hook never fires (inlined/wrong target)" from + // "content that builds protos hasn't loaded yet". + private static long _fireCount; + private static bool _firstFireLogged; + public override void OnInitializeMelon() { _log = LoggerInstance; @@ -84,21 +90,42 @@ public class ProtoDumper : MelonMod var postfix = new HarmonyMethod( typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix), BindingFlags.Public | BindingFlags.Static)); + var ctorPostfix = new HarmonyMethod( + typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix), + BindingFlags.Public | BindingFlags.Static)); int patched = 0; - // BuildFrom is the lower-level builder every descriptor passes - // through; FromGeneratedCode is the generated-code entry point. - // Patch both (deduped downstream) so nothing is missed. + + // PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor + // is built via `newobj` through this ctor, which is NOT an inlining + // candidate the way the small static BuildFrom/FromGeneratedCode + // helpers are (those get AOT-inlined into each generated cctor, so a + // Harmony patch on the managed wrapper never fires — the real work + // runs as inlined native code). The ctor stores SerializedData / + // Proto / Name before it returns, so __instance carries the full + // descriptor bytes. + foreach (ConstructorInfo c in fd.GetConstructors( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + var ps = c.GetParameters(); + // Skip the Il2CppInterop IntPtr wrapper ctor (single IntPtr arg). + if (ps.Length == 1 && ps[0].ParameterType == typeof(IntPtr)) continue; + try { HarmonyInstance.Patch(c, postfix: ctorPostfix); patched++; _log?.Msg($"ProtoDumper: hooked .ctor({ps.Length} args)"); } + catch (Exception e) { _log?.Warning($"ProtoDumper: patch .ctor({ps.Length}) failed: {e.Message}"); } + } + + // SECONDARY hooks (kept as a belt-and-braces net in case the ctor is + // itself inlined on some build): BuildFrom / FromGeneratedCode. foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" }) { - foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.Static)) + foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) { if (m.Name != name) continue; - try { HarmonyInstance.Patch(m, postfix: postfix); patched++; } + try { HarmonyInstance.Patch(m, postfix: postfix); patched++; _log?.Msg($"ProtoDumper: hooked {name}({m.GetParameters().Length} args)"); } catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); } } } - _log?.Msg($"ProtoDumper: installed {patched} descriptor-build hooks. Play through your content; descriptors are captured as they load."); + _log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'."); } catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); } } @@ -132,11 +159,35 @@ public class ProtoDumper : MelonMod // object — same pattern the ClientMod uses for il2cpp reference returns). public static void Captured_Postfix(object? __result) { + Fired(); if (__result == null) return; try { CollectFile(__result); } catch { /* never let our hook disturb the game */ } } + // Postfix on the FileDescriptor instance ctor — __instance is the freshly + // constructed descriptor (SerializedData/Proto/Name already set). + public static void Ctor_Postfix(object? __instance) + { + Fired(); + if (__instance == null) return; + try { CollectFile(__instance); } + catch { /* never let our hook disturb the game */ } + } + + // Interception heartbeat — proves a hooked method actually ran. Logged once + // on first fire, so "no output" can be diagnosed as inlining (never fires) + // vs. content-not-loaded (fires but no proto-building content yet). + private static void Fired() + { + long n = System.Threading.Interlocked.Increment(ref _fireCount); + if (!_firstFireLogged) + { + _firstFireLogged = true; + _log?.Msg("ProtoDumper: FIRST DESCRIPTOR CAPTURED — interception is live."); + } + } + private static void CollectFile(object file) { string? name = GetProp(file, "Name") as string; @@ -213,11 +264,27 @@ public class ProtoDumper : MelonMod return p?.GetValue(obj); } + private long _lastReportedFire = -1; + public override void OnUpdate() { // Flush to disk shortly after new descriptors are captured (every ~1s). if (++_frame % 60 == 0 && _dirty) Flush(); + + // Every ~5s, if the hooks fired since last report, log the running + // fire/capture counts — surfaces "fired but 0 files" (empty base64) + // separately from "never fired". + if (_frame % 300 == 0) + { + long f = System.Threading.Interlocked.Read(ref _fireCount); + if (f != _lastReportedFire) + { + _lastReportedFire = f; + int files; lock (Gate) files = Files.Count; + _log?.Msg($"ProtoDumper heartbeat: hook-fires={f}, unique-descriptors={files}"); + } + } } public override void OnApplicationQuit() => Flush(); diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs index d67f830..bd41dff 100644 --- a/DorkNet.Server.Tests/RoomSave2023Tests.cs +++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs @@ -137,6 +137,18 @@ public sealed class RoomSave2023Tests : IClassFixture /// scene ids, which collide with the seeded id range once the identity /// sequence lags → the clone POST 500s and the client boots the player to /// their dorm ("Failed to copy room: Failed to clone room"). + /// + /// The first clone goes through the roomserver/-prefixed route: + /// the 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF) sends + /// that prefix like every other room mutation, and the bare-only route + /// 404'd with an empty body ("Failed to copy room: Exception of type + /// '…' was thrown"). + /// + /// Blob rules: cloning a FIRST-PARTY template (AG + system-owned) must + /// give the clone a FRESH empty blob — the template's blob is a MakerPen + /// overlay against the shared baked scene, not the clone's content. + /// Cloning a USER-owned room (even AG-flagged) must carry its blob so + /// "copy room" keeps the player's edits. /// [Fact] public async Task Cloning_a_room_and_then_the_clone_both_succeed() @@ -145,6 +157,7 @@ public sealed class RoomSave2023Tests : IClassFixture var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain); var roomId = 9_300_777L; + var templateBlob = $"room_{roomId}_v1.dat"; await using (var scope = _factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); @@ -155,23 +168,24 @@ public sealed class RoomSave2023Tests : IClassFixture Id = roomId, Name = $"CloneSrc_{Guid.NewGuid():N}"[..20], Description = "clone source", - CreatorPlayerId = owner.PlayerId, + // A first-party RRO/AG base room (RecCenter-like): + // system-owned, CloningAllowed=false, yet the base-room + // picker must still be able to clone it. Its blob is a + // MakerPen overlay that must NOT leak into clones. + CreatorPlayerId = PlayerService.SystemAccountId, ImageName = RoomService.DefaultRoomImageName, Accessibility = 1, - // An RRO/AG base room (RecCenter-like): CloningAllowed is - // false and it's owned by the system, yet the base-room - // picker must still be able to clone it. IsAGRoom = true, IsDormRoom = false, CloningAllowed = false, - CurrentDataBlobName = $"room_{roomId}_v1.dat", + CurrentDataBlobName = templateBlob, }); db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Main", - DataBlobName = $"room_{roomId}_v1.dat", + DataBlobName = templateBlob, }); await db.SaveChangesAsync(); } @@ -179,21 +193,61 @@ public sealed class RoomSave2023Tests : IClassFixture using var roomsClient = ApiClient(owner, subdomain: "rooms"); - var firstCloneId = await CloneAndAssertAsync(roomsClient, roomId, "FirstClone"); + // First clone: template → player copy, via the roomserver/ prefix the + // real client uses. + var firstCloneId = await CloneAndAssertAsync( + roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver"); Assert.NotEqual(roomId, firstCloneId); + await AssertBlobsAsync(firstCloneId, expectEmpty: true, + "first-party template blob must not be copied onto the clone"); - // The regression: cloning the clone. + // Simulate the player saving MakerPen edits into their clone. + var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat"; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var room = await db.Rooms.SingleAsync(r => r.Id == firstCloneId); + room.CurrentDataBlobName = editedBlob; + var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == firstCloneId); + scene.DataBlobName = editedBlob; + await db.SaveChangesAsync(); + } + + // The regression: cloning the clone (bare path still works too). + // The source is user-owned — even though it inherited IsAGRoom, the + // player's edits must carry over. var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone"); Assert.NotEqual(firstCloneId, secondCloneId); + await AssertBlobsAsync(secondCloneId, expectEmpty: false, + "copying a user-owned room must keep its blob (player edits)"); } - private static async Task CloneAndAssertAsync(HttpClient client, long sourceId, string name) + private async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var room = await db.Rooms.SingleAsync(r => r.Id == roomId); + var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == roomId); + if (expectEmpty) + { + Assert.True(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was '{room.CurrentDataBlobName}'"); + Assert.True(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was '{scene.DataBlobName}'"); + } + else + { + Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was empty"); + Assert.False(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was empty"); + } + } + + private static async Task CloneAndAssertAsync( + HttpClient client, long sourceId, string name, string pathPrefix = "") { // The real 2023 client posts the name as x-www-form-urlencoded // (`name=...`), NOT JSON — testing with JSON would miss the 415 that // a [FromBody] handler returns for a form POST. using var resp = await client.PostAsync( - $"/rooms/{sourceId}/clone", + $"{pathPrefix}/rooms/{sourceId}/clone", new FormUrlEncodedContent(new Dictionary { ["name"] = name })); var text = await resp.Content.ReadAsStringAsync(); Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}"); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 84ca2ab..47b9f9f 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -3449,6 +3449,7 @@ public class RoomsController( /// a scene (DataBlobName + location pointer). New scene goes at the /// end with " Copy" suffix; per-room name uniqueness preserved. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [Authorize] public async Task CloneSubRoom(long roomId, long subRoomId) { diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index a2aff13..a38eacf 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -5,6 +5,7 @@ using System.Text.Json; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Rooms.V2; @@ -965,7 +966,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // handler runs → the client reports "Failed to clone room" and boots the // player to their dorm. Accept all three content types and read the name // manually from form / JSON body / query. + // The 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF, the + // roomserver client that also sends the subrooms/... mutations) POSTs + // this with the roomserver/ prefix — bare-only routing 404s with an + // empty body, which the client's promise layer surfaces as a + // message-less exception ("Failed to copy room: Exception of type + // '...' was thrown"). Same fix as maxplayers/modify/name. [HttpPost("rooms/{roomId:long}/clone")] + [HttpPost("roomserver/rooms/{roomId:long}/clone")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareClone(long roomId) { @@ -1052,7 +1060,15 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // badge, and quest-instance logic can boot the player). Match // RoomService.CloneAsync and tag it "community". TagsCsv = "community", - CurrentDataBlobName = source.CurrentDataBlobName, + // First-party RRO templates (AG rooms owned by the system + // account — RecCenter and friends) keep their content in baked + // client geometry; any blob on the row is a MakerPen OVERLAY + // save made against the shared template. A clone must start + // from the pristine baked scene with a fresh (empty) blob, not + // inherit that overlay. Scoped to system-owned templates so + // copying a USER's RRO-derived room (IsAGRoom inherited) still + // carries their MakerPen edits along. + CurrentDataBlobName = IsFirstPartyTemplate(source) ? string.Empty : source.CurrentDataBlobName, }; db.Rooms.Add(clone); await db.SaveChangesAsync(); @@ -1077,7 +1093,9 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase OrderIndex = s.OrderIndex, Name = s.Name, RoomSceneLocationId = s.RoomSceneLocationId, - DataBlobName = s.DataBlobName, + // Fresh blob when cloning a first-party template — see + // CurrentDataBlobName above. + DataBlobName = IsFirstPartyTemplate(source) ? string.Empty : s.DataBlobName, StudioSubRoomDataSaveId = s.StudioSubRoomDataSaveId, StudioUnityAssetId = s.StudioUnityAssetId, StudioAssetBundleNamesCsv = s.StudioAssetBundleNamesCsv, @@ -1103,6 +1121,12 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // room". return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes)); } + /// A seeded first-party RRO template (RecCenter, the games): + /// AG-flagged AND owned by the system account. User clones inherit the + /// AG flag but are owned by the player, so they don't match. + private static bool IsFirstPartyTemplate(RoomEntity room) + => room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId; + private async Task ReadCloneNameAsync() { // Form: name=... (what the 2023 client sends). diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 68c6612..6ee6002 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -80,6 +80,29 @@ Server handling: `StorageController.Upload` (FileType 6 → | `NDIKGKCFOCG: Failed to save room`, no upload frame in stack | Commit POST rejected — historically 400 `missing_room_data_filename` because the server parsed only the flat 2020 body and missed `SubRoomData.Filename`. | | `NDIKGKCFOCG: Failed to save room` preceded by a `NullReferenceException` at `NEOPBOMGIOG.FKDDCNLJOLF` | Commit returned 200 but `value` wasn't `{Room, SubRoomDataSave}` — the save actually persisted; only the response parse failed. | +## Room clone (2023) + +The in-room "copy room" flow (`RoomModel.CopyRoom` → +`RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN`) POSTs +`roomserver/rooms/{id}/clone` with an x-www-form-urlencoded `name=…` +body — the same `roomserver/` prefix as every other room mutation from +that client. A bare-only `rooms/{id}/clone` route 404s with an empty +body, which the client's promise layer surfaces as a message-less +`Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`. +The response must be the FULL room-details object (`FGCPNAACHIK`, +same shape as `GET roomserver/rooms/{id}`), not a status wrapper. + +Blob semantics on clone (`RoomsModerationController.BareClone`): + +- **First-party template source** (IsAGRoom + system-owned, e.g. the + RecCenter seed): the clone keeps the copied scenes but gets a + **fresh empty blob** (`CurrentDataBlobName`/scene `DataBlobName` + cleared). Any blob on the template row is a MakerPen overlay against + the shared baked scene and must not leak into clones; AG room + details also require an empty `DataBlobName` on the wire. +- **User-owned source** (including AG-flagged clones of RROs): blobs + are copied so "copy room" carries the player's MakerPen edits. + ## Related 2023 quirks fixed alongside - **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls