mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -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 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();
|
||||
|
||||
@@ -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.
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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<long> 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<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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user