fish-station/Content.Client/_Sunrise/NetTexturesManager.cs

462 lines
17 KiB
C#

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.Network;
using Robust.Shared.Network.Transfer;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client._Sunrise;
/// <summary>
/// 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.
/// </summary>
public sealed class NetTexturesManager
{
/// <summary>
/// Transfer key for server -> client texture downloads via WebSocket
/// </summary>
private const string TransferKeyNetTextures = "TransferKeyNetTextures";
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly IResourceManager _resourceManager = 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 const string UploadedPrefix = "/Uploaded";
private readonly HashSet<string> _requestedResources = new();
private readonly Dictionary<string, ResPath> _pendingResources = new(); // resourcePath -> ResPath
private readonly MemoryContentRoot _netTexturesContentRoot = new();
/// <summary>
/// Event fired when a network texture becomes available.
/// </summary>
public event Action<string>? ResourceLoaded; // resourcePath
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
// Clear resources when disconnecting to ensure fresh state on reconnection
_baseClient.RunLevelChanged += OnRunLevelChanged;
}
/// <summary>
/// Receives NetTextures resources via High Bandwidth Transfer (WebSocket).
/// This doesn't block the main game traffic.
/// </summary>
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}");
}
}
/// <summary>
/// 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.
/// </summary>
private async Task ReadTransferStream(Stream stream, Action<ResPath, byte[]> 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;
}
}
/// <summary>
/// Checks if any pending resources became available after a file was loaded.
/// </summary>
private void CheckPendingResourcesAfterLoad(ResPath loadedPath)
{
var completedResources = new List<string>();
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)
{
if (e.OldLevel == ClientRunLevel.InGame)
{
_sawmill.Debug("Clearing network texture resource tracking on disconnect");
_requestedResources.Clear();
_pendingResources.Clear();
}
}
public void Update(float frameTime)
{
// If there are no pending resources, skip checking
if (_pendingResources.Count == 0)
return;
// Check for loaded resources every frame when there are pending resources
// This ensures we catch resources as soon as they're loaded
var completedResources = new List<string>();
foreach (var (resourcePath, resPath) in _pendingResources)
{
// Check if resource is available
// MemoryContentRoot stores paths relative to /Uploaded prefix
var relativePath = resPath.ToRelativePath();
bool exists = false;
ResPath checkPath;
// For RSI directories, check if all files are present
// This ensures all PNG files are present, not just meta.json
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
// Check if all RSI files are present by reading meta.json and verifying all PNG files exist
exists = CheckRsiFilesComplete(relativePath);
}
else
{
// Single file - check through resource manager
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);
}
}
/// <summary>
/// Checks if a network texture is available, and requests it if not.
/// </summary>
/// <param name="resourcePath">Path to the resource (as specified in prototype, e.g., "/NetTextures/Lobby/Animations/bar.rsi")</param>
/// <returns>True if the resource is available, false if it's being requested</returns>
public bool EnsureResource(string resourcePath)
{
// Normalize the path
ResPath resPath;
if (resourcePath.StartsWith("/"))
{
resPath = new ResPath(resourcePath);
}
else
{
var rootPath = new ResPath("/");
resPath = rootPath / resourcePath;
}
// Check if the resource is actually available
var relativePath = resPath.ToRelativePath();
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
bool isAvailable = false;
// For RSI directories, check for meta.json
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
var metaPath = relativePath / "meta.json";
var metaUploadedPath = (new ResPath(UploadedPrefix) / metaPath).ToRootedPath();
isAvailable = _resourceManager.ContentFileExists(metaUploadedPath);
}
else
{
// Single file
isAvailable = _resourceManager.ContentFileExists(uploadedPath);
}
if (isAvailable)
{
// Resource is available
if (!_requestedResources.Contains(resourcePath))
_requestedResources.Add(resourcePath);
// Remove from pending if it was there
_pendingResources.Remove(resourcePath);
return true;
}
// Resource is not available yet
// If it's already in pending, we're already tracking it
if (_pendingResources.ContainsKey(resourcePath))
{
return false;
}
// If it was requested before but not in pending, add it to pending to track it
if (_requestedResources.Contains(resourcePath))
{
_pendingResources[resourcePath] = resPath;
return false;
}
// Request the resource for the first time
RequestResource(resourcePath);
_pendingResources[resourcePath] = resPath;
// Immediately check if the resource is already available (might be cached or loaded very fast)
// This helps catch resources that load synchronously
CheckResourceImmediately(resourcePath, resPath);
return false;
}
private void RequestResource(string resourcePath)
{
if (_requestedResources.Contains(resourcePath))
return;
// Check if client is connected to server before trying to send message
if (!_netManager.IsConnected)
{
_sawmill.Debug($"Cannot request resource {resourcePath}: client not connected to server");
return;
}
_requestedResources.Add(resourcePath);
var msg = new RequestNetworkResourceMessage
{
ResourcePath = resourcePath
};
_netManager.ClientSendMessage(msg);
}
/// <summary>
/// Immediately checks if a resource is available and fires the event if it is.
/// This helps catch resources that load very quickly.
/// </summary>
private void CheckResourceImmediately(string resourcePath, ResPath resPath)
{
var relativePath = resPath.ToRelativePath();
bool exists = false;
// For RSI directories, check if all files are present
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
// Check if all RSI files are present by reading meta.json and verifying all PNG files exist
exists = CheckRsiFilesComplete(relativePath);
}
else
{
// Single file - check through resource manager
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
exists = _resourceManager.ContentFileExists(uploadedPath);
}
if (exists)
{
if (!_requestedResources.Contains(resourcePath))
_requestedResources.Add(resourcePath);
_pendingResources.Remove(resourcePath);
ResourceLoaded?.Invoke(resourcePath);
}
}
/// <summary>
/// Gets the uploaded path for a network texture.
/// </summary>
/// <param name="resourcePath">Original resource path from prototype</param>
/// <returns>Rooted path to the resource in MemoryContentRoot</returns>
public ResPath GetUploadedPath(string resourcePath)
{
ResPath resPath;
if (resourcePath.StartsWith("/"))
{
resPath = new ResPath(resourcePath);
}
else
{
resPath = new ResPath("/") / resourcePath;
}
var relativePath = resPath.ToRelativePath();
var path = new ResPath(UploadedPrefix) / relativePath;
return path.ToRootedPath(); // Ensure it's always rooted
}
/// <summary>
/// Checks if all RSI files are present by reading meta.json and verifying all PNG files exist.
/// Uses simple string parsing instead of JsonDocument to avoid sandbox restrictions.
/// IMPORTANT: Checks files through VFS (_resourceManager) to ensure they're actually available for ResourceCache.
/// </summary>
private bool CheckRsiFilesComplete(ResPath relativePath)
{
try
{
// Read meta.json from uploaded path (check through VFS)
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
var metaUploadedPath = (uploadedPath / "meta.json").ToRootedPath();
// First check if meta.json exists in VFS
if (!_resourceManager.ContentFileExists(metaUploadedPath))
{
return false;
}
// Try to read meta.json to verify it's accessible
if (!_resourceManager.TryContentFileRead(metaUploadedPath, out var metaStream))
{
return false;
}
using (metaStream)
{
// Read JSON text
using var reader = new StreamReader(metaStream);
var jsonText = reader.ReadToEnd();
// Simple regex to extract state names from JSON
// Matches "name": "statename" patterns
var namePattern = new Regex(@"""name""\s*:\s*""([^""]+)""", RegexOptions.Compiled);
var matches = namePattern.Matches(jsonText);
if (matches.Count == 0)
{
// No states found, might be invalid JSON or empty states array
return false;
}
// Check if all PNG files for each state exist in VFS
foreach (Match match in matches)
{
if (match.Groups.Count < 2)
continue;
var stateName = match.Groups[1].Value;
if (string.IsNullOrEmpty(stateName))
{
continue;
}
// Check if PNG file exists in VFS (not just MemoryContentRoot)
var pngUploadedPath = (uploadedPath / $"{stateName}.png").ToRootedPath();
if (!_resourceManager.ContentFileExists(pngUploadedPath))
{
_sawmill.Debug($"RSI PNG file not yet available in VFS: {pngUploadedPath}");
return false;
}
}
}
return true;
}
catch (Exception ex)
{
_sawmill.Debug($"Error checking RSI files completeness: {ex.Message}");
return false;
}
}
}