mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
fix(cdn): serve baked RRO rooms a loadable stub blob (version 16, save skeleton)
An enabled baked room still had no Maker Pen: the client fetched the CDN's
synthetic stub for room_{id}_dorknet_v8.dat and silently loaded the room
with no room roles. Measured against Rec Center's working room_100_v1.dat
(10,930 bytes, the same client loads it with Maker Pen), the old 1,872-byte
roles-only stub was stamped PersistedRoomVersion 19 — past the March-2023
client's ceiling of 16, which only the S3-hit path clamped — and lacked the
skeleton every real save carries (last_save_time, activity_id = the room's
LocationReplicationId, connectable graph, scene settings, object model,
room_mood_* blocks).
RoomDataBlobService.BuildRroEditableBlob now emits that skeleton per room
at version 16 (untyped fields appended on the wire; payloads are Rec
Center's defaults verbatim). CdnController resolves the RRO room for the
miss path, passes its location id, and clamps synthesised blobs the same
way it clamps S3 hits. Tests pin version 16, the activity id and
CanUseMakerPen on every role.
This commit is contained in:
@@ -54,6 +54,7 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
|||||||
IsAGRoom = true,
|
IsAGRoom = true,
|
||||||
TagsCsv = "recroomoriginal,sport",
|
TagsCsv = "recroomoriginal,sport",
|
||||||
CurrentDataBlobName = string.Empty,
|
CurrentDataBlobName = string.Empty,
|
||||||
|
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
|
||||||
});
|
});
|
||||||
db.RoomScenes.Add(new RoomSceneEntity
|
db.RoomScenes.Add(new RoomSceneEntity
|
||||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
|
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
|
||||||
@@ -102,6 +103,14 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
|||||||
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
|
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
|
||||||
Assert.NotNull(persisted.RoomRoleData);
|
Assert.NotNull(persisted.RoomRoleData);
|
||||||
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
|
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
|
||||||
|
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
|
||||||
|
|
||||||
|
// The March-2023 client rejects a blob past PersistedRoomVersion 16
|
||||||
|
// (the old stub was stamped 19 and loaded as "no room data"), and a
|
||||||
|
// real save carries the room's location id as activity_id. Our proto
|
||||||
|
// doesn't type activity_id, so look for it on the wire.
|
||||||
|
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
|
||||||
|
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", System.Text.Encoding.UTF8.GetString(blobBytes));
|
||||||
|
|
||||||
// ── reset: back to scene-only ───────────────────────────────────────
|
// ── reset: back to scene-only ───────────────────────────────────────
|
||||||
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.Cdn;
|
namespace DorkNet.Server.Controllers.Cdn;
|
||||||
@@ -557,11 +558,18 @@ public class CdnController(
|
|||||||
return new FileContentResult(TransparentPng, "image/png");
|
return new FileContentResult(TransparentPng, "image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
|
var rroRoom = await ResolveRroRoomAsync(fileName);
|
||||||
|
if (rroRoom is not null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
|
// Per-room stub: a real save's skeleton at the 2023 version
|
||||||
Request.Host.Host, fileName);
|
// ceiling with the room's own activity_id. The old generic stub
|
||||||
var rroBlob = roomDataBlob.GetRroEditableBlob();
|
// (version 19, roles only) was fetched and silently rejected by
|
||||||
|
// the client, which is why an enabled baked room still had no
|
||||||
|
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
|
||||||
|
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
|
||||||
|
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
|
||||||
|
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId);
|
||||||
|
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
|
||||||
Response.Headers.CacheControl = "public, max-age=60";
|
Response.Headers.CacheControl = "public, max-age=60";
|
||||||
signatures.AddContentSignature(Response, rroBlob);
|
signatures.AddContentSignature(Response, rroBlob);
|
||||||
return new FileContentResult(rroBlob, "application/octet-stream");
|
return new FileContentResult(rroBlob, "application/octet-stream");
|
||||||
@@ -569,14 +577,34 @@ public class CdnController(
|
|||||||
|
|
||||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
||||||
Request.Host.Host, fileName);
|
Request.Host.Host, fileName);
|
||||||
var fallbackBlob = roomDataBlob.GetDefaultBlob();
|
var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
|
||||||
signatures.AddContentSignature(Response, fallbackBlob);
|
signatures.AddContentSignature(Response, fallbackBlob);
|
||||||
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
|
/// <summary>Same 2023 version clamp the S3-hit path applies, for the
|
||||||
|
/// synthesised blobs on the miss path — a stub past the client's ceiling
|
||||||
|
/// is rejected exactly like a modern export would be.</summary>
|
||||||
|
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
if (!IsRoomDataName(fileName)) return bytes;
|
||||||
|
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
|
||||||
|
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
|
||||||
|
if (changed)
|
||||||
|
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
|
||||||
|
await ResolveRroRoomAsync(fileName) is not null;
|
||||||
|
|
||||||
|
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
|
||||||
|
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
|
||||||
|
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
|
||||||
|
/// the per-room stub on misses.</summary>
|
||||||
|
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName)) return null;
|
||||||
|
|
||||||
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
||||||
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
||||||
@@ -587,9 +615,9 @@ public class CdnController(
|
|||||||
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
||||||
// RoleData) — the game-rule circuit data and GameRoleData are left
|
// RoleData) — the game-rule circuit data and GameRoleData are left
|
||||||
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
||||||
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
|
var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
|
||||||
r.IsAGRoom && r.CurrentDataBlobName == fileName))
|
r.IsAGRoom && r.CurrentDataBlobName == fileName);
|
||||||
return true;
|
if (byCurrentBlob is not null) return byCurrentBlob;
|
||||||
|
|
||||||
// Synthetic/default room-data blob names for rooms with no saved blob:
|
// Synthetic/default room-data blob names for rooms with no saved blob:
|
||||||
// the CURRENT form is room_{id}_dorknet_v8.dat
|
// the CURRENT form is room_{id}_dorknet_v8.dat
|
||||||
@@ -603,18 +631,19 @@ public class CdnController(
|
|||||||
{
|
{
|
||||||
var rest = fileName["room_".Length..];
|
var rest = fileName["room_".Length..];
|
||||||
var underscore = rest.IndexOf('_');
|
var underscore = rest.IndexOf('_');
|
||||||
if (underscore > 0 &&
|
if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
|
||||||
long.TryParse(rest[..underscore], out var roomId) &&
|
{
|
||||||
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom))
|
var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
|
||||||
return true;
|
if (byId is not null) return byId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await (
|
return await (
|
||||||
from scene in db.RoomScenes.AsNoTracking()
|
from scene in db.RoomScenes.AsNoTracking()
|
||||||
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
||||||
where room.IsAGRoom && scene.DataBlobName == fileName
|
where room.IsAGRoom && scene.DataBlobName == fileName
|
||||||
select scene.Id
|
select room
|
||||||
).AnyAsync();
|
).FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ public class RoomDataBlobService
|
|||||||
PersistedRoomVersion.LatestVersion;
|
PersistedRoomVersion.LatestVersion;
|
||||||
|
|
||||||
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
|
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
|
||||||
private readonly byte[] _rroEditableBlob = BuildRroEditableBlob();
|
|
||||||
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
|
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
|
||||||
|
|
||||||
/// <summary>The blob served when a room_<id>_v1.dat misses S3
|
/// <summary>The blob served when a room_<id>_v1.dat misses S3
|
||||||
@@ -37,7 +36,15 @@ public class RoomDataBlobService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public byte[] GetDefaultBlob() => _defaultBlob;
|
public byte[] GetDefaultBlob() => _defaultBlob;
|
||||||
public byte[] GetRroEditableBlob() => _rroEditableBlob;
|
public byte[] GetRroEditableBlob() => GetRroEditableBlob(string.Empty);
|
||||||
|
|
||||||
|
/// <summary>The blob served for a baked Rec Room Original whose blob name
|
||||||
|
/// misses S3 (nothing saved yet). <paramref name="activityId"/> is the
|
||||||
|
/// room's <c>LocationReplicationId</c> — a real save carries it as
|
||||||
|
/// <c>activity_id</c> (Rec Center's saved blob: activity_id ==
|
||||||
|
/// cbad71af-…, its location id).</summary>
|
||||||
|
public byte[] GetRroEditableBlob(string activityId) =>
|
||||||
|
BuildRroEditableBlob(_rroEditableRoleData, activityId);
|
||||||
|
|
||||||
private static byte[] LoadDefaultBlob()
|
private static byte[] LoadDefaultBlob()
|
||||||
{
|
{
|
||||||
@@ -267,19 +274,72 @@ public class RoomDataBlobService
|
|||||||
return BuildRroEditableRoleData();
|
return BuildRroEditableRoleData();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] BuildRroEditableBlob()
|
/// <summary>Shape of the stub matters: the March-2023 client fetches this
|
||||||
|
/// through <c>RecNet.Rooms.GetRoomData</c> and REJECTS a blob it deems
|
||||||
|
/// unusable (seen as the bare "Failed to copy room" on clones, and as a
|
||||||
|
/// baked room that silently loads without any room roles / Maker Pen).
|
||||||
|
/// Two things were wrong with the old six-field stub, measured against
|
||||||
|
/// Rec Center's real saved blob (room_100_v1.dat, which the same client
|
||||||
|
/// loads with Maker Pen):
|
||||||
|
///
|
||||||
|
/// • <c>version</c> was stamped <c>PersistedRoomVersion.LatestVersion</c>
|
||||||
|
/// (19) — past the client's ceiling of <see cref="Client2023MaxPersistedRoomVersion"/>
|
||||||
|
/// (16). The S3-hit path clamps that; the miss path never did.
|
||||||
|
/// • it lacked the skeleton every real save carries: <c>last_save_time</c>,
|
||||||
|
/// <c>activity_id</c> (= the room's LocationReplicationId), an empty
|
||||||
|
/// <c>connectable_graph_data</c>, <c>scene_settings_data</c>,
|
||||||
|
/// <c>object_model_data</c> and the four <c>room_mood_*</c> blocks.
|
||||||
|
///
|
||||||
|
/// Our server proto only types the role/tag fields, so the rest is
|
||||||
|
/// appended as raw wire-format fields (protobuf field order is free).
|
||||||
|
/// The byte payloads below are Rec Center's own defaults, verbatim.</summary>
|
||||||
|
private static byte[] BuildRroEditableBlob(RoomRoleCollectionData roleData, string activityId)
|
||||||
{
|
{
|
||||||
var msg = new PersistedRoomData
|
var msg = new PersistedRoomData
|
||||||
{
|
{
|
||||||
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
|
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
|
||||||
Version = LatestPersistenceVersion,
|
Version = (PersistedRoomVersion)Client2023MaxPersistedRoomVersion,
|
||||||
CreativeRolesEnabled = true,
|
CreativeRolesEnabled = true,
|
||||||
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
|
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
|
||||||
GameRoleData = BuildDefaultGameRoleData(),
|
GameRoleData = BuildDefaultGameRoleData(),
|
||||||
RoomRoleData = BuildRroEditableRoleData(),
|
RoomRoleData = roleData.Clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return msg.ToByteArray();
|
using var output = new MemoryStream(4096);
|
||||||
|
var typed = msg.ToByteArray();
|
||||||
|
output.Write(typed, 0, typed.Length);
|
||||||
|
|
||||||
|
// 3: last_save_time (google.protobuf.Timestamp{seconds})
|
||||||
|
var seconds = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
using (var ts = new MemoryStream(12))
|
||||||
|
{
|
||||||
|
WriteVarint(ts, 0x08);
|
||||||
|
WriteVarint(ts, seconds);
|
||||||
|
AppendLengthDelimited(output, 3, ts.ToArray());
|
||||||
|
}
|
||||||
|
// 4: activity_id
|
||||||
|
if (!string.IsNullOrWhiteSpace(activityId))
|
||||||
|
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
|
||||||
|
// 21: connectable_graph_data { root_node { slot_index=0-ish, field 7 = 1 } }
|
||||||
|
AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 });
|
||||||
|
// 24: scene_settings_data { enforce_host_only_areas=true, use_new_materials=true }
|
||||||
|
AppendLengthDelimited(output, 24, new byte[] { 0x18, 0x01, 0x30, 0x01 });
|
||||||
|
// 29: object_model_data (opaque bytes; empty model)
|
||||||
|
AppendLengthDelimited(output, 29, new byte[] { 0x08, 0x02, 0x1A, 0x00 });
|
||||||
|
// 32-35: room_fog_data / room_mood_sun_data / room_mood_background_objects / room_mood_skydome
|
||||||
|
AppendLengthDelimited(output, 32, Convert.FromHexString("0a070d00000040100110122500004844"));
|
||||||
|
AppendLengthDelimited(output, 33, Convert.FromHexString("0a070d000000401001580162140dffffff3e15ffffff3e1dffffffbe25ffffff3e"));
|
||||||
|
AppendLengthDelimited(output, 34, Convert.FromHexString("0a070d000000401001121208ffffffffffffffffff01100d250000803f1a1208ffffffffffffffffff01100d250000803f221208ffffffffffffffffff01100d250000803f2a1208ffffffffffffffffff01100d250000803f"));
|
||||||
|
AppendLengthDelimited(output, 35, Convert.FromHexString("0a070d0000004010011032182920252825300d3dae47613e45713d8a3e"));
|
||||||
|
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendLengthDelimited(Stream output, int fieldNumber, byte[] payload)
|
||||||
|
{
|
||||||
|
WriteVarint(output, ((ulong)fieldNumber << 3) | 2);
|
||||||
|
WriteVarint(output, (ulong)payload.Length);
|
||||||
|
output.Write(payload, 0, payload.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RoomRoleCollectionData BuildRroEditableRoleData()
|
private static RoomRoleCollectionData BuildRroEditableRoleData()
|
||||||
|
|||||||
@@ -213,6 +213,16 @@ actions:
|
|||||||
"Home" entry (`isVirtual: true`, max players = room capacity) and enable
|
"Home" entry (`isVirtual: true`, max players = room capacity) and enable
|
||||||
creates the real row with the game-side defaults
|
creates the real row with the game-side defaults
|
||||||
(`RoomsController.GetOrCreateSceneForMutationAsync`).
|
(`RoomsController.GetOrCreateSceneForMutationAsync`).
|
||||||
|
What the CDN hands back for that name while nothing is saved is a
|
||||||
|
**per-room stub** built by `RoomDataBlobService.BuildRroEditableBlob`:
|
||||||
|
the skeleton of a real save (Rec Center's `room_100_v1.dat` was the
|
||||||
|
reference) — `version` 16 (the client's ceiling; the old roles-only stub
|
||||||
|
was stamped 19 and the client fetched it and silently loaded the room
|
||||||
|
with no roles, i.e. no Maker Pen), `activity_id` = the room's
|
||||||
|
`LocationReplicationId`, `last_save_time`, empty connectable graph /
|
||||||
|
object model, scene settings and the four `room_mood_*` blocks, plus the
|
||||||
|
permissive role collection. The miss path now runs the same 2023
|
||||||
|
version clamp as the S3-hit path.
|
||||||
- **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`.
|
- **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`.
|
||||||
Clears both pointers. This is the escape hatch if a save left the room
|
Clears both pointers. This is the escape hatch if a save left the room
|
||||||
unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
|
unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
|
||||||
|
|||||||
@@ -152,6 +152,17 @@ baked into the paintball scene). Its rule is the Maker Pen chip
|
|||||||
Rec Room delivered inside the room blob. Without it there is no
|
Rec Room delivered inside the room blob. Without it there is no
|
||||||
`BattleRoyaleManager` in the scene.
|
`BattleRoyaleManager` in the scene.
|
||||||
|
|
||||||
|
Stub shape matters. Until something is saved the CDN synthesises the blob,
|
||||||
|
and the client fetches it through `RecNet.Rooms.GetRoomData` and rejects
|
||||||
|
one it deems unusable — a clone surfaces that as the bare "Failed to copy
|
||||||
|
room", a baked room as a silent load with no room roles (no Maker Pen).
|
||||||
|
Compared against Rec Center's working `room_100_v1.dat`, the old stub was
|
||||||
|
stamped `PersistedRoomVersion` 19 (client ceiling 16; only the S3-hit path
|
||||||
|
clamped) and lacked the save skeleton (`last_save_time`, `activity_id` =
|
||||||
|
LocationReplicationId, connectable graph, scene settings, object model,
|
||||||
|
`room_mood_*`). `RoomDataBlobService.BuildRroEditableBlob` now emits that
|
||||||
|
skeleton per room at version 16.
|
||||||
|
|
||||||
Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable`
|
Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable`
|
||||||
stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and
|
stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and
|
||||||
room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data
|
room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data
|
||||||
|
|||||||
Reference in New Issue
Block a user