feat: Enhance room cloning functionality; add support for roomserver routes and blob management for first-party templates

This commit is contained in:
Alexa
2026-07-13 11:51:20 +03:00
parent e4126e38ce
commit 690e77f520
5 changed files with 187 additions and 18 deletions
+73 -6
View File
@@ -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();
+66 -12
View File
@@ -137,6 +137,18 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
/// 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 <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>
[Fact]
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 roomId = 9_300_777L;
var templateBlob = $"room_{roomId}_v1.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
@@ -155,23 +168,24 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
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<DorkNetServerFactory>
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.
var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
Assert.NotEqual(firstCloneId, secondCloneId);
// 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<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
// (`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<string, string> { ["name"] = name }));
var text = await resp.Content.ReadAsStringAsync();
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
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
@@ -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<IActionResult> 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));
}
/// <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()
{
// Form: name=... (what the 2023 client sends).
+23
View File
@@ -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