mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Enhance RoomZipImportController to support Unity asset bundles
- Added support for .assetbundle files in RoomZipImportController. - Introduced new properties in RoomSceneEntity for handling Studio asset metadata. - Updated ZipImport method to handle asset bundles during bulk imports. - Enhanced CdnController to serve Unity asset bundles via a new route. - Modified GoToController to include Unity asset ID in matchmaking responses. - Updated database schema to accommodate new fields related to Studio asset bundles. - Improved URL coverage documentation to reflect new routes for asset bundles.
This commit is contained in:
@@ -86,3 +86,5 @@ Recovered(*)/
|
|||||||
|
|
||||||
|
|
||||||
local-releases/**
|
local-releases/**
|
||||||
|
DorkNet.Models/bin-verify/**
|
||||||
|
DorkNet.Models/bin-verify2/**
|
||||||
|
|||||||
+285
-16
@@ -22,6 +22,7 @@
|
|||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using Il2CppInterop.Runtime;
|
using Il2CppInterop.Runtime;
|
||||||
using Il2CppInterop.Runtime.InteropTypes;
|
using Il2CppInterop.Runtime.InteropTypes;
|
||||||
|
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
@@ -185,6 +186,24 @@ public class Mod : MelonMod
|
|||||||
"NotifyServerCertificate",
|
"NotifyServerCertificate",
|
||||||
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
|
||||||
}
|
}
|
||||||
|
if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton)
|
||||||
|
Log.Msg($"[debugconsole] armed: toggleKey={Cfg.DebugConsoleToggleKey}, commands={DebugConsolePatches.FormatConfiguredCommandsForLog()}");
|
||||||
|
// Image CDN signature verification. Server responses still include
|
||||||
|
// Content-Signature, but the official RSA public key is baked into
|
||||||
|
// GameAssembly and the matching private key is not available. This
|
||||||
|
// method is the verifier in the 2023 build (JFDPIJFDHAD.HOCPMNEKIHP);
|
||||||
|
// skipping it lets signed local CDN/image bytes load.
|
||||||
|
TryPatchByName("JFDPIJFDHAD",
|
||||||
|
"HOCPMNEKIHP",
|
||||||
|
args: new[]
|
||||||
|
{
|
||||||
|
typeof(string),
|
||||||
|
typeof(Il2CppStructArray<byte>),
|
||||||
|
typeof(Il2CppStructArray<byte>),
|
||||||
|
typeof(Il2CppStructArray<byte>),
|
||||||
|
typeof(Il2CppStructArray<byte>)
|
||||||
|
},
|
||||||
|
prefix: nameof(ImageSignaturePatches.VerifyImageSignature_Prefix));
|
||||||
RegisterQuitTracePatches(logMisses: true);
|
RegisterQuitTracePatches(logMisses: true);
|
||||||
RegisterAntiTamperCallbackPatches(logMisses: true);
|
RegisterAntiTamperCallbackPatches(logMisses: true);
|
||||||
RegisterToxModPatches(logMisses: true);
|
RegisterToxModPatches(logMisses: true);
|
||||||
@@ -316,6 +335,9 @@ public class Mod : MelonMod
|
|||||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||||
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RegisterStudioTracePatches();
|
||||||
|
|
||||||
Log.Msg("=== Client patches registered ===");
|
Log.Msg("=== Client patches registered ===");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +753,11 @@ public class Mod : MelonMod
|
|||||||
RegisterToxModPatches(logMisses: false);
|
RegisterToxModPatches(logMisses: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Cfg.EnableDebugConsole || Cfg.EnableDevWatchButton)
|
||||||
|
DebugConsolePatches.PollToggleKey();
|
||||||
|
|
||||||
|
if (Cfg.DiagnoseDevMenu || Cfg.EnableDevWatchButton)
|
||||||
|
DevMenuProbe.Tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnApplicationQuit()
|
public override void OnApplicationQuit()
|
||||||
@@ -1036,7 +1063,7 @@ public class Mod : MelonMod
|
|||||||
{
|
{
|
||||||
// Look in all three patch holder classes — small enough that a
|
// Look in all three patch holder classes — small enough that a
|
||||||
// linear scan is cheaper than per-class lookups.
|
// linear scan is cheaper than per-class lookups.
|
||||||
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ImageSignaturePatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||||
{
|
{
|
||||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||||
if (m is not null) return m;
|
if (m is not null) return m;
|
||||||
@@ -1249,6 +1276,27 @@ public class Mod : MelonMod
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Studio baked-asset load trace. Registered directly from the
|
||||||
|
/// live client-patch flow (the deep diagnostics path
|
||||||
|
/// <see cref="RegisterDiagnostics"/> is not wired into init, so probes
|
||||||
|
/// placed there never run). Pinpoints where the baked-Studio-scene chain
|
||||||
|
/// breaks: the unity-asset getter (does the client ever request a
|
||||||
|
/// bundle), prefab registration (does a bundle ever load), and the prefab
|
||||||
|
/// lookups that fail with "Expected a prefab, but found none".
|
||||||
|
/// RecRoomObjectPrefabManager is name-preserved; the getter is the
|
||||||
|
/// obfuscated NLDBPDCNNCF.FKLDMPMFLBD. Each is a single overload.</summary>
|
||||||
|
private void RegisterStudioTracePatches()
|
||||||
|
{
|
||||||
|
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
|
||||||
|
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
|
||||||
|
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
|
||||||
|
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
|
||||||
|
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
|
||||||
|
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
|
||||||
|
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
|
||||||
|
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
|
||||||
|
}
|
||||||
|
|
||||||
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||||
{
|
{
|
||||||
const string label = "room-load-state-machines.MoveNext";
|
const string label = "room-load-state-machines.MoveNext";
|
||||||
@@ -1409,6 +1457,66 @@ internal static class RoomLoadDiagnostics
|
|||||||
return __exception;
|
return __exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Studio baked-asset load trace ──────────────────────────────────
|
||||||
|
// Pinpoints where the baked-Studio-scene load chain breaks. The room
|
||||||
|
// joins but renders no custom geometry and logs "Expected a prefab,
|
||||||
|
// but found none"; the server serves everything but the client never
|
||||||
|
// requests a bundle. These probes answer: (1) does the client ever
|
||||||
|
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
|
||||||
|
// ever register baked prefabs; (3) which prefab GUIDs does it look up
|
||||||
|
// and miss.
|
||||||
|
|
||||||
|
/// <summary>RecNet unity-asset getter
|
||||||
|
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
|
||||||
|
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
|
||||||
|
/// this never fires, the client decided not to fetch the bundle at all
|
||||||
|
/// (trigger missing upstream); if it fires, the URL/host/response is the
|
||||||
|
/// problem.</summary>
|
||||||
|
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
|
||||||
|
{
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
|
||||||
|
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
|
||||||
|
WriteStack("studio-assetfetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
|
||||||
|
/// prefabs being registered after a bundle loads. If this never fires,
|
||||||
|
/// no bundle was loaded.</summary>
|
||||||
|
public static void StudioRegisterPrefabs_Postfix(object? __0)
|
||||||
|
{
|
||||||
|
int count = -1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (__0 is not null)
|
||||||
|
{
|
||||||
|
var p = __0.GetType().GetProperty("Count");
|
||||||
|
if (p?.GetValue(__0) is int c) count = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best-effort count */ }
|
||||||
|
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
|
||||||
|
WriteStack("studio-registerprefabs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
|
||||||
|
/// — the lookup that fails with "Expected a prefab, but found none".
|
||||||
|
/// Logs the GUID + whether it resolved.</summary>
|
||||||
|
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
|
||||||
|
{
|
||||||
|
if (!ShouldLog("studio-trygetprefab", 200)) return;
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
|
||||||
|
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
|
||||||
|
{
|
||||||
|
if (!ShouldLog("studio-isknownprefab", 200)) return;
|
||||||
|
DiagnosticPatches.Write(
|
||||||
|
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
|
||||||
|
}
|
||||||
|
|
||||||
public static string FormatException(Exception? ex, object? fallback)
|
public static string FormatException(Exception? ex, object? fallback)
|
||||||
{
|
{
|
||||||
if (ex is null) return fallback?.ToString() ?? "<null>";
|
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||||
@@ -1634,12 +1742,13 @@ internal static class RoomLoadDiagnostics
|
|||||||
//
|
//
|
||||||
// Everything is resolved by reflection at runtime so the SAME code works on
|
// Everything is resolved by reflection at runtime so the SAME code works on
|
||||||
// both client builds despite their differing obfuscation:
|
// both client builds despite their differing obfuscation:
|
||||||
// * The show/hide toggle is the one non-compiler-generated private
|
// * The show/hide toggle is the one private instance void(bool) on
|
||||||
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
|
// DebugConsole — ShowInputField on 2020.03, MCNBJFECHLJ on 2020.12,
|
||||||
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
|
// KIAMDEDKFBC on 2023.03. We try the known names, then fall back to
|
||||||
// one, then fall back to that structural match.
|
// a structural match.
|
||||||
// * The live component is found via UnityEngine.Object.FindObjectOfType
|
// * The live component is found via Resources.FindObjectsOfTypeAll
|
||||||
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
|
// using an Il2CppSystem.Type argument; this interop build does not
|
||||||
|
// expose UnityEngine.Object.FindObjectOfType(System.Type).
|
||||||
// * Execute(string) keeps its readable name on both builds (kept here as
|
// * Execute(string) keeps its readable name on both builds (kept here as
|
||||||
// a scripted-command entry point even though the hotkey drives the UI).
|
// a scripted-command entry point even though the hotkey drives the UI).
|
||||||
internal static class DebugConsolePatches
|
internal static class DebugConsolePatches
|
||||||
@@ -1656,12 +1765,23 @@ internal static class DebugConsolePatches
|
|||||||
private static bool _consoleResolved;
|
private static bool _consoleResolved;
|
||||||
private static Type? _consoleType;
|
private static Type? _consoleType;
|
||||||
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
|
||||||
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
|
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
|
||||||
|
|
||||||
// Harmony prefix returning false → skips the original CheatManager
|
// Harmony prefix returning false → skips the original CheatManager
|
||||||
// detector, so a tripped heuristic never runs its punish/drop path.
|
// detector, so a tripped heuristic never runs its punish/drop path.
|
||||||
public static bool SuppressCheatDetected_Prefix() => false;
|
public static bool SuppressCheatDetected_Prefix() => false;
|
||||||
|
|
||||||
|
public static string FormatConfiguredCommandsForLog()
|
||||||
|
{
|
||||||
|
var commands = GetConfiguredCommands();
|
||||||
|
if (commands.Count == 0) return "<none>";
|
||||||
|
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var command in commands)
|
||||||
|
parts.Add($"{command.Label}={command.Command}");
|
||||||
|
return string.Join(", ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
|
||||||
public static void PollToggleKey()
|
public static void PollToggleKey()
|
||||||
{
|
{
|
||||||
@@ -1697,7 +1817,7 @@ internal static class DebugConsolePatches
|
|||||||
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
|
var active = FindConsoleInstance();
|
||||||
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
|
||||||
}
|
}
|
||||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
|
||||||
@@ -1750,13 +1870,9 @@ internal static class DebugConsolePatches
|
|||||||
// name, then a structural fallback that fits both builds.
|
// name, then a structural fallback that fits both builds.
|
||||||
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
|
||||||
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
|
||||||
|
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
|
||||||
?? FindToggleByShape(_consoleType);
|
?? FindToggleByShape(_consoleType);
|
||||||
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
|
||||||
|
|
||||||
var objType = Mod.ResolveType("UnityEngine.Object");
|
|
||||||
_findObjectOfType = objType is null ? null
|
|
||||||
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
|
|
||||||
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The console's show/hide is the single private, instance, void method
|
// The console's show/hide is the single private, instance, void method
|
||||||
@@ -1782,10 +1898,10 @@ internal static class DebugConsolePatches
|
|||||||
public static void Toggle()
|
public static void Toggle()
|
||||||
{
|
{
|
||||||
if (!_consoleResolved) ResolveConsole();
|
if (!_consoleResolved) ResolveConsole();
|
||||||
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
|
if (_consoleType is null || _toggleMethod is null) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
|
var instance = FindConsoleInstance();
|
||||||
if (instance is null)
|
if (instance is null)
|
||||||
{
|
{
|
||||||
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
|
||||||
@@ -1798,10 +1914,109 @@ internal static class DebugConsolePatches
|
|||||||
_consoleVisible = !_consoleVisible;
|
_consoleVisible = !_consoleVisible;
|
||||||
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
|
||||||
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
|
||||||
|
if (_consoleVisible)
|
||||||
|
ShowConfiguredCommands(instance);
|
||||||
}
|
}
|
||||||
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static object? FindConsoleInstance()
|
||||||
|
{
|
||||||
|
if (_consoleType is null) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2cppType = ToIl2CppType(_consoleType);
|
||||||
|
if (il2cppType is null) return null;
|
||||||
|
|
||||||
|
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||||
|
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
|
||||||
|
var count = ArrLen(all);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var item = ArrItem(all, i);
|
||||||
|
if (item is not null) return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
var objType = Mod.ResolveType("UnityEngine.Object");
|
||||||
|
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
|
||||||
|
return findOne?.Invoke(null, new[] { il2cppType });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowConfiguredCommands(object instance)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_consoleType is null) return;
|
||||||
|
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
|
||||||
|
if (_setConsoleText is null)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
|
||||||
|
Mod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Mod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildConfiguredCommandText()
|
||||||
|
{
|
||||||
|
var commands = GetConfiguredCommands();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("DorkNet debug commands");
|
||||||
|
sb.AppendLine();
|
||||||
|
if (commands.Count == 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("No commands configured in DevCommands.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (var i = 0; i < commands.Count; i++)
|
||||||
|
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Type a command and press Enter.");
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ConfiguredCommand> GetConfiguredCommands()
|
||||||
|
{
|
||||||
|
var commands = new List<ConfiguredCommand>();
|
||||||
|
foreach (var entry in Mod.Cfg.DevCommands)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||||
|
var split = entry.IndexOf('=');
|
||||||
|
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
|
||||||
|
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(command)) continue;
|
||||||
|
if (string.IsNullOrWhiteSpace(label)) label = command;
|
||||||
|
commands.Add(new ConfiguredCommand(label, command));
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct ConfiguredCommand
|
||||||
|
{
|
||||||
|
public ConfiguredCommand(string label, string command)
|
||||||
|
{
|
||||||
|
Label = label;
|
||||||
|
Command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Label { get; }
|
||||||
|
public string Command { get; }
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnableShowButton()
|
private static void EnableShowButton()
|
||||||
{
|
{
|
||||||
if (_consoleType is null) return;
|
if (_consoleType is null) return;
|
||||||
@@ -1817,6 +2032,52 @@ internal static class DebugConsolePatches
|
|||||||
}
|
}
|
||||||
catch { /* best-effort — the toggle still works without it */ }
|
catch { /* best-effort — the toggle still works without it */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static object? ToIl2CppType(Type managed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
|
||||||
|
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||||
|
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
|
||||||
|
if (from is null) return null;
|
||||||
|
var args = from.GetParameters().Length == 2
|
||||||
|
? new object[] { managed, true }
|
||||||
|
: new object[] { managed };
|
||||||
|
return from.Invoke(null, args);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
|
||||||
|
{
|
||||||
|
if (type is null) return null;
|
||||||
|
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
if (method.Name != name) continue;
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ArrLen(object? array)
|
||||||
|
{
|
||||||
|
if (array is null) return 0;
|
||||||
|
if (array is Array managedArray) return managedArray.Length;
|
||||||
|
var type = array.GetType();
|
||||||
|
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
|
||||||
|
return (length?.GetValue(array) as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ArrItem(object? array, int index)
|
||||||
|
{
|
||||||
|
if (array is null) return null;
|
||||||
|
if (array is Array managedArray) return managedArray.GetValue(index);
|
||||||
|
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||||
|
return getItem?.Invoke(array, new object[] { index });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3545,6 +3806,14 @@ internal static class TlsPatches
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class ImageSignaturePatches
|
||||||
|
{
|
||||||
|
public static bool VerifyImageSignature_Prefix()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static class QuitTracePatches
|
internal static class QuitTracePatches
|
||||||
{
|
{
|
||||||
public static bool ApplicationQuit_Prefix()
|
public static bool ApplicationQuit_Prefix()
|
||||||
|
|||||||
@@ -135,9 +135,31 @@ public class InventionsController(
|
|||||||
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
[HttpPost("api/inventions/v1/dormskinsfromids")]
|
||||||
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
|
||||||
{
|
{
|
||||||
if (req?.InventionIds is null || req.InventionIds.Count == 0)
|
return await BatchIdsAsync(req?.InventionIds ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/inventions/v1/batch")]
|
||||||
|
[HttpGet("api/inventions/v2/batch")]
|
||||||
|
[HttpGet("api/inventions/v1/dormskinsfromids")]
|
||||||
|
public async Task<ActionResult> BatchFromQuery()
|
||||||
|
{
|
||||||
|
var ids = Request.Query
|
||||||
|
.SelectMany(q => q.Value)
|
||||||
|
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
.Select(v => long.TryParse(v, out var id) ? id : 0L)
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Take(200)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return await BatchIdsAsync(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
|
||||||
|
{
|
||||||
|
if (requestedIds.Count == 0)
|
||||||
return Ok(Array.Empty<object>());
|
return Ok(Array.Empty<object>());
|
||||||
var ids = req.InventionIds.Take(200).ToList();
|
var ids = requestedIds.Take(200).ToList();
|
||||||
var rows = await db.Inventions
|
var rows = await db.Inventions
|
||||||
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ public class RoomsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("rooms/{roomId:long}")]
|
[HttpGet("rooms/{roomId:long}")]
|
||||||
public async Task<IActionResult> RoomServerById(long roomId)
|
public async Task<IActionResult> RoomServerById(
|
||||||
|
long roomId,
|
||||||
|
[FromQuery] int? unityAssetTarget,
|
||||||
|
[FromQuery] int? unityAssetVersion)
|
||||||
{
|
{
|
||||||
var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId);
|
var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId);
|
||||||
var sceneRows = await db.RoomScenes
|
var sceneRows = await db.RoomScenes
|
||||||
@@ -229,7 +232,12 @@ public class RoomsController(
|
|||||||
.Where(r => r.RoomId == room.Id)
|
.Where(r => r.RoomId == room.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Ok(BuildRoomServerDetails(room, sceneRows, roles: roles));
|
return Ok(BuildRoomServerDetails(
|
||||||
|
room,
|
||||||
|
sceneRows,
|
||||||
|
roles: roles,
|
||||||
|
unityAssetTarget: unityAssetTarget,
|
||||||
|
unityAssetVersion: unityAssetVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RoomDetails (the boot-critical one) ─────────────────────────────
|
// ── RoomDetails (the boot-critical one) ─────────────────────────────
|
||||||
@@ -312,6 +320,270 @@ public class RoomsController(
|
|||||||
return Ok(blobs);
|
return Ok(blobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}</c>
|
||||||
|
/// — Studio room save metadata. The 2023 client can load baked Studio
|
||||||
|
/// scenes by first asking this endpoint which Unity asset bundle file
|
||||||
|
/// belongs to the save, then downloading it from
|
||||||
|
/// <c>cdn.../room/{Filename}.assetbundle</c>. The importer persists
|
||||||
|
/// the exact bundle filenames from the Studio dump on RoomScenes, so
|
||||||
|
/// this response is backed by the imported archive bytes rather than a
|
||||||
|
/// placeholder.</summary>
|
||||||
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
|
||||||
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> StudioSubRoomSave(
|
||||||
|
long roomId,
|
||||||
|
long subRoomId,
|
||||||
|
long saveId,
|
||||||
|
[FromQuery] int? unityAssetTarget,
|
||||||
|
[FromQuery] int? unityAssetVersion)
|
||||||
|
{
|
||||||
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
|
var sceneRows = await db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId)
|
||||||
|
.OrderBy(s => s.OrderIndex)
|
||||||
|
.ToListAsync();
|
||||||
|
var scene = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId)
|
||||||
|
?? sceneRows.FirstOrDefault(s => s.StudioSubRoomDataSaveId == saveId);
|
||||||
|
if (scene is null) return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||||
|
|
||||||
|
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>rooms/{roomId}/subrooms</c> — the subroom list the
|
||||||
|
/// 2023 client / Studio editor fetches
|
||||||
|
/// (<c>String.Format("rooms/{0}/subrooms", roomId)</c>). Emits the same
|
||||||
|
/// per-subroom shape as the <c>SubRooms[]</c> entries inside
|
||||||
|
/// <c>/roomserver/rooms/{id}</c>, so a single-scene room synthesises one
|
||||||
|
/// "Home" entry and a multi-scene imported room enumerates its
|
||||||
|
/// RoomScenes.</summary>
|
||||||
|
[HttpGet("rooms/{roomId:long}/subrooms")]
|
||||||
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SubRooms(long roomId)
|
||||||
|
{
|
||||||
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
|
var sceneRows = await db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId)
|
||||||
|
.OrderBy(s => s.OrderIndex)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var wire = sceneRows.Count > 0
|
||||||
|
? sceneRows.Select(s => BuildSubRoomWire(room, s)).ToArray()
|
||||||
|
: new[] { BuildSubRoomWire(room, null) };
|
||||||
|
return Ok(wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}</c> — one subroom
|
||||||
|
/// (<c>String.Format("rooms/{0}/subrooms/{1}", roomId, subRoomId)</c>).
|
||||||
|
/// <paramref name="subRoomId"/> is the RoomScene OrderIndex.</summary>
|
||||||
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||||
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
|
||||||
|
{
|
||||||
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
|
var scene = await db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
// A single-scene room has no RoomScene row; subRoom 0 is the
|
||||||
|
// synthesised "Home" scene.
|
||||||
|
if (scene is null && subRoomId != 0)
|
||||||
|
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||||
|
|
||||||
|
return Ok(BuildSubRoomWire(room, scene));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/data</c> — the
|
||||||
|
/// current persisted save for the subroom
|
||||||
|
/// (<c>String.Format("rooms/{0}/subrooms/{1}/data", roomId, subRoomId)</c>).
|
||||||
|
/// Returns the same <c>SubRoomDataSave</c> shape as
|
||||||
|
/// <c>/saves/{saveId}</c> resolved to the scene's current save id.</summary>
|
||||||
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||||
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SubRoomData(
|
||||||
|
long roomId,
|
||||||
|
long subRoomId,
|
||||||
|
[FromQuery] int? unityAssetTarget,
|
||||||
|
[FromQuery] int? unityAssetVersion)
|
||||||
|
{
|
||||||
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
|
var scene = await db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (scene is null && subRoomId != 0)
|
||||||
|
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||||
|
|
||||||
|
var saveId = scene?.StudioSubRoomDataSaveId ?? 0L;
|
||||||
|
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves</c> — the
|
||||||
|
/// list of saves for a subroom
|
||||||
|
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
|
||||||
|
/// Returns the current save plus any historical blobs recorded for the
|
||||||
|
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.</summary>
|
||||||
|
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
||||||
|
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SubRoomSaves(long roomId, long subRoomId)
|
||||||
|
{
|
||||||
|
var room = await rooms.GetByIdAsync(roomId);
|
||||||
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||||
|
|
||||||
|
var scene = await db.RoomScenes
|
||||||
|
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (scene is null && subRoomId != 0)
|
||||||
|
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
||||||
|
|
||||||
|
var currentSaveId = scene?.StudioSubRoomDataSaveId ?? 0L;
|
||||||
|
var saves = new List<object>
|
||||||
|
{
|
||||||
|
BuildStudioSaveWire(room, scene, subRoomId, currentSaveId, null, null),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Historical blobs for this subroom (the same rows the datahistory
|
||||||
|
// endpoint surfaces), surfaced as prior saves so the Studio editor's
|
||||||
|
// version picker has the full set. Distinct by blob name, current
|
||||||
|
// save excluded (already first in the list).
|
||||||
|
var history = await db.RoomDataBlobs
|
||||||
|
.Where(b => b.RoomId == roomId && b.SubRoomId == subRoomId)
|
||||||
|
.OrderByDescending(b => b.UploadedAt)
|
||||||
|
.Take(50)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var h in history)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName) &&
|
||||||
|
string.Equals(h.BlobName, scene!.DataBlobName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
saves.Add(new
|
||||||
|
{
|
||||||
|
SubRoomDataSaveId = currentSaveId,
|
||||||
|
SubRoomId = subRoomId,
|
||||||
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
|
DataBlob = h.BlobName,
|
||||||
|
DataBlobName = h.BlobName,
|
||||||
|
PersistenceVersion = 41,
|
||||||
|
OMVersion = 0,
|
||||||
|
SavedByAccountId = h.UploadedByPlayerId,
|
||||||
|
SavedOnPlatform = 7,
|
||||||
|
SavedOnDeviceClass = 2,
|
||||||
|
SavedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
|
||||||
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(saves);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build one subroom entry in the wire shape the watch's
|
||||||
|
/// <c>SubRooms[]</c> array uses (see <c>BuildRoomServerDetails</c>). A
|
||||||
|
/// null <paramref name="scene"/> yields the synthesised "Home" scene for
|
||||||
|
/// single-scene rooms.</summary>
|
||||||
|
private object BuildSubRoomWire(RoomEntity room, RoomSceneEntity? scene)
|
||||||
|
{
|
||||||
|
var fallback = CurrentOrSyntheticDataBlobName(room);
|
||||||
|
var orderIndex = scene?.OrderIndex ?? 0;
|
||||||
|
var dataBlob = scene is null
|
||||||
|
? fallback
|
||||||
|
: SceneOrSyntheticDataBlobName(room, scene.DataBlobName, fallback);
|
||||||
|
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
SubRoomId = (long)orderIndex,
|
||||||
|
RoomId = room.Id,
|
||||||
|
Name = scene?.Name ?? "Home",
|
||||||
|
DataBlob = dataBlob,
|
||||||
|
DataSavedAt = (modified == default ? DateTime.UtcNow : modified)
|
||||||
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||||
|
IsSandbox = scene?.IsSandbox ?? false,
|
||||||
|
MaxPlayers = scene?.MaxPlayers ?? 8,
|
||||||
|
Accessibility = room.Accessibility,
|
||||||
|
UnitySceneId = scene?.RoomSceneLocationId ?? room.LocationReplicationId,
|
||||||
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
|
CurrentSave = new
|
||||||
|
{
|
||||||
|
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
||||||
|
SubRoomId = (long)orderIndex,
|
||||||
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||||
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
|
DataBlob = dataBlob,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build the <c>SubRoomDataSave</c> wire object for a Studio
|
||||||
|
/// save — shared by <c>/saves/{saveId}</c>, <c>/data</c>, and the first
|
||||||
|
/// entry of <c>/saves</c>. A null <paramref name="scene"/> (single-scene
|
||||||
|
/// room) returns a save with no baked bundles, pointing at the room's
|
||||||
|
/// current data blob.</summary>
|
||||||
|
private object BuildStudioSaveWire(
|
||||||
|
RoomEntity room,
|
||||||
|
RoomSceneEntity? scene,
|
||||||
|
long subRoomId,
|
||||||
|
long saveId,
|
||||||
|
int? unityAssetTarget,
|
||||||
|
int? unityAssetVersion)
|
||||||
|
{
|
||||||
|
var bundles = StudioBundlesForScene(scene, saveId);
|
||||||
|
|
||||||
|
if (bundles.Count == 0 && scene is not null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"[rooms-studio-save] no asset bundles room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} csvLen={CsvLen}",
|
||||||
|
room.Id, subRoomId, saveId, scene.Name, scene.StudioAssetBundleNamesCsv?.Length ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||||
|
var bakedAssets = BuildStudioBakedAssets(room, scene, saveId, unityAssetTarget, unityAssetVersion);
|
||||||
|
var primary = bakedAssets.FirstOrDefault();
|
||||||
|
var unityAsset = BuildStudioUnityAsset(room, scene, bakedAssets);
|
||||||
|
var dataBlob = !string.IsNullOrWhiteSpace(scene?.DataBlobName)
|
||||||
|
? scene!.DataBlobName
|
||||||
|
: CurrentOrSyntheticDataBlobName(room);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"[rooms-studio-save] room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} target={Target} version={Version} primary={Primary} bundles={BundleCount}",
|
||||||
|
room.Id, subRoomId, saveId, scene?.Name ?? "Home", unityAssetTarget, unityAssetVersion,
|
||||||
|
primary?.Filename ?? string.Empty, bundles.Count);
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
SubRoomDataSaveId = saveId,
|
||||||
|
SubRoomId = subRoomId,
|
||||||
|
UnityAssetId = unityAssetId,
|
||||||
|
SubRoomUnityAssetId = unityAssetId,
|
||||||
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
|
DataBlob = dataBlob,
|
||||||
|
DataBlobName = dataBlob,
|
||||||
|
PersistenceVersion = 41,
|
||||||
|
OMVersion = 0,
|
||||||
|
SavedByAccountId = room.CreatorPlayerId,
|
||||||
|
SavedOnPlatform = 7,
|
||||||
|
SavedOnDeviceClass = 2,
|
||||||
|
CreatedByAccountId = room.CreatorPlayerId,
|
||||||
|
UnityAssetHash = string.Empty,
|
||||||
|
UnityAsset = unityAsset,
|
||||||
|
UnityAssetFilename = primary?.Filename ?? string.Empty,
|
||||||
|
BakedUnityAssets = bakedAssets,
|
||||||
|
UnitySubAssets = Array.Empty<object>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
|
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
|
||||||
/// — the watch's "Go" button in the Restore Room dialog. Body is
|
/// — the watch's "Go" button in the Restore Room dialog. Body is
|
||||||
/// form-urlencoded with a single <c>filename</c> field carrying the
|
/// form-urlencoded with a single <c>filename</c> field carrying the
|
||||||
@@ -1300,7 +1572,9 @@ public class RoomsController(
|
|||||||
RoomEntity room,
|
RoomEntity room,
|
||||||
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
|
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
|
||||||
string? overrideDataBlobName = null,
|
string? overrideDataBlobName = null,
|
||||||
IReadOnlyList<RoomRoleEntity>? roles = null)
|
IReadOnlyList<RoomRoleEntity>? roles = null,
|
||||||
|
int? unityAssetTarget = null,
|
||||||
|
int? unityAssetVersion = null)
|
||||||
{
|
{
|
||||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||||
// 2023's room-permissions runtime needs a real PersistedRoomData
|
// 2023's room-permissions runtime needs a real PersistedRoomData
|
||||||
@@ -1311,34 +1585,118 @@ public class RoomsController(
|
|||||||
.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||||
|
|
||||||
object[] subRooms = sceneRows is { Count: > 0 }
|
object[] subRooms = sceneRows is { Count: > 0 }
|
||||||
? sceneRows.Select(s => (object)new
|
? sceneRows.Select(s =>
|
||||||
{
|
{
|
||||||
|
var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
||||||
|
? overrideDataBlobName
|
||||||
|
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName);
|
||||||
|
// A scene with a baked Studio asset (non-empty UnityAssetId)
|
||||||
|
// must advertise a Studio-era PersistenceVersion on its
|
||||||
|
// CurrentSave. The 2023 client gates the baked-Addressables
|
||||||
|
// load path on this: a version of 0 (the old default) makes
|
||||||
|
// it treat the save as a pre-baked legacy blob and it never
|
||||||
|
// fetches unity_assets/{id}/{target}/{version}, so the
|
||||||
|
// persistence views reference prefabs that were never loaded
|
||||||
|
// ("Expected a prefab, but found none"). 41 matches the real
|
||||||
|
// RecRocks export (room.json PersistenceVersion=41).
|
||||||
|
var baked = !string.IsNullOrWhiteSpace(s.StudioUnityAssetId);
|
||||||
|
var saveVersion = baked ? StudioPersistenceVersion : 0;
|
||||||
|
var bakedAssets = BuildStudioBakedAssets(
|
||||||
|
room,
|
||||||
|
s,
|
||||||
|
s.StudioSubRoomDataSaveId ?? 0L,
|
||||||
|
unityAssetTarget,
|
||||||
|
unityAssetVersion);
|
||||||
|
var unityAsset = bakedAssets.FirstOrDefault();
|
||||||
|
var unityAssetParent = BuildStudioUnityAsset(room, s, bakedAssets);
|
||||||
|
return (object)new
|
||||||
|
{
|
||||||
|
ReplicationId = s.RoomSceneLocationId,
|
||||||
|
PersistenceVersion = saveVersion,
|
||||||
|
SupportsJoinInProgress = true,
|
||||||
|
UseLevelBasedMatchmaking = false,
|
||||||
|
UseAgeBasedMatchmaking = false,
|
||||||
|
UseRecRoyaleMatchmaking = false,
|
||||||
SubRoomId = (long)s.OrderIndex,
|
SubRoomId = (long)s.OrderIndex,
|
||||||
RoomId = room.Id,
|
RoomId = room.Id,
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
UnityAssetId = s.StudioUnityAssetId,
|
||||||
? overrideDataBlobName
|
SubRoomUnityAssetId = s.StudioUnityAssetId,
|
||||||
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName),
|
UnityAsset = unityAsset?.Filename ?? string.Empty,
|
||||||
|
UnityAssetHash = string.Empty,
|
||||||
|
DataBlob = dataBlob,
|
||||||
|
DataBlobHash = (string?)null,
|
||||||
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||||
IsSandbox = s.IsSandbox,
|
IsSandbox = s.IsSandbox,
|
||||||
MaxPlayers = s.MaxPlayers,
|
MaxPlayers = s.MaxPlayers,
|
||||||
Accessibility = room.Accessibility,
|
Accessibility = room.Accessibility,
|
||||||
UnitySceneId = s.RoomSceneLocationId,
|
UnitySceneId = s.RoomSceneLocationId,
|
||||||
|
CurrentSave = new
|
||||||
|
{
|
||||||
|
SubRoomDataSaveId = s.StudioSubRoomDataSaveId ?? 0L,
|
||||||
|
SubRoomId = (long)s.OrderIndex,
|
||||||
|
UnityAssetId = s.StudioUnityAssetId,
|
||||||
|
SubRoomUnityAssetId = s.StudioUnityAssetId,
|
||||||
|
CreatedByAccountId = room.CreatorPlayerId,
|
||||||
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
|
DataBlob = dataBlob,
|
||||||
|
DataBlobHash = (string?)null,
|
||||||
|
PersistenceVersion = saveVersion,
|
||||||
|
OMVersion = 0,
|
||||||
|
SavedByAccountId = room.CreatorPlayerId,
|
||||||
|
SavedOnPlatform = 0,
|
||||||
|
SavedOnDeviceClass = 2,
|
||||||
|
Description = string.Empty,
|
||||||
|
ModerationState = 0,
|
||||||
|
CreatedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||||
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||||
|
UgcSubVersion = saveVersion,
|
||||||
|
UnityAssetHash = string.Empty,
|
||||||
|
UnityAsset = unityAssetParent,
|
||||||
|
UnityAssetFilename = unityAsset?.Filename ?? string.Empty,
|
||||||
|
BakedUnityAssets = bakedAssets,
|
||||||
|
UnitySubAssets = Array.Empty<object>(),
|
||||||
|
},
|
||||||
|
LastModeratedSaveModerationState = 0,
|
||||||
|
DefaultMatchmakingPolicy = 0,
|
||||||
|
ShouldAutoStageSaves = true,
|
||||||
|
StagedSubRoomDataSaveId = (long?)null,
|
||||||
|
};
|
||||||
}).ToArray()
|
}).ToArray()
|
||||||
: new object[]
|
: new object[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
|
ReplicationId = room.LocationReplicationId,
|
||||||
|
PersistenceVersion = 0,
|
||||||
|
SupportsJoinInProgress = true,
|
||||||
|
UseLevelBasedMatchmaking = false,
|
||||||
|
UseAgeBasedMatchmaking = false,
|
||||||
|
UseRecRoyaleMatchmaking = false,
|
||||||
SubRoomId = 0L,
|
SubRoomId = 0L,
|
||||||
RoomId = room.Id,
|
RoomId = room.Id,
|
||||||
Name = "Home",
|
Name = "Home",
|
||||||
|
UnityAssetId = string.Empty,
|
||||||
|
SubRoomUnityAssetId = string.Empty,
|
||||||
|
UnityAsset = string.Empty,
|
||||||
|
UnityAssetHash = string.Empty,
|
||||||
DataBlob = dataBlobName,
|
DataBlob = dataBlobName,
|
||||||
|
DataBlobHash = (string?)null,
|
||||||
DataSavedAt = updatedAt,
|
DataSavedAt = updatedAt,
|
||||||
IsSandbox = false,
|
IsSandbox = false,
|
||||||
MaxPlayers = 8,
|
MaxPlayers = 8,
|
||||||
Accessibility = room.Accessibility,
|
Accessibility = room.Accessibility,
|
||||||
UnitySceneId = room.LocationReplicationId,
|
UnitySceneId = room.LocationReplicationId,
|
||||||
|
CurrentSave = new
|
||||||
|
{
|
||||||
|
SubRoomDataSaveId = 0L,
|
||||||
|
SubRoomId = 0L,
|
||||||
|
UnityAssetId = string.Empty,
|
||||||
|
SubRoomUnityAssetId = string.Empty,
|
||||||
|
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||||
|
DataBlob = dataBlobName,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1389,6 +1747,19 @@ public class RoomsController(
|
|||||||
DisableMicAutoMute = room.DisableMicAutoMute,
|
DisableMicAutoMute = room.DisableMicAutoMute,
|
||||||
DisableRoomComments = false,
|
DisableRoomComments = false,
|
||||||
EncryptVoiceChat = false,
|
EncryptVoiceChat = false,
|
||||||
|
// Studio/UGC version family. The 2023 client reads UgcVersion to
|
||||||
|
// decide the asset-bundle version it requests
|
||||||
|
// (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion
|
||||||
|
// to recognise a baked Studio room at all. Matches the real
|
||||||
|
// RecRocks room.json (UgcVersion=1, PersistenceVersion=41). For a
|
||||||
|
// non-Studio room these stay at the legacy 0 so behaviour is
|
||||||
|
// unchanged.
|
||||||
|
PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
||||||
|
UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0,
|
||||||
|
UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
||||||
|
MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
||||||
|
PublishState = 0,
|
||||||
|
MaxPlayerCalculationMode = 0,
|
||||||
SubRooms = subRooms,
|
SubRooms = subRooms,
|
||||||
Roles = wireRoles,
|
Roles = wireRoles,
|
||||||
LoadScreens = Array.Empty<object>(),
|
LoadScreens = Array.Empty<object>(),
|
||||||
@@ -1398,6 +1769,18 @@ public class RoomsController(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>PersistenceVersion / UgcSubVersion stamped on a baked Studio
|
||||||
|
/// scene's CurrentSave. The 2023 client's room-load gate treats anything
|
||||||
|
/// below the Studio-asset-bundle era as a legacy non-baked save and skips
|
||||||
|
/// the Addressables bundle fetch. 41 matches the real RecRocks export
|
||||||
|
/// (room.json + the per-save sidecars).</summary>
|
||||||
|
public const int StudioPersistenceVersion = 41;
|
||||||
|
|
||||||
|
/// <summary>Room-level UgcVersion for a Studio room. The client uses this
|
||||||
|
/// as the asset-bundle version in unity_assets/{id}/{target}/{version}
|
||||||
|
/// (RecRocks bundles bake at v1, room.json UgcVersion=1).</summary>
|
||||||
|
public const int StudioUgcVersion = 1;
|
||||||
|
|
||||||
|
|
||||||
// ── Clone / create ───────────────────────────────────────────────────
|
// ── Clone / create ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1564,7 +1947,7 @@ public class RoomsController(
|
|||||||
public async Task<IActionResult> RoomsRequiring(string restriction)
|
public async Task<IActionResult> RoomsRequiring(string restriction)
|
||||||
{
|
{
|
||||||
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
|
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
|
||||||
if (key.Length == 0) return Ok(new List<object>());
|
if (key.Length == 0) return Ok(new List<string>());
|
||||||
|
|
||||||
IQueryable<RoomEntity> query = db.Rooms.AsNoTracking()
|
IQueryable<RoomEntity> query = db.Rooms.AsNoTracking()
|
||||||
.Where(r => !r.HiddenFromBrowse && r.State == 0);
|
.Where(r => !r.HiddenFromBrowse && r.State == 0);
|
||||||
@@ -1585,9 +1968,10 @@ public class RoomsController(
|
|||||||
var rows = await query
|
var rows = await query
|
||||||
.OrderByDescending(r => r.HotScore)
|
.OrderByDescending(r => r.HotScore)
|
||||||
.ThenBy(r => r.Name)
|
.ThenBy(r => r.Name)
|
||||||
|
.Select(r => r.Name)
|
||||||
.Take(100)
|
.Take(100)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows.Select(RoomService.ToWireRoom).ToList());
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("rooms/curated_playlists")]
|
[HttpGet("rooms/curated_playlists")]
|
||||||
@@ -2709,6 +3093,93 @@ public class RoomsController(
|
|||||||
return fallbackBlobName;
|
return fallbackBlobName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record StudioBundleInfo(long SaveId, int Target, int Version, string Filename);
|
||||||
|
|
||||||
|
private sealed record BakedUnityAssetWire(
|
||||||
|
string UnityAssetId,
|
||||||
|
long CreatedByAccountId,
|
||||||
|
string Filename,
|
||||||
|
int Target,
|
||||||
|
int Version,
|
||||||
|
string Hash);
|
||||||
|
|
||||||
|
private sealed record UnityAssetWire(
|
||||||
|
string UnityAssetId,
|
||||||
|
long CreatedByAccountId,
|
||||||
|
string Filename,
|
||||||
|
BakedUnityAssetWire[] BakedUnityAssets);
|
||||||
|
|
||||||
|
private static BakedUnityAssetWire[] BuildStudioBakedAssets(
|
||||||
|
RoomEntity room,
|
||||||
|
RoomSceneEntity? scene,
|
||||||
|
long saveId,
|
||||||
|
int? unityAssetTarget,
|
||||||
|
int? unityAssetVersion)
|
||||||
|
{
|
||||||
|
var bundles = StudioBundlesForScene(scene, saveId);
|
||||||
|
if (unityAssetTarget is int target)
|
||||||
|
bundles = bundles.Where(b => b.Target == target).ToList();
|
||||||
|
if (unityAssetVersion is int requestedVersion && bundles.Any(b => b.Version == requestedVersion))
|
||||||
|
bundles = bundles.Where(b => b.Version == requestedVersion).ToList();
|
||||||
|
|
||||||
|
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||||
|
return bundles
|
||||||
|
.OrderBy(b => b.Target)
|
||||||
|
.ThenByDescending(b => b.Version)
|
||||||
|
.Select(b => new BakedUnityAssetWire(
|
||||||
|
unityAssetId,
|
||||||
|
room.CreatorPlayerId,
|
||||||
|
b.Filename,
|
||||||
|
b.Target,
|
||||||
|
b.Version,
|
||||||
|
string.Empty))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnityAssetWire? BuildStudioUnityAsset(
|
||||||
|
RoomEntity room,
|
||||||
|
RoomSceneEntity? scene,
|
||||||
|
BakedUnityAssetWire[] bakedAssets)
|
||||||
|
{
|
||||||
|
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(unityAssetId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var filename = bakedAssets.FirstOrDefault()?.Filename ?? string.Empty;
|
||||||
|
return new UnityAssetWire(unityAssetId, room.CreatorPlayerId, filename, bakedAssets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StudioBundleInfo> StudioBundlesForScene(RoomSceneEntity? scene, long saveId)
|
||||||
|
{
|
||||||
|
return (scene?.StudioAssetBundleNamesCsv ?? string.Empty)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(ParseStudioBundleName)
|
||||||
|
.Where(b => b is not null)
|
||||||
|
.Cast<StudioBundleInfo>()
|
||||||
|
.Where(b => b.SaveId == saveId || scene?.StudioSubRoomDataSaveId == saveId)
|
||||||
|
.OrderBy(b => b.Target)
|
||||||
|
.ThenByDescending(b => b.Version)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudioBundleInfo? ParseStudioBundleName(string fileName)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(fileName);
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(
|
||||||
|
name,
|
||||||
|
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||||||
|
if (!match.Success) return null;
|
||||||
|
if (!long.TryParse(match.Groups["save"].Value, out var saveId)) return null;
|
||||||
|
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
|
||||||
|
var version = 0;
|
||||||
|
if (match.Groups["version"].Success &&
|
||||||
|
!int.TryParse(match.Groups["version"].Value, out version))
|
||||||
|
return null;
|
||||||
|
return new StudioBundleInfo(saveId, target, version, name);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class RoomZipImportController(
|
|||||||
ILogger<RoomZipImportController> logger) : ControllerBase
|
ILogger<RoomZipImportController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
private long CurrentAdminId => this.RequireCurrentPlayerId();
|
||||||
|
private const long MaxArchiveBytes = 12_000_000_000L;
|
||||||
|
|
||||||
// ── Room-blob normaliser toggle ─────────────────────────────────────
|
// ── Room-blob normaliser toggle ─────────────────────────────────────
|
||||||
// The normaliser does two things now (see RoomBlobNormalizerService):
|
// The normaliser does two things now (see RoomBlobNormalizerService):
|
||||||
@@ -128,6 +129,7 @@ public class RoomZipImportController(
|
|||||||
".mp4" => "video/mp4",
|
".mp4" => "video/mp4",
|
||||||
".htr" => "application/octet-stream",
|
".htr" => "application/octet-stream",
|
||||||
".room" => "application/octet-stream",
|
".room" => "application/octet-stream",
|
||||||
|
".assetbundle" => "application/octet-stream",
|
||||||
".meta" => "application/octet-stream",
|
".meta" => "application/octet-stream",
|
||||||
".inv" => "application/octet-stream",
|
".inv" => "application/octet-stream",
|
||||||
".dat" => "application/octet-stream",
|
".dat" => "application/octet-stream",
|
||||||
@@ -220,6 +222,7 @@ public class RoomZipImportController(
|
|||||||
/// missing, the importer derives it from the SubRooms/<scene>
|
/// missing, the importer derives it from the SubRooms/<scene>
|
||||||
/// folder name suffix (<c>SubRoomName__<subRoomId></c>).</summary>
|
/// folder name suffix (<c>SubRoomName__<subRoomId></c>).</summary>
|
||||||
public long? SubRoomId { get; set; }
|
public long? SubRoomId { get; set; }
|
||||||
|
public string? UnityAssetId { get; set; }
|
||||||
public string? DataBlob { get; set; }
|
public string? DataBlob { get; set; }
|
||||||
public DateTime? CreatedAt { get; set; }
|
public DateTime? CreatedAt { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
@@ -268,7 +271,7 @@ public class RoomZipImportController(
|
|||||||
|
|
||||||
[HttpPost("zip-bulk-import")]
|
[HttpPost("zip-bulk-import")]
|
||||||
[DisableRequestSizeLimit]
|
[DisableRequestSizeLimit]
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB
|
[RequestFormLimits(MultipartBodyLengthLimit = MaxArchiveBytes)]
|
||||||
public async Task<IActionResult> ZipImport(
|
public async Task<IActionResult> ZipImport(
|
||||||
[FromForm(Name = "archive")] IFormFile? archive,
|
[FromForm(Name = "archive")] IFormFile? archive,
|
||||||
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
|
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
|
||||||
@@ -320,8 +323,8 @@ public class RoomZipImportController(
|
|||||||
[HttpPost("zip-upload-init")]
|
[HttpPost("zip-upload-init")]
|
||||||
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
|
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
|
||||||
{
|
{
|
||||||
if (body.TotalBytes <= 0 || body.TotalBytes > 5_000_000_000)
|
if (body.TotalBytes <= 0 || body.TotalBytes > MaxArchiveBytes)
|
||||||
return BadRequest(new { error = "invalid_size", maxBytes = 5_000_000_000L, gotBytes = body.TotalBytes });
|
return BadRequest(new { error = "invalid_size", maxBytes = MaxArchiveBytes, gotBytes = body.TotalBytes });
|
||||||
|
|
||||||
// Prune stale sessions before allocating a new one. Anything
|
// Prune stale sessions before allocating a new one. Anything
|
||||||
// older than 2 hours with no activity is abandoned.
|
// older than 2 hours with no activity is abandoned.
|
||||||
@@ -914,9 +917,11 @@ public class RoomZipImportController(
|
|||||||
// it reflects whether the diagnostic parse succeeded, not
|
// it reflects whether the diagnostic parse succeeded, not
|
||||||
// whether we actually applied the normalised output.
|
// whether we actually applied the normalised output.
|
||||||
NormalizedOk: norm.Normalized,
|
NormalizedOk: norm.Normalized,
|
||||||
|
StudioUnityAssetId: null,
|
||||||
HtrAssets: htrAssets,
|
HtrAssets: htrAssets,
|
||||||
PvImages: pvImages,
|
PvImages: pvImages,
|
||||||
Polaroids: polaroids,
|
Polaroids: polaroids,
|
||||||
|
AssetBundles: new List<(string Name, ZipArchiveEntry Entry)>(),
|
||||||
History: history));
|
History: history));
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
|
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
|
||||||
@@ -1044,9 +1049,11 @@ public class RoomZipImportController(
|
|||||||
var htrAssetCount = 0;
|
var htrAssetCount = 0;
|
||||||
var pvImageCount = 0;
|
var pvImageCount = 0;
|
||||||
var polaroidCount = 0;
|
var polaroidCount = 0;
|
||||||
|
var assetBundleCount = 0;
|
||||||
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
|
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
|
||||||
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
|
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
|
||||||
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
|
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
|
||||||
|
var allAssetBundles = ordered.SelectMany(s => s.AssetBundles).ToList();
|
||||||
|
|
||||||
// De-dupe within the archive first (same .htr can appear under
|
// De-dupe within the archive first (same .htr can appear under
|
||||||
// multiple scenes), then de-dupe against the DB. Both layers
|
// multiple scenes), then de-dupe against the DB. Both layers
|
||||||
@@ -1054,6 +1061,7 @@ public class RoomZipImportController(
|
|||||||
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var uniqueAssetBundleNames = allAssetBundles.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
var existingHtrNames = new HashSet<string>(
|
var existingHtrNames = new HashSet<string>(
|
||||||
await db.RoomDataBlobs
|
await db.RoomDataBlobs
|
||||||
@@ -1070,9 +1078,15 @@ public class RoomZipImportController(
|
|||||||
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
|
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
|
||||||
.Select(b => b.BlobName).ToListAsync(),
|
.Select(b => b.BlobName).ToListAsync(),
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingAssetBundleNames = new HashSet<string>(
|
||||||
|
await db.RoomDataBlobs
|
||||||
|
.Where(b => uniqueAssetBundleNames.Contains(b.BlobName))
|
||||||
|
.Select(b => b.BlobName).ToListAsync(),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
var htrAlreadyInDbAtStart = existingHtrNames.Count;
|
var htrAlreadyInDbAtStart = existingHtrNames.Count;
|
||||||
var pvAlreadyInDbAtStart = existingPvNames.Count;
|
var pvAlreadyInDbAtStart = existingPvNames.Count;
|
||||||
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
|
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
|
||||||
|
var assetBundleAlreadyInDbAtStart = existingAssetBundleNames.Count;
|
||||||
|
|
||||||
// History (SubRooms/<scene>/History/<DataBlob>.room) — pre-load
|
// History (SubRooms/<scene>/History/<DataBlob>.room) — pre-load
|
||||||
// existing names so the de-dup HashSet covers prior imports + same
|
// existing names so the de-dup HashSet covers prior imports + same
|
||||||
@@ -1173,6 +1187,17 @@ public class RoomZipImportController(
|
|||||||
await WriteBlobAsync(0, polName, bytes, creator);
|
await WriteBlobAsync(0, polName, bytes, creator);
|
||||||
polaroidCount++;
|
polaroidCount++;
|
||||||
}
|
}
|
||||||
|
// Studio baked Unity asset bundles. Keep original filenames;
|
||||||
|
// Studio save metadata points the 2023 client at
|
||||||
|
// cdn.../room/<filename>.assetbundle.
|
||||||
|
foreach (var (bundleName, bundleEntry) in allAssetBundles)
|
||||||
|
{
|
||||||
|
if (existingAssetBundleNames.Contains(bundleName)) continue;
|
||||||
|
existingAssetBundleNames.Add(bundleName);
|
||||||
|
var bytes = await ReadAllAsync(bundleEntry);
|
||||||
|
await WriteBlobAsync(0, bundleName, bytes, creator);
|
||||||
|
assetBundleCount++;
|
||||||
|
}
|
||||||
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
|
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
|
||||||
// sentinel the exporter drops, purpose unclear but watching
|
// sentinel the exporter drops, purpose unclear but watching
|
||||||
// for fetches just in case). Rename to room_<id>_<hash>.meta
|
// for fetches just in case). Rename to room_<id>_<hash>.meta
|
||||||
@@ -1234,6 +1259,9 @@ public class RoomZipImportController(
|
|||||||
Accessibility = details.Accessibility ?? 1,
|
Accessibility = details.Accessibility ?? 1,
|
||||||
IsAGRoom = false,
|
IsAGRoom = false,
|
||||||
IsDormRoom = details.IsDorm ?? false,
|
IsDormRoom = details.IsDorm ?? false,
|
||||||
|
IsStudioRoom = entries.ContainsKey(studioDetailsKey),
|
||||||
|
IsRoomLinkedToRecRoomStudio = entries.ContainsKey(studioDetailsKey),
|
||||||
|
StudioSessionId = details.RoomId?.ToString() ?? TryParseStudioId(ZipFileName(roomFolder))?.ToString() ?? string.Empty,
|
||||||
CloningAllowed = details.CloningAllowed ?? true,
|
CloningAllowed = details.CloningAllowed ?? true,
|
||||||
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
|
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
|
||||||
SupportsVRLow = details.SupportsVRLow ?? true,
|
SupportsVRLow = details.SupportsVRLow ?? true,
|
||||||
@@ -1246,7 +1274,7 @@ public class RoomZipImportController(
|
|||||||
CustomRoomWarning = details.CustomWarning ?? string.Empty,
|
CustomRoomWarning = details.CustomWarning ?? string.Empty,
|
||||||
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
|
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
|
||||||
LocationReplicationId = entryLocation,
|
LocationReplicationId = entryLocation,
|
||||||
TagsCsv = JoinTags(details.Tags),
|
TagsCsv = EnsureStudioTags(JoinTags(details.Tags), entries.ContainsKey(studioDetailsKey)),
|
||||||
CheerCount = details.Stats?.CheerCount ?? 0,
|
CheerCount = details.Stats?.CheerCount ?? 0,
|
||||||
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
|
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
|
||||||
VisitCount = details.Stats?.VisitCount ?? 0,
|
VisitCount = details.Stats?.VisitCount ?? 0,
|
||||||
@@ -1266,6 +1294,11 @@ public class RoomZipImportController(
|
|||||||
Name = s.SceneName,
|
Name = s.SceneName,
|
||||||
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
|
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
|
||||||
DataBlobName = MakeBlobName(s.SceneName),
|
DataBlobName = MakeBlobName(s.SceneName),
|
||||||
|
StudioSubRoomDataSaveId = s.Manifest.CurrentSave?.SubRoomDataSaveId,
|
||||||
|
StudioUnityAssetId = s.StudioUnityAssetId ?? string.Empty,
|
||||||
|
StudioAssetBundleNamesCsv = string.Join(',', s.AssetBundles
|
||||||
|
.Select(a => a.Name)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)),
|
||||||
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
|
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
|
||||||
IsSandbox = s.Manifest.IsSandbox ?? false,
|
IsSandbox = s.Manifest.IsSandbox ?? false,
|
||||||
CanMatchmakeInto = true,
|
CanMatchmakeInto = true,
|
||||||
@@ -1282,7 +1315,8 @@ public class RoomZipImportController(
|
|||||||
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
|
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
|
||||||
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
|
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
|
||||||
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
|
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
|
||||||
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
|
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed " +
|
||||||
|
$"bundles={assetBundleCount}new+{assetBundleAlreadyInDbAtStart}existed",
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
@@ -1318,6 +1352,12 @@ public class RoomZipImportController(
|
|||||||
newlyImported = polaroidCount,
|
newlyImported = polaroidCount,
|
||||||
alreadyInDb = polaroidAlreadyInDbAtStart,
|
alreadyInDb = polaroidAlreadyInDbAtStart,
|
||||||
},
|
},
|
||||||
|
assetBundles = new
|
||||||
|
{
|
||||||
|
referenced = uniqueAssetBundleNames.Count,
|
||||||
|
newlyImported = assetBundleCount,
|
||||||
|
alreadyInDb = assetBundleAlreadyInDbAtStart,
|
||||||
|
},
|
||||||
history = new
|
history = new
|
||||||
{
|
{
|
||||||
referenced = allHistoryNames.Count,
|
referenced = allHistoryNames.Count,
|
||||||
@@ -1350,6 +1390,7 @@ public class RoomZipImportController(
|
|||||||
maxPlayers = s.Manifest.MaxPlayers,
|
maxPlayers = s.Manifest.MaxPlayers,
|
||||||
htrAssets = s.HtrAssets.Count,
|
htrAssets = s.HtrAssets.Count,
|
||||||
pvImages = s.PvImages.Count,
|
pvImages = s.PvImages.Count,
|
||||||
|
assetBundles = s.AssetBundles.Count,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1430,9 +1471,19 @@ public class RoomZipImportController(
|
|||||||
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
|
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||||
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
|
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||||
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
|
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||||
|
var assetBundles = new List<(string Name, ZipArchiveEntry Entry)>();
|
||||||
|
|
||||||
foreach (var save in saves)
|
foreach (var save in saves)
|
||||||
{
|
{
|
||||||
|
var bundlePrefix = $"{savesPrefix}{save.SaveId}__bundle_";
|
||||||
|
foreach (var (path, assetEntry) in entries.Where(kv =>
|
||||||
|
kv.Key.StartsWith(bundlePrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& kv.Key.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !kv.Key[bundlePrefix.Length..].Contains('/')))
|
||||||
|
{
|
||||||
|
assetBundles.Add((Path.GetFileName(path), assetEntry));
|
||||||
|
}
|
||||||
|
|
||||||
var refPrefix = $"{savesPrefix}{save.SaveId}__ref_";
|
var refPrefix = $"{savesPrefix}{save.SaveId}__ref_";
|
||||||
foreach (var (path, assetEntry) in entries.Where(kv =>
|
foreach (var (path, assetEntry) in entries.Where(kv =>
|
||||||
kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)
|
kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -1481,16 +1532,18 @@ public class RoomZipImportController(
|
|||||||
Bytes: persistedBytes,
|
Bytes: persistedBytes,
|
||||||
RawBytes: blobBytes.Length,
|
RawBytes: blobBytes.Length,
|
||||||
NormalizedOk: norm.Normalized,
|
NormalizedOk: norm.Normalized,
|
||||||
|
StudioUnityAssetId: current.Sidecar.UnityAssetId,
|
||||||
HtrAssets: htrAssets,
|
HtrAssets: htrAssets,
|
||||||
PvImages: pvImages,
|
PvImages: pvImages,
|
||||||
Polaroids: polaroids,
|
Polaroids: polaroids,
|
||||||
|
AssetBundles: assetBundles,
|
||||||
History: history));
|
History: history));
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} history={History}",
|
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} bundles={Bundles} history={History}",
|
||||||
roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length,
|
roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length,
|
||||||
normalizeBlobs ? "normaliser" : "raw",
|
normalizeBlobs ? "normaliser" : "raw",
|
||||||
htrAssets.Count, pvImages.Count, polaroids.Count, history.Count);
|
htrAssets.Count, pvImages.Count, polaroids.Count, assetBundles.Count, history.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (resolvedScenes, missingScenes);
|
return (resolvedScenes, missingScenes);
|
||||||
@@ -1791,15 +1844,32 @@ public class RoomZipImportController(
|
|||||||
return values.Count > 0 ? string.Join(',', values!) : "community";
|
return values.Count > 0 ? string.Join(',', values!) : "community";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string EnsureStudioTags(string tagsCsv, bool isStudioDump)
|
||||||
|
{
|
||||||
|
if (!isStudioDump) return tagsCsv;
|
||||||
|
var values = (tagsCsv ?? string.Empty)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
if (!values.Contains("community", StringComparer.OrdinalIgnoreCase))
|
||||||
|
values.Add("community");
|
||||||
|
if (!values.Contains("studio", StringComparer.OrdinalIgnoreCase))
|
||||||
|
values.Add("studio");
|
||||||
|
if (!values.Contains("developer", StringComparer.OrdinalIgnoreCase))
|
||||||
|
values.Add("developer");
|
||||||
|
return string.Join(',', values);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record ResolvedSubroom(
|
private sealed record ResolvedSubroom(
|
||||||
SubRoomDto Manifest,
|
SubRoomDto Manifest,
|
||||||
string SceneName,
|
string SceneName,
|
||||||
byte[] Bytes,
|
byte[] Bytes,
|
||||||
int RawBytes,
|
int RawBytes,
|
||||||
bool NormalizedOk,
|
bool NormalizedOk,
|
||||||
|
string? StudioUnityAssetId,
|
||||||
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
|
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
|
||||||
List<(string Name, ZipArchiveEntry Entry)> PvImages,
|
List<(string Name, ZipArchiveEntry Entry)> PvImages,
|
||||||
List<(string Name, ZipArchiveEntry Entry)> Polaroids,
|
List<(string Name, ZipArchiveEntry Entry)> Polaroids,
|
||||||
|
List<(string Name, ZipArchiveEntry Entry)> AssetBundles,
|
||||||
List<HistorySave> History);
|
List<HistorySave> History);
|
||||||
|
|
||||||
/// <summary>One historical save under SubRooms/<scene>/History/.
|
/// <summary>One historical save under SubRooms/<scene>/History/.
|
||||||
|
|||||||
@@ -110,11 +110,12 @@ public class CdnController(
|
|||||||
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
|
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
|
||||||
//
|
//
|
||||||
// Extension list: image formats + room/.htr/.inv blobs that the
|
// Extension list: image formats + room/.htr/.inv blobs that the
|
||||||
// watch fetches directly, plus video formats (mp4/webm/mov/m4v) so
|
// watch fetches directly, Studio .assetbundle files, plus video
|
||||||
|
// formats (mp4/webm/mov/m4v) so
|
||||||
// community-board video blobs uploaded via /admin/v1/communityboard/
|
// community-board video blobs uploaded via /admin/v1/communityboard/
|
||||||
// video/upload (which writes `cb_video_<hash>.mp4` etc.) resolve
|
// video/upload (which writes `cb_video_<hash>.mp4` etc.) resolve
|
||||||
// through the same bare-filename catch-all.
|
// through the same bare-filename catch-all.
|
||||||
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|mp4|webm|mov|m4v)$)}")]
|
[Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|assetbundle|mp4|webm|mov|m4v)$)}")]
|
||||||
[AcceptVerbs("GET", "HEAD")]
|
[AcceptVerbs("GET", "HEAD")]
|
||||||
public async Task<IActionResult> Serve(string? path)
|
public async Task<IActionResult> Serve(string? path)
|
||||||
{
|
{
|
||||||
@@ -140,6 +141,113 @@ public class CdnController(
|
|||||||
return await ServeCdn(fullPath);
|
return await ServeCdn(fullPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Studio baked Unity asset-bundle download ───────────────────────
|
||||||
|
/// <summary>GET <c>unity_assets/{assetId}/{target}/{version}</c> — the
|
||||||
|
/// 2023 client's asset-bundle download for baked Studio scenes. The
|
||||||
|
/// client builds this URL itself in RecNet.Runtime:
|
||||||
|
/// <c>String.Format("unity_assets/{0}/{1}/{2}",
|
||||||
|
/// WebUtility.UrlEncode(unityAssetId), (byte)target, (int)version)</c>
|
||||||
|
/// after reading <c>UnityAssetId</c> / <c>Target</c> / <c>Version</c>
|
||||||
|
/// from <c>rooms/{id}/subrooms/{sid}/saves/{saveId}</c>. It does NOT
|
||||||
|
/// fetch the bare <c>{filename}.assetbundle</c> the save metadata names
|
||||||
|
/// — so the bundle bytes only resolve through this id-addressed route.
|
||||||
|
///
|
||||||
|
/// <para>Resolution: the importer stores each baked bundle in S3 under
|
||||||
|
/// its original filename (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>)
|
||||||
|
/// and stamps the owning scene's <see cref="Data.Entities.RoomSceneEntity.StudioUnityAssetId"/>
|
||||||
|
/// + <see cref="Data.Entities.RoomSceneEntity.StudioAssetBundleNamesCsv"/>.
|
||||||
|
/// We look the scene up by <c>StudioUnityAssetId == assetId</c>, pick the
|
||||||
|
/// bundle whose embedded target+version match, and stream its bytes.</para>
|
||||||
|
///
|
||||||
|
/// <para>A genuine miss 404s — handing back a default room blob here
|
||||||
|
/// (as the room-blob path does) would feed the Unity AssetBundle loader
|
||||||
|
/// garbage and fail the scene load far less legibly than an honest
|
||||||
|
/// 404.</para></summary>
|
||||||
|
[Route("/unity_assets/{assetId}/{target:int}/{version:int}")]
|
||||||
|
[AcceptVerbs("GET", "HEAD")]
|
||||||
|
public async Task<IActionResult> ServeUnityAsset(string assetId, int target, int version)
|
||||||
|
{
|
||||||
|
assetId = (assetId ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(assetId)) return NotFound();
|
||||||
|
|
||||||
|
// The Studio UnityAssetId is the per-save asset id from the dump
|
||||||
|
// sidecar, unique per baked save, so this maps to one scene in
|
||||||
|
// practice; tolerate >1 by scanning each candidate's bundle list.
|
||||||
|
var scenes = await db.RoomScenes
|
||||||
|
.Where(s => s.StudioUnityAssetId == assetId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var candidates = scenes
|
||||||
|
.SelectMany(s => (s.StudioAssetBundleNamesCsv ?? string.Empty)
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
.Select(ParseBundleName)
|
||||||
|
.Where(b => b is not null)
|
||||||
|
.Cast<(int Target, int Version, string Filename)>()
|
||||||
|
.Where(b => b.Target == target)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Exact match first; otherwise serve the highest available version
|
||||||
|
// for the requested target (a Studio re-bake bumps the version, but
|
||||||
|
// a cached client manifest may still ask for a superseded one).
|
||||||
|
var pick = candidates.FirstOrDefault(b => b.Version == version);
|
||||||
|
if (pick.Filename is null && candidates.Count > 0)
|
||||||
|
pick = candidates.OrderByDescending(b => b.Version).First();
|
||||||
|
|
||||||
|
if (pick.Filename is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("[unity_assets] no bundle assetId={AssetId} target={Target} version={Version} scenes={Scenes}",
|
||||||
|
assetId, target, version, scenes.Count);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (bucket, key) = BlobRouter.Route(pick.Filename);
|
||||||
|
byte[]? bytes = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
|
bytes = await storage.GetAsync(bucket, key, cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "[unity_assets] S3 GetAsync threw {Bucket}/{Key}", bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
logger.LogWarning("[unity_assets] MISS assetId={AssetId} file={File} bucket={Bucket} key={Key}",
|
||||||
|
assetId, pick.Filename, bucket, key);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("[unity_assets] hit assetId={AssetId} target={Target} version={Version} file={File} bytes={Bytes}",
|
||||||
|
assetId, target, version, pick.Filename, bytes.Length);
|
||||||
|
Response.Headers.CacheControl = "public, max-age=3600";
|
||||||
|
signatures.AddContentSignature(Response, bytes);
|
||||||
|
return new FileContentResult(bytes, "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse a Studio baked-bundle filename
|
||||||
|
/// (<c>{saveId}__bundle_t{target}_v{version}.assetbundle</c>, or older
|
||||||
|
/// <c>{saveId}__bundle_t{target}.assetbundle</c>) into its
|
||||||
|
/// target/version/filename. Mirrors the parser in RoomsController so the
|
||||||
|
/// id-addressed CDN route and the save-metadata endpoint agree on the
|
||||||
|
/// same naming contract.</summary>
|
||||||
|
private static (int Target, int Version, string Filename)? ParseBundleName(string fileName)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(fileName);
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(
|
||||||
|
name,
|
||||||
|
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\d+))?\.assetbundle$",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||||||
|
if (!match.Success) return null;
|
||||||
|
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
|
||||||
|
var version = 0;
|
||||||
|
if (match.Groups["version"].Success &&
|
||||||
|
!int.TryParse(match.Groups["version"].Value, out version))
|
||||||
|
return null;
|
||||||
|
return (target, version, name);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Image pipeline (img.{apex}) ────────────────────────────────────
|
// ── Image pipeline (img.{apex}) ────────────────────────────────────
|
||||||
private async Task<IActionResult> ServeImage(string? path)
|
private async Task<IActionResult> ServeImage(string? path)
|
||||||
{
|
{
|
||||||
@@ -512,6 +620,7 @@ public class CdnController(
|
|||||||
".mp4" or ".m4v" => "video/mp4",
|
".mp4" or ".m4v" => "video/mp4",
|
||||||
".webm" => "video/webm",
|
".webm" => "video/webm",
|
||||||
".mov" => "video/quicktime",
|
".mov" => "video/quicktime",
|
||||||
|
".assetbundle" => "application/octet-stream",
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ public class GoToController(
|
|||||||
/// location/blob when we actually resolved one.</summary>
|
/// location/blob when we actually resolved one.</summary>
|
||||||
private static void ApplySubRoomMutation(
|
private static void ApplySubRoomMutation(
|
||||||
MatchmakingResponseDto resp, long subId, string subRoomName,
|
MatchmakingResponseDto resp, long subId, string subRoomName,
|
||||||
string? subLocation, string? subDataBlob)
|
string? subLocation, string? subDataBlob, string? subRoomUnityAssetId = null)
|
||||||
{
|
{
|
||||||
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
|
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
|
||||||
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
|
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
|
||||||
@@ -255,6 +255,7 @@ public class GoToController(
|
|||||||
resp.RoomInstance.SubRoomId = subId;
|
resp.RoomInstance.SubRoomId = subId;
|
||||||
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
|
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
|
||||||
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
||||||
|
resp.RoomInstance.SubRoomUnityAssetId = subRoomUnityAssetId ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>match.*/matchmake/room/{roomId}</c> — the 2023
|
/// <summary>POST <c>match.*/matchmake/room/{roomId}</c> — the 2023
|
||||||
@@ -329,14 +330,20 @@ public class GoToController(
|
|||||||
{
|
{
|
||||||
var scene = await db.RoomScenes.AsNoTracking()
|
var scene = await db.RoomScenes.AsNoTracking()
|
||||||
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
|
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
|
||||||
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName })
|
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName, s.StudioUnityAssetId })
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (scene is not null)
|
if (scene is not null)
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob}",
|
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob} unityAsset={UnityAsset}",
|
||||||
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName);
|
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName, scene.StudioUnityAssetId);
|
||||||
ApplySubRoomMutation(resp, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName);
|
ApplySubRoomMutation(
|
||||||
|
resp,
|
||||||
|
subRoomId,
|
||||||
|
scene.Name,
|
||||||
|
scene.RoomSceneLocationId,
|
||||||
|
scene.DataBlobName,
|
||||||
|
scene.StudioUnityAssetId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -684,6 +691,7 @@ public class GoToController(
|
|||||||
IsPrivate = true,
|
IsPrivate = true,
|
||||||
IsInProgress = true,
|
IsInProgress = true,
|
||||||
DataBlob = inst.DataBlob,
|
DataBlob = inst.DataBlob,
|
||||||
|
SubRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(inst.RoomId, inst.SubRoomId),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -818,6 +826,7 @@ public class GoToController(
|
|||||||
IsPrivate = room.IsPrivate,
|
IsPrivate = room.IsPrivate,
|
||||||
IsInProgress = room.IsInProgress,
|
IsInProgress = room.IsInProgress,
|
||||||
DataBlob = room.DataBlob,
|
DataBlob = room.DataBlob,
|
||||||
|
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
|
||||||
EventId = room.EventId,
|
EventId = room.EventId,
|
||||||
Name = room.Name,
|
Name = room.Name,
|
||||||
RoomInstanceType = room.RoomInstanceType,
|
RoomInstanceType = room.RoomInstanceType,
|
||||||
@@ -1200,6 +1209,7 @@ public class GoToController(
|
|||||||
? $"^dormroom_p{ownerForName}"
|
? $"^dormroom_p{ownerForName}"
|
||||||
: $"^{roomName.ToLowerInvariant()}_{roomId}";
|
: $"^{roomName.ToLowerInvariant()}_{roomId}";
|
||||||
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
|
var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId);
|
||||||
|
var subRoomUnityAssetId = await ResolveSubRoomUnityAssetIdAsync(roomId, 0);
|
||||||
|
|
||||||
var resp = new MatchmakingResponseDto
|
var resp = new MatchmakingResponseDto
|
||||||
{
|
{
|
||||||
@@ -1228,6 +1238,7 @@ public class GoToController(
|
|||||||
IsPrivate = false,
|
IsPrivate = false,
|
||||||
IsInProgress = true,
|
IsInProgress = true,
|
||||||
DataBlob = dataBlob,
|
DataBlob = dataBlob,
|
||||||
|
SubRoomUnityAssetId = subRoomUnityAssetId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
@@ -1292,6 +1303,14 @@ public class GoToController(
|
|||||||
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveSubRoomUnityAssetIdAsync(long roomId, long subRoomId)
|
||||||
|
{
|
||||||
|
return await db.RoomScenes.AsNoTracking()
|
||||||
|
.Where(s => s.RoomId == roomId && s.OrderIndex == subRoomId)
|
||||||
|
.Select(s => s.StudioUnityAssetId)
|
||||||
|
.FirstOrDefaultAsync() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stable id-per-room-name map for well-known rooms; unknown names get
|
/// Stable id-per-room-name map for well-known rooms; unknown names get
|
||||||
/// a hash-based id so each room name routes to its own roomId space.
|
/// a hash-based id so each room name routes to its own roomId space.
|
||||||
@@ -1399,6 +1418,13 @@ public class RoomInstanceDto
|
|||||||
[JsonPropertyName("dataBlob")]
|
[JsonPropertyName("dataBlob")]
|
||||||
public string DataBlob { get; set; } = string.Empty;
|
public string DataBlob { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// 2023 RoomLoading.LoadRoomCommand carries this separately from
|
||||||
|
// dataBlob. If it is empty, the loader can deserialize room view data
|
||||||
|
// before the Studio asset bundle has registered its prefabs, producing
|
||||||
|
// "Bad View Data detected. Expected a prefab, but found none".
|
||||||
|
[JsonPropertyName("subRoomUnityAssetId")]
|
||||||
|
public string SubRoomUnityAssetId { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonPropertyName("eventId")]
|
[JsonPropertyName("eventId")]
|
||||||
public long EventId { get; set; } = 0;
|
public long EventId { get; set; } = 0;
|
||||||
|
|
||||||
@@ -1472,6 +1498,7 @@ public class RoomInstanceDto
|
|||||||
IsPrivate = room.IsPrivate,
|
IsPrivate = room.IsPrivate,
|
||||||
IsInProgress = room.IsInProgress,
|
IsInProgress = room.IsInProgress,
|
||||||
DataBlob = string.Empty,
|
DataBlob = string.Empty,
|
||||||
|
SubRoomUnityAssetId = room.SubRoomUnityAssetId,
|
||||||
EventId = room.EventId,
|
EventId = room.EventId,
|
||||||
Name = room.Name,
|
Name = room.Name,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ public class RoomSceneEntity
|
|||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string DataBlobName { get; set; } = string.Empty;
|
public string DataBlobName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Original RecNet Studio save id for this sub-room, when
|
||||||
|
/// the scene was imported from a Studio dump. Used by
|
||||||
|
/// rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId} to return the
|
||||||
|
/// baked Unity asset bundle filenames the 2023 client downloads.</summary>
|
||||||
|
public long? StudioSubRoomDataSaveId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string StudioUnityAssetId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(4096)]
|
||||||
|
public string StudioAssetBundleNamesCsv { get; set; } = string.Empty;
|
||||||
|
|
||||||
public int MaxPlayers { get; set; } = 8;
|
public int MaxPlayers { get; set; } = 8;
|
||||||
public bool IsSandbox { get; set; } = false;
|
public bool IsSandbox { get; set; } = false;
|
||||||
public bool CanMatchmakeInto { get; set; } = true;
|
public bool CanMatchmakeInto { get; set; } = true;
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
|
|||||||
| POST | `api/rooms/v1/bookmark` | REAL | `MissingEndpointsController.BookmarkRoom` — upserts/removes a `RoomBookmarkEntity` based on the `Bookmark` form field |
|
| POST | `api/rooms/v1/bookmark` | REAL | `MissingEndpointsController.BookmarkRoom` — upserts/removes a `RoomBookmarkEntity` based on the `Bookmark` form field |
|
||||||
| GET | `api/rooms/v1/datahistory/{roomId}` | REAL | `Rooms.V2.RoomsController.DataHistory` — list of historical scene snapshots |
|
| GET | `api/rooms/v1/datahistory/{roomId}` | REAL | `Rooms.V2.RoomsController.DataHistory` — list of historical scene snapshots |
|
||||||
| POST | `api/rooms/v1/datahistory/restore` | REAL | `Rooms.V2.RoomsController.RestoreDataHistory` — rolls a room's scenes back to a snapshot |
|
| POST | `api/rooms/v1/datahistory/restore` | REAL | `Rooms.V2.RoomsController.RestoreDataHistory` — rolls a room's scenes back to a snapshot |
|
||||||
|
| GET | `rooms/{roomId}/subrooms` | REAL | `Rooms.V2.RoomsController.SubRooms` — Studio/2023 subroom list (one synthesised "Home" for single-scene rooms; `RoomScenes` rows otherwise) |
|
||||||
|
| GET | `rooms/{roomId}/subrooms/{subRoomId}` | REAL | `Rooms.V2.RoomsController.SubRoom` — one subroom by `RoomScene.OrderIndex` |
|
||||||
|
| GET | `rooms/{roomId}/subrooms/{subRoomId}/data` | REAL | `Rooms.V2.RoomsController.SubRoomData` — current `SubRoomDataSave` for the subroom |
|
||||||
|
| GET | `rooms/{roomId}/subrooms/{subRoomId}/saves` | REAL | `Rooms.V2.RoomsController.SubRoomSaves` — current save + historical blobs, newest first |
|
||||||
|
| GET | `rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}` | REAL | `Rooms.V2.RoomsController.StudioSubRoomSave` — Studio save metadata + baked `unity_assets` bundle list the 2023 client downloads |
|
||||||
| POST | `api/rooms/v2/report` | REAL | `RoomsModerationController.Report` — files a room report |
|
| POST | `api/rooms/v2/report` | REAL | `RoomsModerationController.Report` — files a room report |
|
||||||
| GET | `api/rooms/v1/modrooms` | REAL | `Rooms.V2.RoomsController.MyModerated` (route alias `api/rooms/v1/modrooms`) — rooms the caller moderates |
|
| GET | `api/rooms/v1/modrooms` | REAL | `Rooms.V2.RoomsController.MyModerated` (route alias `api/rooms/v1/modrooms`) — rooms the caller moderates |
|
||||||
| GET | `api/rooms/v2/myrooms` | REAL | `Rooms.V2.RoomsController.MyRooms` |
|
| GET | `api/rooms/v2/myrooms` | REAL | `Rooms.V2.RoomsController.MyRooms` |
|
||||||
@@ -383,6 +388,14 @@ These all live on `match.rec.net` (locally: `match.localhost`).
|
|||||||
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
||||||
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
||||||
|
|
||||||
|
### CDN host (`cdn.rec.net` / `storage` / `data`)
|
||||||
|
|
||||||
|
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
|
||||||
|
|
||||||
|
| Verb | URL | Status | Handler |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET/HEAD | `unity_assets/{assetId}/{target}/{version}` | REAL | `Cdn.CdnController.ServeUnityAsset` — Studio baked asset-bundle download. The 2023 client builds this URL itself from a save's `UnityAssetId`/`Target`/`Version`; resolves the owning `RoomScene` by `StudioUnityAssetId`, picks the matching `{saveId}__bundle_t{target}_v{version}.assetbundle` from `StudioAssetBundleNamesCsv`, streams it from S3. Without it imported Studio geometry never renders |
|
||||||
|
|
||||||
## Catch-all wildcards (informational)
|
## Catch-all wildcards (informational)
|
||||||
|
|
||||||
Routes registered as `[Route("api/<ns>/{*path}")]` in `ApiNamespaceStubsController` (`Controllers/API/Stubs/ApiNamespaceStubsController.cs`):
|
Routes registered as `[Route("api/<ns>/{*path}")]` in `ApiNamespaceStubsController` (`Controllers/API/Stubs/ApiNamespaceStubsController.cs`):
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ public static class DatabaseBootstrap
|
|||||||
await EnsureInventorySchemaAsync(db);
|
await EnsureInventorySchemaAsync(db);
|
||||||
await EnsureLeaderboardSchemaAsync(db);
|
await EnsureLeaderboardSchemaAsync(db);
|
||||||
await EnsureRoomColumnsAsync(db);
|
await EnsureRoomColumnsAsync(db);
|
||||||
|
await EnsureRoomSceneColumnsAsync(db);
|
||||||
await EnsureInventionColumnsAsync(db);
|
await EnsureInventionColumnsAsync(db);
|
||||||
await EnsureMarch2023TablesAsync(db);
|
await EnsureMarch2023TablesAsync(db);
|
||||||
|
|
||||||
@@ -334,6 +335,42 @@ public static class DatabaseBootstrap
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureRoomSceneColumnsAsync(DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""RoomScenes"" ADD COLUMN IF NOT EXISTS ""StudioSubRoomDataSaveId"" bigint NULL;");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""RoomScenes"" ADD COLUMN IF NOT EXISTS ""StudioUnityAssetId"" character varying(64) NOT NULL DEFAULT '';");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""RoomScenes"" ADD COLUMN IF NOT EXISTS ""StudioAssetBundleNamesCsv"" character varying(4096) NOT NULL DEFAULT '';");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""RoomScenes"" ADD COLUMN ""StudioSubRoomDataSaveId"" INTEGER NULL;");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""RoomScenes"" ADD COLUMN ""StudioUnityAssetId"" TEXT NOT NULL DEFAULT '';");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""RoomScenes"" ADD COLUMN ""StudioAssetBundleNamesCsv"" TEXT NOT NULL DEFAULT '';");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
|
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
|
||||||
{
|
{
|
||||||
var provider = db.Database.ProviderName ?? string.Empty;
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
|||||||
Reference in New Issue
Block a user