mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Enhance room cloning functionality; add support for roomserver routes and blob management for first-party templates
This commit is contained in:
@@ -51,6 +51,12 @@ public class ProtoDumper : MelonMod
|
|||||||
private static volatile bool _dirty;
|
private static volatile bool _dirty;
|
||||||
private int _frame;
|
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()
|
public override void OnInitializeMelon()
|
||||||
{
|
{
|
||||||
_log = LoggerInstance;
|
_log = LoggerInstance;
|
||||||
@@ -84,21 +90,42 @@ public class ProtoDumper : MelonMod
|
|||||||
var postfix = new HarmonyMethod(
|
var postfix = new HarmonyMethod(
|
||||||
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
|
||||||
BindingFlags.Public | BindingFlags.Static));
|
BindingFlags.Public | BindingFlags.Static));
|
||||||
|
var ctorPostfix = new HarmonyMethod(
|
||||||
|
typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix),
|
||||||
|
BindingFlags.Public | BindingFlags.Static));
|
||||||
|
|
||||||
int patched = 0;
|
int patched = 0;
|
||||||
// BuildFrom is the lower-level builder every descriptor passes
|
|
||||||
// through; FromGeneratedCode is the generated-code entry point.
|
// PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor
|
||||||
// Patch both (deduped downstream) so nothing is missed.
|
// 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 (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;
|
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}"); }
|
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}"); }
|
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).
|
// object — same pattern the ClientMod uses for il2cpp reference returns).
|
||||||
public static void Captured_Postfix(object? __result)
|
public static void Captured_Postfix(object? __result)
|
||||||
{
|
{
|
||||||
|
Fired();
|
||||||
if (__result == null) return;
|
if (__result == null) return;
|
||||||
try { CollectFile(__result); }
|
try { CollectFile(__result); }
|
||||||
catch { /* never let our hook disturb the game */ }
|
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)
|
private static void CollectFile(object file)
|
||||||
{
|
{
|
||||||
string? name = GetProp(file, "Name") as string;
|
string? name = GetProp(file, "Name") as string;
|
||||||
@@ -213,11 +264,27 @@ public class ProtoDumper : MelonMod
|
|||||||
return p?.GetValue(obj);
|
return p?.GetValue(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long _lastReportedFire = -1;
|
||||||
|
|
||||||
public override void OnUpdate()
|
public override void OnUpdate()
|
||||||
{
|
{
|
||||||
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
// Flush to disk shortly after new descriptors are captured (every ~1s).
|
||||||
if (++_frame % 60 == 0 && _dirty)
|
if (++_frame % 60 == 0 && _dirty)
|
||||||
Flush();
|
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();
|
public override void OnApplicationQuit() => Flush();
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
/// scene ids, which collide with the seeded id range once the identity
|
/// 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
|
/// sequence lags → the clone POST 500s and the client boots the player to
|
||||||
/// their dorm ("Failed to copy room: Failed to clone room").
|
/// their dorm ("Failed to copy room: Failed to clone room").
|
||||||
|
///
|
||||||
|
/// The first clone goes through the <c>roomserver/</c>-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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
|
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
|
||||||
@@ -145,6 +157,7 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
||||||
|
|
||||||
var roomId = 9_300_777L;
|
var roomId = 9_300_777L;
|
||||||
|
var templateBlob = $"room_{roomId}_v1.dat";
|
||||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
@@ -155,23 +168,24 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
Id = roomId,
|
Id = roomId,
|
||||||
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
|
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
|
||||||
Description = "clone source",
|
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,
|
ImageName = RoomService.DefaultRoomImageName,
|
||||||
Accessibility = 1,
|
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,
|
IsAGRoom = true,
|
||||||
IsDormRoom = false,
|
IsDormRoom = false,
|
||||||
CloningAllowed = false,
|
CloningAllowed = false,
|
||||||
CurrentDataBlobName = $"room_{roomId}_v1.dat",
|
CurrentDataBlobName = templateBlob,
|
||||||
});
|
});
|
||||||
db.RoomScenes.Add(new RoomSceneEntity
|
db.RoomScenes.Add(new RoomSceneEntity
|
||||||
{
|
{
|
||||||
RoomId = roomId,
|
RoomId = roomId,
|
||||||
OrderIndex = 0,
|
OrderIndex = 0,
|
||||||
Name = "Main",
|
Name = "Main",
|
||||||
DataBlobName = $"room_{roomId}_v1.dat",
|
DataBlobName = templateBlob,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
@@ -179,21 +193,61 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
|
|
||||||
using var roomsClient = ApiClient(owner, subdomain: "rooms");
|
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);
|
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 secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
|
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
|
||||||
Assert.NotEqual(firstCloneId, secondCloneId);
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<long> CloneAndAssertAsync(HttpClient client, long sourceId, string name)
|
// 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 async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why)
|
||||||
|
{
|
||||||
|
await using var scope = _factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
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<long> CloneAndAssertAsync(
|
||||||
|
HttpClient client, long sourceId, string name, string pathPrefix = "")
|
||||||
{
|
{
|
||||||
// The real 2023 client posts the name as x-www-form-urlencoded
|
// The real 2023 client posts the name as x-www-form-urlencoded
|
||||||
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
|
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
|
||||||
// a [FromBody] handler returns for a form POST.
|
// a [FromBody] handler returns for a form POST.
|
||||||
using var resp = await client.PostAsync(
|
using var resp = await client.PostAsync(
|
||||||
$"/rooms/{sourceId}/clone",
|
$"{pathPrefix}/rooms/{sourceId}/clone",
|
||||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
|
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
|
||||||
var text = await resp.Content.ReadAsStringAsync();
|
var text = await resp.Content.ReadAsStringAsync();
|
||||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||||
|
|||||||
@@ -3449,6 +3449,7 @@ public class RoomsController(
|
|||||||
/// a scene (DataBlobName + location pointer). New scene goes at the
|
/// a scene (DataBlobName + location pointer). New scene goes at the
|
||||||
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
|
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
|
||||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||||
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Text.Json;
|
|||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
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
|
// 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
|
// player to their dorm. Accept all three content types and read the name
|
||||||
// manually from form / JSON body / query.
|
// 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("rooms/{roomId:long}/clone")]
|
||||||
|
[HttpPost("roomserver/rooms/{roomId:long}/clone")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
public async Task<IActionResult> BareClone(long roomId)
|
public async Task<IActionResult> BareClone(long roomId)
|
||||||
{
|
{
|
||||||
@@ -1052,7 +1060,15 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
// badge, and quest-instance logic can boot the player). Match
|
// badge, and quest-instance logic can boot the player). Match
|
||||||
// RoomService.CloneAsync and tag it "community".
|
// RoomService.CloneAsync and tag it "community".
|
||||||
TagsCsv = "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);
|
db.Rooms.Add(clone);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -1077,7 +1093,9 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
OrderIndex = s.OrderIndex,
|
OrderIndex = s.OrderIndex,
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
RoomSceneLocationId = s.RoomSceneLocationId,
|
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,
|
StudioSubRoomDataSaveId = s.StudioSubRoomDataSaveId,
|
||||||
StudioUnityAssetId = s.StudioUnityAssetId,
|
StudioUnityAssetId = s.StudioUnityAssetId,
|
||||||
StudioAssetBundleNamesCsv = s.StudioAssetBundleNamesCsv,
|
StudioAssetBundleNamesCsv = s.StudioAssetBundleNamesCsv,
|
||||||
@@ -1103,6 +1121,12 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
|||||||
// room".
|
// room".
|
||||||
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
||||||
}
|
}
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private static bool IsFirstPartyTemplate(RoomEntity room)
|
||||||
|
=> room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId;
|
||||||
|
|
||||||
private async Task<string> ReadCloneNameAsync()
|
private async Task<string> ReadCloneNameAsync()
|
||||||
{
|
{
|
||||||
// Form: name=... (what the 2023 client sends).
|
// Form: name=... (what the 2023 client sends).
|
||||||
|
|||||||
@@ -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`, 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. |
|
| `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
|
## Related 2023 quirks fixed alongside
|
||||||
|
|
||||||
- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls
|
- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls
|
||||||
|
|||||||
Reference in New Issue
Block a user