feat: Rec Royale night mode (ClientMod) + admin switch to give baked RRO rooms a data blob

ClientMod (2023-03-21): opt-in Rec Royale night mode. RRO scenes never
enable the room mood engine (no data blob → SceneMoodSettings.isEnabled
stays false, CurrentMood null, the per-frame update returns at once), so
the mod replays the room-data loader's bring-up, registers a shipped
night MoodSetting (Night_Calm_Outdoor_Mood by default) as the scene's
default mood, supplies a skybox material on the Custom/SkyBox shader
(MoodConfig.SkyBoxMaterial is not loaded outside custom rooms), assigns
the scene's directional light as RenderSettings.sun, matches the key
light to the engine-driven sun, darkens via the mood's post-exposure and
sun/ambient intensities, forces post-processing on, keeps or disables
fog, hides the daytime cloud meshes, unlocks weapon flashlights, and
restores everything on hotkey-off / scene change. All knobs live in
dorknet-clientmod.json (RecRoyaleNight*), written by install-melon.ps1.

Server/admin: baked Rec Room Originals are seeded with an empty
DataBlobName, which the 2023 client reads as "no room data" — no Maker
Pen, no CV2 room-settings chips, no saves, and for Rec Royale no game at
all (its game-rule chip RecRoyaleSolos_GameRuleWrapperCircuit ships in
the blob, not the scene). New admin actions on the Sub-rooms tab:
POST rooms/{id}/subrooms/{sub}/blob/enable stamps the synthetic default
blob name (which CdnController already answers with the RRO-editable
blob) and blob/reset clears it back to scene-only. IsRRO is derived from
IsAGRoom, so the client keeps loading the baked map.

