diff --git a/Content.Client/_Sunrise/NetTexturesManager.cs b/Content.Client/_Sunrise/NetTexturesManager.cs
index 69ba1a0d57..0d75eb66e5 100644
--- a/Content.Client/_Sunrise/NetTexturesManager.cs
+++ b/Content.Client/_Sunrise/NetTexturesManager.cs
@@ -1,12 +1,16 @@
+using System.Buffers.Binary;
using System.IO;
+using System.Text;
using System.Text.RegularExpressions;
+using System.Threading.Tasks;
using Content.Shared._Sunrise.NetTextures;
using Robust.Client;
using Robust.Client.ResourceManagement;
using Robust.Client.Upload;
+using Robust.Shared.Asynchronous;
using Robust.Shared.ContentPack;
-using Robust.Shared.Log;
using Robust.Shared.Network;
+using Robust.Shared.Network.Transfer;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -14,23 +18,30 @@ namespace Content.Client._Sunrise;
///
/// Manager that handles dynamic loading of network textures from server.
+/// Uses High Bandwidth Transfer (WebSocket) to avoid blocking main game traffic.
/// Textures are loaded into MemoryContentRoot on the client.
///
public sealed class NetTexturesManager
{
+ ///
+ /// Transfer key for server -> client texture downloads via WebSocket
+ ///
+ private const string TransferKeyNetTextures = "TransferKeyNetTextures";
+
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly IResourceManager _resourceManager = default!;
- [Dependency] private readonly IResourceCache _resourceCache = default!;
- [Dependency] private readonly IGameTiming _gameTiming = default!;
- [Dependency] private readonly NetworkResourceManager _networkResourceManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IBaseClient _baseClient = default!;
+ [Dependency] private readonly ITransferManager _transferManager = default!;
+ [Dependency] private readonly ITaskManager _taskManager = default!;
- private ISawmill _sawmill = default!;
+ private ISawmill _sawmill = default!;
- private const string UploadedPrefix = "/Uploaded";
- private readonly HashSet _requestedResources = new();
- private readonly Dictionary _pendingResources = new(); // resourcePath -> ResPath
+ private const string UploadedPrefix = "/Uploaded";
+ private readonly HashSet _requestedResources = new();
+ private readonly Dictionary _pendingResources = new(); // resourcePath -> ResPath
+
+ private readonly MemoryContentRoot _netTexturesContentRoot = new();
///
/// Event fired when a network texture becomes available.
@@ -40,6 +51,10 @@ public sealed class NetTexturesManager
public void Initialize()
{
_sawmill = _logManager.GetSawmill("network.textures");
+ _resourceManager.AddRoot(new ResPath(UploadedPrefix), _netTexturesContentRoot);
+
+ _transferManager.RegisterTransferMessage(TransferKeyNetTextures, ReceiveNetTexturesTransfer);
+
// NetworkResourceUploadMessage is already registered by SharedNetworkResourceManager
// We'll check for loaded resources in Update() method very frequently
@@ -47,10 +62,122 @@ public sealed class NetTexturesManager
_baseClient.RunLevelChanged += OnRunLevelChanged;
}
+ ///
+ /// Receives NetTextures resources via High Bandwidth Transfer (WebSocket).
+ /// This doesn't block the main game traffic.
+ ///
+ private async void ReceiveNetTexturesTransfer(TransferReceivedEvent transfer)
+ {
+ var startTime = DateTime.UtcNow;
+ var fileCount = 0;
+ var totalSize = 0L;
+
+ _sawmill.Debug("[NetTextures] Starting receive via High Bandwidth Transfer!");
+
+ await using var stream = transfer.DataStream;
+
+ try
+ {
+ // Read transfer stream using the same format as SharedNetworkResourceManager
+ // But without IAsyncEnumerable to avoid sandbox violations
+ await ReadTransferStream(stream, (relative, data) =>
+ {
+ fileCount++;
+ totalSize += data.Length;
+ _sawmill.Verbose($"Storing NetTexture: {relative} ({ByteHelpers.FormatBytes(data.Length)})");
+
+ // Store file on main thread (required for MemoryContentRoot)
+ _taskManager.RunOnMainThread(() =>
+ {
+ _netTexturesContentRoot.AddOrUpdateFile(relative, data);
+
+ // Check if any pending resources are now available
+ CheckPendingResourcesAfterLoad(relative);
+ });
+ });
+
+ var totalTime = (DateTime.UtcNow - startTime).TotalMilliseconds;
+ _sawmill.Info($"[NetTextures] Received {fileCount} files ({ByteHelpers.FormatBytes(totalSize)}) via High Bandwidth Transfer in {totalTime:F0}ms");
+ }
+ catch (Exception e)
+ {
+ _sawmill.Error($"Error while receiving NetTextures transfer: {e}");
+ }
+ }
+
+ ///
+ /// Reads the transfer stream format used by SharedNetworkResourceManager.
+ /// Format: [pathLength: uint32][dataLength: uint32][path: bytes][data: bytes][continue: byte]...
+ /// Uses callback instead of IAsyncEnumerable to avoid sandbox violations.
+ ///
+ private async Task ReadTransferStream(Stream stream, Action onFileRead)
+ {
+ var lengthBytes = new byte[4];
+ var continueByte = new byte[1];
+
+ while (true)
+ {
+ await stream.ReadExactlyAsync(lengthBytes);
+ var pathLength = BinaryPrimitives.ReadUInt32LittleEndian(lengthBytes);
+
+ await stream.ReadExactlyAsync(lengthBytes);
+ var dataLength = BinaryPrimitives.ReadUInt32LittleEndian(lengthBytes);
+
+ var pathData = new byte[pathLength];
+ await stream.ReadExactlyAsync(pathData);
+ var data = new byte[dataLength];
+ await stream.ReadExactlyAsync(data);
+
+ var path = new ResPath(Encoding.UTF8.GetString(pathData));
+ onFileRead(path, data);
+
+ await stream.ReadExactlyAsync(continueByte);
+ if (continueByte[0] == 0)
+ break;
+ }
+ }
+
+ ///
+ /// Checks if any pending resources became available after a file was loaded.
+ ///
+ private void CheckPendingResourcesAfterLoad(ResPath loadedPath)
+ {
+ var completedResources = new List();
+
+ foreach (var (resourcePath, resPath) in _pendingResources)
+ {
+ var relativePath = resPath.ToRelativePath();
+ bool exists = false;
+
+ // For RSI directories, check if all files are present
+ var pathStr = relativePath.ToString();
+ if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
+ {
+ exists = CheckRsiFilesComplete(relativePath);
+ }
+ else
+ {
+ // Single file - check through resource manager (our content root is added there)
+ var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
+ exists = _resourceManager.ContentFileExists(uploadedPath);
+ }
+
+ if (exists)
+ {
+ _requestedResources.Add(resourcePath);
+ completedResources.Add(resourcePath);
+ ResourceLoaded?.Invoke(resourcePath);
+ }
+ }
+
+ foreach (var resourcePath in completedResources)
+ {
+ _pendingResources.Remove(resourcePath);
+ }
+ }
+
private void OnRunLevelChanged(object? sender, RunLevelChangedEventArgs e)
{
- // Clear requested and pending resources when disconnecting from a multiplayer game
- // This ensures resources are re-requested on reconnection
if (e.OldLevel == ClientRunLevel.InGame)
{
_sawmill.Debug("Clearing network texture resource tracking on disconnect");
@@ -88,9 +215,9 @@ public sealed class NetTexturesManager
}
else
{
- // Single file
- checkPath = relativePath;
- exists = _networkResourceManager.FileExists(checkPath);
+ // Single file - check through resource manager
+ var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
+ exists = _resourceManager.ContentFileExists(uploadedPath);
}
if (exists)
@@ -139,12 +266,12 @@ public sealed class NetTexturesManager
{
var metaPath = relativePath / "meta.json";
var metaUploadedPath = (new ResPath(UploadedPrefix) / metaPath).ToRootedPath();
- isAvailable = _networkResourceManager.FileExists(metaPath) || _resourceManager.ContentFileExists(metaUploadedPath);
+ isAvailable = _resourceManager.ContentFileExists(metaUploadedPath);
}
else
{
// Single file
- isAvailable = _networkResourceManager.FileExists(relativePath) || _resourceManager.ContentFileExists(uploadedPath);
+ isAvailable = _resourceManager.ContentFileExists(uploadedPath);
}
if (isAvailable)
@@ -223,8 +350,9 @@ public sealed class NetTexturesManager
}
else
{
- // Single file
- exists = _networkResourceManager.FileExists(relativePath);
+ // Single file - check through resource manager
+ var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
+ exists = _resourceManager.ContentFileExists(uploadedPath);
}
if (exists)
diff --git a/Content.Server/_Sunrise/NetTexturesManager.cs b/Content.Server/_Sunrise/NetTexturesManager.cs
index 56ef68f769..18e4a75f21 100644
--- a/Content.Server/_Sunrise/NetTexturesManager.cs
+++ b/Content.Server/_Sunrise/NetTexturesManager.cs
@@ -1,24 +1,37 @@
+using System.Buffers.Binary;
+using System.IO;
using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
using Content.Shared._Sunrise.NetTextures;
using Robust.Server.Player;
using Robust.Shared.ContentPack;
using Robust.Shared.Network;
+using Robust.Shared.Network.Transfer;
using Robust.Shared.Player;
using Robust.Shared.Upload;
using Robust.Shared.Utility;
+using ByteHelpers = Robust.Shared.Utility.ByteHelpers;
namespace Content.Server._Sunrise;
///
/// Manager that handles dynamic loading of network textures from server to client.
+/// Uses High Bandwidth Transfer (WebSocket) to avoid blocking main game traffic.
/// Textures are loaded into MemoryContentRoot on the client.
///
public sealed class NetTexturesManager
{
+ ///
+ /// Transfer key for server -> client texture downloads via WebSocket
+ ///
+ private const string TransferKeyNetTextures = "TransferKeyNetTextures";
+
[Dependency] private readonly IResourceManager _resourceManager = default!;
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
+ [Dependency] private readonly ITransferManager _transferManager = default!;
private ISawmill _sawmill = default!;
private const string AllowedPrefix = "/NetTextures/";
@@ -27,6 +40,9 @@ public sealed class NetTexturesManager
{
_sawmill = _logManager.GetSawmill("network.textures");
_netManager.RegisterNetMessage(OnRequestNetworkResource);
+
+ // Register transfer key for High Bandwidth Transfer (WebSocket)
+ _transferManager.RegisterTransferMessage(TransferKeyNetTextures);
}
private void OnRequestNetworkResource(RequestNetworkResourceMessage msg)
@@ -107,12 +123,18 @@ public sealed class NetTexturesManager
}
///
- /// Sends a resource (file or directory) to the client.
+ /// Sends a resource (file or directory) to the client using High Bandwidth Transfer (WebSocket).
/// If the path points to a directory (e.g., .rsi), all files in that directory are sent.
/// If the path points to a file, only that file is sent.
///
- private void SendResource(ICommonSession session, ResPath resourcePath)
+ private async void SendResource(ICommonSession session, ResPath resourcePath)
{
+ var startTime = DateTime.UtcNow;
+ _sawmill.Debug($"[NetTextures] Starting transfer of {resourcePath} to {session.Name}");
+
+ // Collect all files to send
+ var filesToSend = new List<(ResPath Relative, byte[] Data)>();
+
// Check if it's a directory (RSI files are directories)
// Try to find files in the directory first
var files = _resourceManager.ContentFindFiles(resourcePath).ToList();
@@ -126,11 +148,12 @@ public sealed class NetTexturesManager
return;
}
- SendSingleFile(session, resourcePath);
+ if (!CollectSingleFile(resourcePath, filesToSend))
+ return;
}
else
{
- // Directory - send all files
+ // Directory - collect all files
foreach (var filePath in files)
{
if (!filePath.TryRelativeTo(resourcePath, out var relativePath))
@@ -155,24 +178,47 @@ public sealed class NetTexturesManager
var relativeUploadPath = resourcePath.ToRelativePath();
var uploadedPath = relativeUploadPath / relativePathValue;
- var uploadMsg = new NetworkResourceUploadMessage(data, uploadedPath);
- session.Channel.SendMessage(uploadMsg);
+ filesToSend.Add((uploadedPath, data));
}
}
- _sawmill.Debug($"Sent resource directory {resourcePath} ({files.Count} files) to {session.Name}");
+ _sawmill.Debug($"Collected resource directory {resourcePath} ({files.Count} files) for {session.Name}");
+ }
+
+ // Send via High Bandwidth Transfer (WebSocket) to avoid blocking main game traffic
+ try
+ {
+ var transferStartTime = DateTime.UtcNow;
+ await using var transferStream = _transferManager.StartTransfer(session.Channel,
+ new TransferStartInfo
+ {
+ MessageKey = TransferKeyNetTextures
+ });
+
+ await WriteFileStream(transferStream, filesToSend);
+
+ var totalTime = (DateTime.UtcNow - startTime).TotalMilliseconds;
+ var transferTime = (DateTime.UtcNow - transferStartTime).TotalMilliseconds;
+ var totalSize = filesToSend.Sum(f => f.Data.Length);
+ _sawmill.Info($"[NetTextures] Sent {filesToSend.Count} files ({ByteHelpers.FormatBytes(totalSize)}) via High Bandwidth Transfer to {session.Name} in {transferTime:F0}ms (total: {totalTime:F0}ms)");
+ }
+ catch (Exception ex)
+ {
+ _sawmill.Warning($"Failed to send resource via High Bandwidth Transfer to {session.Name}: {ex.Message}");
+ // Fallback to regular network message if WebSocket transfer fails
+ SendResourceFallback(session, filesToSend);
}
}
///
- /// Sends a single file resource to the client.
+ /// Collects a single file for transfer.
///
- private void SendSingleFile(ICommonSession session, ResPath filePath)
+ private bool CollectSingleFile(ResPath filePath, List<(ResPath Relative, byte[] Data)> filesToSend)
{
if (!_resourceManager.TryContentFileRead(filePath, out var stream))
{
_sawmill.Warning($"Failed to read file: {filePath}");
- return;
+ return false;
}
using (stream)
@@ -182,11 +228,58 @@ public sealed class NetTexturesManager
// Calculate uploaded path: preserve the original path structure relative to Resources root
var relativeUploadPath = filePath.ToRelativePath();
- var uploadMsg = new NetworkResourceUploadMessage(data, relativeUploadPath);
- session.Channel.SendMessage(uploadMsg);
+ filesToSend.Add((relativeUploadPath, data));
}
- _sawmill.Debug($"Sent resource file {filePath} to {session.Name}");
+ return true;
+ }
+
+ ///
+ /// Fallback method: sends resources via regular network messages if WebSocket transfer fails.
+ ///
+ private void SendResourceFallback(ICommonSession session, List<(ResPath Relative, byte[] Data)> files)
+ {
+ foreach (var (relativePath, data) in files)
+ {
+ var uploadMsg = new NetworkResourceUploadMessage(data, relativePath);
+ session.Channel.SendMessage(uploadMsg);
+ }
+ _sawmill.Debug($"Sent {files.Count} files via fallback (regular network) to {session.Name}");
+ }
+
+ ///
+ /// Writes files to a transfer stream using the same format as SharedNetworkResourceManager.
+ /// Format: [pathLength: uint32][dataLength: uint32][path: bytes][data: bytes][continue: byte]...
+ ///
+ private static async Task WriteFileStream(Stream stream, IEnumerable<(ResPath Relative, byte[] Data)> files)
+ {
+ var lengthBytes = new byte[4];
+ var continueByte = new byte[1];
+
+ var first = true;
+
+ foreach (var (relative, data) in files)
+ {
+ if (!first)
+ {
+ continueByte[0] = 1;
+ await stream.WriteAsync(continueByte);
+ }
+
+ first = false;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(lengthBytes, (uint)Encoding.UTF8.GetByteCount(relative.CanonPath));
+ await stream.WriteAsync(lengthBytes);
+
+ BinaryPrimitives.WriteUInt32LittleEndian(lengthBytes, (uint)data.Length);
+ await stream.WriteAsync(lengthBytes);
+
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(relative.CanonPath));
+ await stream.WriteAsync(data);
+ }
+
+ continueByte[0] = 0;
+ await stream.WriteAsync(continueByte);
}
}