From 1417651c77bf5a79b31e72c85a5e573dc426b104 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Wed, 8 Jul 2026 01:19:19 +0300
Subject: [PATCH] feat: Enhance room save flow with new metadata handling and
add tests for 2023 client integration
---
DorkNet.Server.Tests/RoomSave2023Tests.cs | 128 ++++++++++++++++++
.../API/Rooms/V2/RoomsController.cs | 31 ++++-
.../Controllers/Storage/StorageController.cs | 21 ++-
3 files changed, 170 insertions(+), 10 deletions(-)
create mode 100644 DorkNet.Server.Tests/RoomSave2023Tests.cs
diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs
new file mode 100644
index 0000000..1fee3a9
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs
@@ -0,0 +1,128 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// 2023 room-save flow (captured from the 2023.03.21 client against dev,
+/// 2026-07-07). The client uploads two blobs to storage /upload —
+/// FileType=6 room metadata, then FileType=1 scene save — and commits
+/// them with a JSON POST to rooms/{id}/subrooms/{id}/data:
+/// { "UnityAssetId": null,
+/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null },
+/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } }
+/// SubRoomData.Filename is the scene blob and must become the room's
+/// CurrentDataBlobName.
+///
+public sealed class RoomSave2023Tests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob()
+ {
+ using var setupClient = ApiClient();
+ var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
+
+ var roomId = 9_200_001L;
+ var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat";
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
+ {
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Save2023_{Guid.NewGuid():N}"[..24],
+ Description = "2023 save flow test room",
+ CreatorPlayerId = owner.PlayerId,
+ ImageName = RoomService.DefaultRoomImageName,
+ Accessibility = 1,
+ IsAGRoom = true,
+ IsDormRoom = false,
+ });
+ await db.SaveChangesAsync();
+ }
+ else
+ {
+ await db.Rooms.Where(r => r.Id == roomId)
+ .ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId));
+ }
+ }
+
+ using var roomsClient = ApiClient(owner, subdomain: "rooms");
+ var body = new
+ {
+ UnityAssetId = (long?)null,
+ RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null },
+ SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null },
+ };
+ using var response = await roomsClient.PostAsync(
+ $"/rooms/{roomId}/subrooms/0/data",
+ new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
+
+ var responseText = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
+
+ using var json = JsonDocument.Parse(responseText);
+ Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
+
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId);
+ Assert.Equal(blobName, room.CurrentDataBlobName);
+ }
+ }
+
+ [Fact]
+ public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed()
+ {
+ using var setupClient = ApiClient();
+ var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
+
+ using var storageClient = ApiClient(player, subdomain: "storage");
+ using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST");
+ var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 });
+ file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+ form.Add(file, "File", "file.bin");
+ form.Add(new StringContent("6"), "FileType");
+
+ using var response = await storageClient.PostAsync("/upload", form);
+ var responseText = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
+
+ using var json = JsonDocument.Parse(responseText);
+ var filename = json.RootElement.GetProperty("filename").GetString();
+ Assert.NotNull(filename);
+ Assert.StartsWith("roommeta_", filename);
+
+ await using var scope = _factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.True(
+ await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename),
+ $"expected a RoomDataBlobs row for {filename}");
+ }
+
+ private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
index fcd3738..a392282 100644
--- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
+++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
@@ -1093,8 +1093,26 @@ public class RoomsController(
public long RequestPlayerId { get; set; }
public int SaveRequestPlayerId { get; set; }
+ // 2023 client shape. rooms/{id}/subrooms/{id}/data arrives as JSON:
+ // { "UnityAssetId": null,
+ // "RoomData": { "Filename": "roommeta_….bin", "Hash": …, "OwnershipProof": … },
+ // "SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": …, "OwnershipProof": … } }
+ // SubRoomData.Filename is the scene save blob (the FileType=1
+ // /upload result) — that's what must become DataBlobName.
+ // RoomData.Filename is the FileType=6 room-metadata blob.
+ public long? UnityAssetId { get; set; }
+ public BlobRefDto? RoomData { get; set; }
+ public BlobRefDto? SubRoomData { get; set; }
+
public string EffectiveRoomDataFilename =>
- RoomDataFilename ?? Filename ?? string.Empty;
+ RoomDataFilename ?? Filename ?? SubRoomData?.Filename ?? string.Empty;
+ }
+
+ public class BlobRefDto
+ {
+ public string? Filename { get; set; }
+ public string? Hash { get; set; }
+ public string? OwnershipProof { get; set; }
}
public class CreatorActionContextDto
@@ -3411,9 +3429,14 @@ public class RoomsController(
/// commit the blob returned by storage/upload as the current
/// scene save. The 2020 watch posts this as form-urlencoded
/// (filename, inventionUsage, savedByAccountId),
- /// while some tooling uses JSON. Both variants delegate to the
- /// existing saveData path so room, scene, dorm-state, presence, and
- /// subscription fanout stay consistent.
+ /// while some tooling uses JSON. The 2023 client posts JSON with
+ /// nested blob refs — {UnityAssetId, RoomData:{Filename,Hash,
+ /// OwnershipProof}, SubRoomData:{Filename,…}} — where
+ /// SubRoomData.Filename is the scene save (FileType=1 upload)
+ /// and RoomData.Filename the FileType=6 room-metadata blob.
+ /// All variants delegate to the existing saveData path so room,
+ /// scene, dorm-state, presence, and subscription fanout stay
+ /// consistent.
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
diff --git a/DorkNet.Server/Controllers/Storage/StorageController.cs b/DorkNet.Server/Controllers/Storage/StorageController.cs
index 9de2e3d..37cf06c 100644
--- a/DorkNet.Server/Controllers/Storage/StorageController.cs
+++ b/DorkNet.Server/Controllers/Storage/StorageController.cs
@@ -44,15 +44,23 @@ public class StorageController(
/// FileType enum values from the client's RecNet.Storage+FileType
/// (dump.cs:586846). Kept as a private enum so callers can pattern-
/// match without leaking client-side numbering elsewhere.
+ ///
+ /// RoomMetadata = 6 is new in the 2023 client: the room-save
+ /// flow (OGPDOMCNIFM.UploadRoomDataBlobAndSyncReload, logs
+ /// "Uploading room metadata {0}/{1}") uploads a small metadata blob
+ /// with FileType=6 alongside the FileType=1 scene save, then posts
+ /// both returned filenames to rooms/{id}/subrooms/{id}/data
+ /// as {RoomData:{Filename},SubRoomData:{Filename}}.
///
private enum FileType
{
- Unknown = 0,
- RoomSave = 1,
- Holotar = 2,
- Image = 3,
- Video = 4,
- Invention = 5,
+ Unknown = 0,
+ RoomSave = 1,
+ Holotar = 2,
+ Image = 3,
+ Video = 4,
+ Invention = 5,
+ RoomMetadata = 6,
}
[HttpPost("/upload", Order = -2000)]
@@ -103,6 +111,7 @@ public class StorageController(
FileType.Image => await UploadImageAsync(playerId, bytes),
FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"),
FileType.Video => await UploadGenericAsync(playerId, bytes, "video"),
+ FileType.RoomMetadata => await UploadGenericAsync(playerId, bytes, "roommeta"),
_ => Ok(new
{
filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}",