Docs: README section, docs/admin.md procedure, docs/recroom-2023-room-save.md,
new docs/recroom-2023-scene-moods.md with the ISIL offsets behind all of it.
Tests: RroRoomBlobTests.
This commit is contained in:
Alexa
2026-09-27 22:45:40 +03:00
parent cf3aa29ba2
commit 258183fb94
12 changed files with 2300 additions and 7 deletions
@@ -1117,6 +1117,9 @@ public class AdminController(
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
return Ok(scenes.Select(s => new
{
// Ids go out as strings: the admin SPA is JavaScript and silently
@@ -1128,10 +1131,134 @@ public class AdminController(
canMatchmakeInto = s.CanMatchmakeInto,
isSandbox = s.IsSandbox,
dataBlobName = s.DataBlobName,
// What the client will actually be told to download for this
// sub-room (same rule as RoomsController.BuildSubRoomWire): the
// scene's own blob, else the room's, else a synthetic default —
// except baked Rec Room Originals, which get NO blob until an
// admin enables one (see EnableSubRoomBlob).
effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal),
isBakedOriginal = bakedOriginal,
dataModifiedAt = s.DataModifiedAt,
}).ToList());
}
private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity scene, bool bakedOriginal)
{
if (!string.IsNullOrWhiteSpace(scene.DataBlobName)) return scene.DataBlobName;
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (bakedOriginal) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
/// editing: the synthetic default for the entry scene (the same name the
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
subRoomId == 0
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
/// — give a baked Rec Room Original sub-room a data blob so the client
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
///
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
/// builder + matchmaking resolver turns that into an empty
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
/// no persistence views, no room-role overlay, no Maker Pen, no room
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
/// it, not in the scene). Stamping the synthetic default name is enough:
/// every resolver returns it verbatim, and <c>CdnController</c> already
/// answers that name for AG rooms with the RRO-editable blob (permissive
/// room roles, no objects) when nothing has been saved yet. The first
/// in-game save then replaces the name with a real upload.
///
/// No-op when the sub-room already has a blob. Idempotent.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
if (!string.IsNullOrWhiteSpace(scene.DataBlobName))
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
var now = DateTime.UtcNow;
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}");
await db.SaveChangesAsync();
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
/// — clear the sub-room's data blob reference. For a baked Rec Room
/// Original that puts the room back to "scene only, no blob" (the escape
/// hatch if a save left the room unplayable); for any other room the
/// resolvers fall back to the synthetic default, i.e. an empty room.
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
/// the in-game restore list can bring an old save back.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
var previousScene = scene.DataBlobName;
var previousRoom = room.CurrentDataBlobName;
var changed = false;
var now = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(scene.DataBlobName))
{
scene.DataBlobName = string.Empty;
scene.DataModifiedAt = now;
changed = true;
}
if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = string.Empty;
room.UpdatedAt = now;
changed = true;
}
if (changed)
{
await LogAsync("reset_subroom_blob", "room", id,
$"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'");
await db.SaveChangesAsync();
}
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
}
private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity scene, bool bakedOriginal, bool changed) => new
{
id = scene.Id.ToString(),
subRoomId = scene.OrderIndex,
name = scene.Name,
dataBlobName = scene.DataBlobName,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
changed,
};
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
+73 -2
View File
@@ -563,6 +563,11 @@ interface SubRoom {
canMatchmakeInto: boolean;
isSandbox: boolean;
dataBlobName: string;
/** What the client is actually told to download: scene blob, else room
* blob, else synthetic default — or '' for a baked Rec Room Original
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
effectiveBlobName: string;
isBakedOriginal: boolean;
}
// Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is
@@ -576,6 +581,34 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
const { data, loading, error, refresh } = useApi<SubRoom[]>(`/rooms/${roomId}/subrooms`);
const [edits, setEdits] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<number | null>(null);
const [blobBusy, setBlobBusy] = useState<number | null>(null);
const [resetArmed, setResetArmed] = useState<number | null>(null);
// Baked Rec Room Originals ship with no data blob, so the client never
// runs its room-data path there (no Maker Pen, no CV2 chips, no saves).
// "Enable" stamps the synthetic default name the CDN already answers with
// the RRO-editable blob; "Reset" clears the pointers back to scene-only.
const setBlob = async (sub: SubRoom, action: 'enable' | 'reset') => {
setBlobBusy(sub.subRoomId);
try {
const res = await api<{ effectiveBlobName: string; changed: boolean }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/${action}`,
{ method: 'POST' },
);
toast.push(
action === 'enable'
? `${sub.name || `Sub-room ${sub.subRoomId}`} now loads ${res.effectiveBlobName}`
: `${sub.name || `Sub-room ${sub.subRoomId}`} reset to the baked scene (no blob)`,
'success',
);
setResetArmed(null);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const save = async (sub: SubRoom) => {
const raw = edits[sub.subRoomId];
@@ -631,8 +664,46 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
{sub.isSandbox && <span className="badge-neutral">Sandbox</span>}
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
</div>
{sub.dataBlobName && (
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">
{sub.effectiveBlobName
? sub.effectiveBlobName
: sub.isBakedOriginal
? 'no data blob — baked scene only (no Maker Pen / chips)'
: '(synthetic default blob)'}
</div>
{sub.isBakedOriginal && (
<div className="flex items-center gap-2 mt-1.5">
{!sub.dataBlobName ? (
<button
onClick={() => setBlob(sub, 'enable')}
disabled={blobBusy === sub.subRoomId}
className="btn-secondary text-xs"
title="Serve a room data blob for this baked sub-room so Maker Pen, CV2 chips and saves work. The first in-game save replaces it with a real upload."
>
{blobBusy === sub.subRoomId ? 'Enabling…' : 'Enable Maker Pen blob'}
</button>
) : resetArmed === sub.subRoomId ? (
<>
<span className="text-xs text-danger">Drops the blob pointer; saved blobs stay restorable.</span>
<button
onClick={() => setBlob(sub, 'reset')}
disabled={blobBusy === sub.subRoomId}
className="btn-danger text-xs"
>
{blobBusy === sub.subRoomId ? 'Resetting…' : 'Confirm reset'}
</button>
<button onClick={() => setResetArmed(null)} className="btn-secondary text-xs">Cancel</button>
</>
) : (
<button
onClick={() => setResetArmed(sub.subRoomId)}
className="btn-secondary text-xs"
title="Go back to the baked scene with no data blob (escape hatch if a save broke the room)."
>
Reset to baked scene
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">