From 9c9f1841d6de25f4d852ac94c1f5af784f72e075 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:28:50 +0300 Subject: [PATCH] feat: cloned rooms get their own copy of the source's save data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloning was a shallow copy: the new room's scenes kept pointing at the SOURCE room's blob until someone saved over them. So every clone of a room shared one object, a clone read as "already has content" while owning none of it, and the room a player copied stayed load-bearing for their copy indefinitely. Cloning RecCenter produced rooms whose data blob was literally room_100_v1.dat, which is visible in the join log. Each sub-room's bytes are now copied into the clone's own namespace, keyed by the CLONE's room id so BlobRouter files them under that room's prefix. Sub-room 0 takes the canonical default name, which matters because the details builder falls back to exactly that name when a room has no recorded blob — writing the copy there means both the recorded and the fallback path resolve to real content. Each copy also gets a RoomDataBlobs row so it is tracked like any other save. Two cases deliberately do not clear a reference: - With no object store configured there is nothing to copy, and a blob name is only a reference; clearing it would throw away the player's edits, which is strictly worse than the shared reference. That path keeps the old shallow behaviour, which is also what the existing user-room clone test asserts. - A source object that is missing or unreadable leaves the clone empty rather than failing the request. The room and its sub-rooms are already committed by that point, so a hard failure would strand a half-made room, and an unsaved source clones to an empty room anyway. Everywhere else a reference is either replaced by a real copy or cleared — it is never left pointing into another room. --- .../ChatStartConversationTests.cs | 5 + DorkNet.Server.Tests/RoomCloneShapeTests.cs | 104 +++++++++++++ .../API/Rooms/V2/RoomsModerationController.cs | 145 +++++++++++++++++- 3 files changed, 253 insertions(+), 1 deletion(-) diff --git a/DorkNet.Server.Tests/ChatStartConversationTests.cs b/DorkNet.Server.Tests/ChatStartConversationTests.cs index f67e8ab..2885de1 100644 --- a/DorkNet.Server.Tests/ChatStartConversationTests.cs +++ b/DorkNet.Server.Tests/ChatStartConversationTests.cs @@ -70,6 +70,11 @@ public sealed class ChatStartConversationTests : IClassFixture {(int)openResponse.StatusCode}: {openBody}"); } diff --git a/DorkNet.Server.Tests/RoomCloneShapeTests.cs b/DorkNet.Server.Tests/RoomCloneShapeTests.cs index 8333d20..0bb7c30 100644 --- a/DorkNet.Server.Tests/RoomCloneShapeTests.cs +++ b/DorkNet.Server.Tests/RoomCloneShapeTests.cs @@ -2,6 +2,9 @@ using System.Net.Http.Headers; using System.Text.Json; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; +using DorkNet.Server.Services; using Microsoft.Extensions.DependencyInjection; namespace DorkNet.Server.Tests; @@ -99,6 +102,107 @@ public sealed class RoomCloneShapeTests : IClassFixture """); } + /// A clone must own its save data. Cloning used to be a shallow + /// copy — the new room's scenes kept pointing at the SOURCE's blob until + /// someone saved over them — so every copy of a room shared one object and + /// cloning RecCenter produced rooms whose data blob was literally + /// room_100_v1.dat. A clone that references another room's blob is + /// the bug, whatever the name. + [Fact] + public async Task Clone_does_not_reference_the_source_rooms_blob() + { + // The suite runs with S3 unconfigured, where there is nothing to copy + // and the clone deliberately keeps the shallow reference. Swap in a + // real (in-memory) store so this exercises the copy path itself. + var store = new InMemoryObjectStorage(); + using var factory = _factory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(store); + })); + + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}"); + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + + var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999); + var sourceBlob = $"room_{sourceId}_v1.dat"; + var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob); + await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream"); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Rooms.Add(new RoomEntity + { + Id = sourceId, + Name = $"BlobSrc{Guid.NewGuid():N}"[..20], + CreatorPlayerId = session.PlayerId, + CloningAllowed = true, + CurrentDataBlobName = sourceBlob, + }); + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = sourceId, + Name = "Home", + OrderIndex = 0, + DataBlobName = sourceBlob, + }); + await db.SaveChangesAsync(); + } + + var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", + new FormUrlEncodedContent([new KeyValuePair("name", $"Copy{Guid.NewGuid():N}"[..20])])); + var cloneId = clone.GetProperty("RoomId").GetInt64(); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var room = await db.Rooms.FirstAsync(r => r.Id == cloneId); + var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync(); + + Assert.False(room.CurrentDataBlobName == sourceBlob, + $"the clone's room row still points at the source's blob ({sourceBlob})"); + foreach (var scene in scenes) + Assert.False(scene.DataBlobName == sourceBlob, + $"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})"); + + // It must be a real copy, not just a cleared reference — the point + // is that the player's content comes along. + Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), + "the clone has no blob at all; the source's data should have been copied, not dropped"); + var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName); + Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey)); + } + } + + /// Minimal in-memory . The suite runs + /// with S3 unconfigured, so without this the clone's copy path short-circuits + /// and never gets exercised. + private sealed class InMemoryObjectStorage : IObjectStorage + { + private readonly Dictionary _objects = new(StringComparer.Ordinal); + + public bool IsS3Configured => true; + + public Task PutAsync( + string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default) + { + _objects[$"{bucket}/{key}"] = bytes; + return Task.FromResult((long)bytes.Length); + } + + public Task GetAsync(string bucket, string key, CancellationToken ct = default) => + Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null); + + public Task ExistsAsync(string bucket, string key, CancellationToken ct = default) => + Task.FromResult(_objects.ContainsKey($"{bucket}/{key}")); + } + private static async Task ReadJsonAsync( HttpClient client, HttpMethod method, string path, HttpContent? content = null) { diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 434cc1a..44088b6 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -22,7 +22,10 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2; /// [ApiController] [Authorize] -public class RoomsModerationController(DorkNetDbContext db) : ControllerBase +public class RoomsModerationController( + DorkNetDbContext db, + IObjectStorage storage, + ILogger logger) : ControllerBase { private const int PublicAccessibility = 1; private const long CanUseShareCamPermission = 1L << 18; @@ -1507,6 +1510,8 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase await db.SaveChangesAsync(); } + await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me); + // The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) // deserializes the response as the FULL room-details object // (FGCPNAACHIK — the same type get-by-id / rename return on the @@ -1518,6 +1523,144 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // room". return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes)); } + /// Give a freshly-cloned room its OWN copy of the source's save + /// data instead of pointing its scenes at the source's blob. + /// + /// The shallow copy this replaces relied on copy-on-write: the clone + /// referenced room_<source>_*.dat until someone saved over it. + /// That left every clone of a room sharing one object, so a clone read as + /// "already has content" while owning none of it, and the room a player + /// copied stayed load-bearing for their copy forever. Cloning RecCenter + /// produced rooms whose data blob was literally room_100_v1.dat. + /// + /// Each sub-room's bytes are copied to a name derived from the CLONE's id, + /// so files them under that room's own prefix. + /// Sub-room 0 takes the canonical default name, which matters because the + /// details builder falls back to exactly that name when a room has no + /// recorded blob — writing the copy there means both paths resolve to real + /// content. + /// + /// A missing source object is not fatal: the clone keeps an empty blob name + /// and behaves like a fresh room, which is the same outcome as cloning a + /// room that never had a save. Failing the whole clone would be worse — the + /// room and its sub-rooms are already committed by this point. + private async Task GiveCloneItsOwnBlobsAsync( + RoomEntity clone, + List sourceScenes, + List clonedScenes, + long callerId) + { + // With no object store there are no objects to copy, and a blob name is + // only a reference. Clearing it would throw away the player's edits — + // strictly worse than the shared reference — so leave the shallow copy + // alone and let copy-on-write handle it, exactly as before. + if (!storage.IsS3Configured) + { + logger.LogInformation( + "[room-clone] no object store configured; room {Room} keeps the source's blob references", + clone.Id); + return; + } + + string? subRoomZeroBlob = null; + + for (var i = 0; i < clonedScenes.Count; i++) + { + var target = clonedScenes[i]; + // The scene was created as a shallow copy, so its DataBlobName is + // currently the SOURCE's. Either a copy replaces it or it gets + // cleared — it must never survive as-is. + var copied = await CopyBlobForCloneAsync( + sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId); + + target.DataBlobName = copied ?? string.Empty; + target.DataModifiedAt = DateTime.UtcNow; + if (target.OrderIndex == 0) subRoomZeroBlob = copied; + } + + // Same rule for the room row, which carries its own inherited copy of + // the name. Sub-room 0 is the room's current save, so prefer whatever + // that produced; with no sub-room 0 at all, copy the room-level blob + // directly. Anything that can't be copied is cleared rather than left + // pointing into another room. + if (clonedScenes.Any(s => s.OrderIndex == 0)) + { + clone.CurrentDataBlobName = subRoomZeroBlob ?? string.Empty; + } + else + { + clone.CurrentDataBlobName = + await CopyBlobForCloneAsync(clone.CurrentDataBlobName, clone.Id, 0, callerId) + ?? string.Empty; + } + + await db.SaveChangesAsync(); + } + + /// Copy one blob into the clone's own namespace. Returns the new + /// name, or null when there was nothing to copy or the source object could + /// not be read — in which case the caller clears the reference. + /// + /// A missing source is not fatal: the clone then behaves like a room that + /// was never saved, which is also what cloning an unsaved room does. The + /// room and its sub-rooms are already committed by this point, so failing + /// the request outright would leave a half-made room behind. + private async Task CopyBlobForCloneAsync( + string? sourceBlob, long cloneId, int orderIndex, long callerId) + { + // A first-party template deliberately starts empty (see + // CurrentDataBlobName in BareClone), and a sub-room may simply never + // have been saved. + if (string.IsNullOrWhiteSpace(sourceBlob)) return null; + + byte[]? bytes; + try + { + var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob); + bytes = await storage.GetAsync(srcBucket, srcKey); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "[room-clone] could not read source blob {Blob}; room {Room} sub-room {Sub} starts empty", + sourceBlob, cloneId, orderIndex); + return null; + } + + if (bytes is null || bytes.Length == 0) + { + logger.LogWarning( + "[room-clone] source blob {Blob} is missing from storage; room {Room} sub-room {Sub} " + + "starts empty", sourceBlob, cloneId, orderIndex); + return null; + } + + var newBlob = CloneBlobName(cloneId, orderIndex); + var (dstBucket, dstKey) = BlobRouter.Route(newBlob); + await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream"); + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = cloneId, + BlobName = newBlob, + SubRoomId = orderIndex, + UploadedByPlayerId = callerId, + UploadedAt = DateTime.UtcNow, + }); + + logger.LogInformation( + "[room-clone] copied {Bytes} bytes {From} → {To} (room {Room} sub-room {Sub})", + bytes.Length, sourceBlob, newBlob, cloneId, orderIndex); + return newBlob; + } + + /// Blob name for a cloned sub-room, keyed by the CLONE's room id so + /// BlobRouter files it under that room rather than the source's. + private static string CloneBlobName(long roomId, int orderIndex) => + orderIndex == 0 + ? RoomService.SyntheticDefaultRoomDataBlobName(roomId) + : $"room_{roomId}_dorknet_v8_sub{orderIndex}.dat"; + /// 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.