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); } }