Сетевая подгрузка артов, анимаций и паралаксов. (#3646)

This commit is contained in:
Vigers Ray 2026-01-07 06:40:08 +03:00 committed by GitHub
parent 172cac205e
commit 2beed69bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
314 changed files with 1167 additions and 439 deletions

View file

@ -1,5 +1,6 @@
using Content.Client._RMC14.Explosion;
using Content.Client._RMC14.Xenonids.Screech;
using Content.Client._Sunrise;
using Content.Client._Sunrise.Contributors;
using Content.Client._Sunrise.Entry;
using Content.Client._Sunrise.PlayerCache;
@ -88,6 +89,7 @@ namespace Content.Client.Entry
[Dependency] private readonly ServersHubManager _serversHubManager = default!; // Sunrise-Hub
[Dependency] private readonly ContributorsManager _contributorsManager = default!; // Sunrise-Edit
[Dependency] private readonly PlayerCacheManager _playerCacheManager = default!; // Sunrise-Edit
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!; // Sunrise-Edit
public override void PreInit()
{
@ -162,6 +164,7 @@ namespace Content.Client.Entry
_serversHubManager.Initialize(); // Sunrise-Hub
_contributorsManager.Initialize(); // Sunrise-Edit
_playerCacheManager.Initialize(); // Sunrise-Edit
_netTexturesManager.Initialize(); // Sunrise-Edit
// Sunrise-Sponsors-Start
SunriseClientEntry.Init();
@ -262,6 +265,7 @@ namespace Content.Client.Entry
if (level == ModUpdateLevel.FramePreEngine)
{
_debugMonitorManager.FrameUpdate();
_netTexturesManager.Update(frameEventArgs.DeltaSeconds); // Sunrise-Edit
}
if (level == ModUpdateLevel.PreEngine)

View file

@ -1,4 +1,5 @@
using Content.Client._Sunrise.Contributors;
using Content.Client._Sunrise;
using Content.Client._Sunrise.Contributors;
using Content.Client._Sunrise.InteractionsPanel.Models;
using Content.Client._Sunrise.IoC;
using Content.Client._Sunrise.PlayerCache;
@ -72,6 +73,7 @@ namespace Content.Client.IoC
collection.Register<ServersHubManager>();
collection.Register<ContributorsManager>();
collection.Register<PlayerCacheManager>();
collection.Register<NetTexturesManager>();
SunriseClientContentIoC.Register();
collection.Register<CustomInteractionService, CustomInteractionService>(true);
// Sunrise-End

View file

@ -1,4 +1,5 @@
using System.Linq;
using Content.Client._Sunrise;
using Content.Client._Sunrise.Contributors;
using Content.Client._Sunrise.Latejoin;
using Content.Client._Sunrise.ServersHub;
@ -14,9 +15,11 @@ using Content.Shared._Sunrise.Contributors;
using Robust.Client;
using Robust.Client.Console;
using Robust.Client.ResourceManagement;
using Robust.Client.Upload;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.Log;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@ -52,15 +55,24 @@ namespace Content.Client.Lobby
[Dependency] private readonly ContributorsManager _contributorsManager = default!;
[Dependency] private readonly ChangelogManager _changelogManager = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
private ClientGameTicker _gameTicker = default!;
private ContentAudioSystem _contentAudioSystem = default!;
private ISawmill _sawmill = default!;
// Track loaded resources for unloading
private ResPath? _currentAnimationPath;
private ResPath? _currentArtPath;
protected override Type? LinkedScreenType { get; } = typeof(LobbyGui);
public LobbyGui? Lobby;
protected override void Startup()
{
_sawmill = _logManager.GetSawmill("lobby");
if (_userInterfaceManager.ActiveScreen == null)
{
return;
@ -115,6 +127,9 @@ namespace Content.Client.Lobby
_cfg.OnValueChanged(SunriseCCVars.LobbyArt, OnLobbyArtChanged, true);
_cfg.OnValueChanged(SunriseCCVars.LobbyAnimation, OnLobbyAnimationChanged, true);
_cfg.OnValueChanged(SunriseCCVars.LobbyParallax, OnLobbyParallaxChanged, true);
// Subscribe to resource loaded events
_netTexturesManager.ResourceLoaded += OnNetworkResourceLoaded;
// Sunrise-end
Lobby.CharacterPreview.CharacterSetupButton.OnPressed += OnSetupPressed;
@ -146,10 +161,28 @@ namespace Content.Client.Lobby
Lobby!.ReadyButton.OnPressed -= OnReadyPressed;
Lobby!.ReadyButton.OnToggled -= OnReadyToggled;
// Unload lobby resources if CVar is enabled
if (_cfg.GetCVar(SunriseCCVars.LobbyUnloadResources))
{
if (_currentAnimationPath.HasValue)
{
UnloadResource(_currentAnimationPath.Value);
_currentAnimationPath = null;
}
if (_currentArtPath.HasValue)
{
UnloadResource(_currentArtPath.Value);
_currentArtPath = null;
}
}
Lobby = null;
_serversHubManager.ServersDataListChanged -= RefreshServersHubHeader;
_contributorsManager.ContributorsDataListChanged -= RefreshContributorsHeader;
// Unsubscribe from resource loaded events
_netTexturesManager.ResourceLoaded -= OnNetworkResourceLoaded;
}
private void RefreshServersHubHeader(List<ServerHubEntry> servers)
@ -237,9 +270,30 @@ namespace Content.Client.Lobby
{
// Sunrise-Start
UpdateLobbyType();
UpdateLobbyParallax();
UpdateLobbyAnimation();
UpdateLobbyArt();
// Only update the selected background type, not all of them
var backgroundType = _cfg.GetCVar(SunriseCCVars.LobbyBackgroundType);
if (backgroundType == "Random" && _gameTicker.LobbyType != null)
{
backgroundType = _gameTicker.LobbyType;
}
if (!Enum.TryParse(backgroundType, out LobbyBackgroundType lobbyBackgroundType))
{
lobbyBackgroundType = LobbyBackgroundType.Parallax; // Default
}
switch (lobbyBackgroundType)
{
case LobbyBackgroundType.Parallax:
UpdateLobbyParallax();
break;
case LobbyBackgroundType.Art:
UpdateLobbyArt();
break;
case LobbyBackgroundType.Animation:
UpdateLobbyAnimation();
break;
}
// Sunrise-End
UpdateLobbyUi();
}
@ -346,7 +400,7 @@ namespace Content.Client.Lobby
if (Lobby == null)
{
Logger.Error("Error in SetLobbyBackgroundType. Lobby is null");
_sawmill.Error("Error in SetLobbyBackgroundType. Lobby is null");
return;
}
@ -402,41 +456,197 @@ namespace Content.Client.Lobby
private void SetLobbyAnimation(string lobbyAnimation)
{
// Check if animation background type is currently selected
var backgroundType = _cfg.GetCVar(SunriseCCVars.LobbyBackgroundType);
if (backgroundType == "Random" && _gameTicker.LobbyType != null)
{
backgroundType = _gameTicker.LobbyType;
}
if (!Enum.TryParse(backgroundType, out LobbyBackgroundType lobbyBackgroundType) ||
lobbyBackgroundType != LobbyBackgroundType.Animation)
{
// Animation is not the selected background type, don't load it
return;
}
if (!_protoMan.TryIndex<LobbyAnimationPrototype>(lobbyAnimation, out var lobbyAnimationPrototype))
return;
if (Lobby == null)
{
Logger.Error("Error in SetLobbyAnimation. Lobby is null");
_sawmill.Error("Error in SetLobbyAnimation. Lobby is null");
return;
}
// Unload previous animation if CVar is enabled
if (_cfg.GetCVar(SunriseCCVars.LobbyUnloadResources) && _currentAnimationPath.HasValue)
{
UnloadResource(_currentAnimationPath.Value);
}
Lobby!.LobbyAnimation.SetFromSpriteSpecifier(new SpriteSpecifier.Rsi(new ResPath(lobbyAnimationPrototype.Animation), lobbyAnimationPrototype.State));
Lobby!.LobbyAnimation.DisplayRect.TextureScale = lobbyAnimationPrototype.Scale;
var rsiPath = lobbyAnimationPrototype.Animation;
// Check if resource is available, request if not
var isAvailable = _netTexturesManager.EnsureResource(rsiPath);
ResPath targetPath;
if (isAvailable)
{
// Resource is available, use uploaded path
targetPath = _netTexturesManager.GetUploadedPath(rsiPath);
}
else
{
// Resource is being requested, try to use uploaded path first
var uploadedPath = _netTexturesManager.GetUploadedPath(rsiPath);
var metaPath = (uploadedPath / "meta.json").ToRootedPath();
// Check if uploaded resource exists
if (_resource.ContentFileExists(metaPath))
{
targetPath = uploadedPath;
}
else
{
// Resource not available yet, don't try to load it (will cause error)
// The resource will be loaded when it arrives via NetworkResourceUploadMessage
return;
}
}
// Try to set the animation, handle errors gracefully
try
{
// Check if the file actually exists before trying to load it
if (!_resource.ContentFileExists(targetPath))
{
var metaPath = (targetPath / "meta.json").ToRootedPath();
if (!_resource.ContentFileExists(metaPath))
{
return;
}
}
// Try to get the resource - this will load it if not cached
if (_resourceCache.TryGetResource<RSIResource>(targetPath, out var rsiResource))
{
Lobby!.LobbyAnimation.SetFromSpriteSpecifier(new SpriteSpecifier.Rsi(targetPath, lobbyAnimationPrototype.State));
Lobby!.LobbyAnimation.DisplayRect.TextureScale = lobbyAnimationPrototype.Scale;
_currentAnimationPath = targetPath;
}
else
{
_sawmill.Warning($"Failed to load lobby animation RSI: {targetPath}. Resource not found in cache.");
}
}
catch (Exception ex)
{
_sawmill.Warning($"Exception while setting lobby animation {lobbyAnimation}: {ex.Message}");
}
}
private void SetLobbyArt(string lobbyArt)
{
// Check if art background type is currently selected
var backgroundType = _cfg.GetCVar(SunriseCCVars.LobbyBackgroundType);
if (backgroundType == "Random" && _gameTicker.LobbyType != null)
{
backgroundType = _gameTicker.LobbyType;
}
if (!Enum.TryParse(backgroundType, out LobbyBackgroundType lobbyBackgroundType) ||
lobbyBackgroundType != LobbyBackgroundType.Art)
{
// Art is not the selected background type, don't load it
return;
}
if (!_protoMan.TryIndex<LobbyBackgroundPrototype>(lobbyArt, out var lobbyArtPrototype))
return;
if (Lobby == null)
{
Logger.Error("Error in SetLobbyArt. Lobby is null");
_sawmill.Error("Error in SetLobbyArt. Lobby is null");
return;
}
// Unload previous art if CVar is enabled
if (_cfg.GetCVar(SunriseCCVars.LobbyUnloadResources) && _currentArtPath.HasValue)
{
UnloadResource(_currentArtPath.Value);
}
Lobby!.LobbyArt.Texture = _resourceCache.GetResource<TextureResource>(lobbyArtPrototype.Background);
var imagePath = lobbyArtPrototype.Background.ToString();
// Check if resource is available, request if not
var isAvailable = _netTexturesManager.EnsureResource(imagePath);
ResPath targetPath;
if (isAvailable)
{
// Resource is available, use uploaded path
targetPath = _netTexturesManager.GetUploadedPath(imagePath);
}
else
{
// Resource is being requested, try to use uploaded path first
var uploadedPath = _netTexturesManager.GetUploadedPath(imagePath);
// Check if uploaded resource exists
if (_resource.ContentFileExists(uploadedPath))
{
targetPath = uploadedPath;
}
else
{
// Resource not available yet, don't try to load it (will cause error)
// The resource will be loaded when it arrives via NetworkResourceUploadMessage
return;
}
}
// Try to set the art, handle errors gracefully
try
{
if (_resourceCache.TryGetResource<TextureResource>(targetPath, out var textureResource))
{
Lobby!.LobbyArt.Texture = textureResource.Texture;
_currentArtPath = targetPath;
}
else
{
_sawmill.Warning($"Failed to load lobby art texture: {targetPath}");
}
}
catch (Exception ex)
{
_sawmill.Warning($"Exception while setting lobby art {lobbyArt}: {ex.Message}");
}
}
private void SetLobbyParallax(string lobbyParallax)
{
// Check if parallax background type is currently selected
var backgroundType = _cfg.GetCVar(SunriseCCVars.LobbyBackgroundType);
if (backgroundType == "Random" && _gameTicker.LobbyType != null)
{
backgroundType = _gameTicker.LobbyType;
}
if (!Enum.TryParse(backgroundType, out LobbyBackgroundType lobbyBackgroundType) ||
lobbyBackgroundType != LobbyBackgroundType.Parallax)
{
// Parallax is not the selected background type, don't load it
return;
}
if (!_protoMan.TryIndex<LobbyParallaxPrototype>(lobbyParallax, out var lobbyParallaxPrototype))
return;
if (Lobby == null)
{
Logger.Error("Error in SetLobbyParallax. Lobby is null");
_sawmill.Error("Error in SetLobbyParallax. Lobby is null");
return;
}
@ -476,6 +686,111 @@ namespace Content.Client.Lobby
SetLobbyParallax(_gameTicker.LobbyParallax!);
}
private void OnNetworkResourceLoaded(string resourcePath)
{
// Only update the resource that matches the current background type
var backgroundType = _cfg.GetCVar(SunriseCCVars.LobbyBackgroundType);
if (backgroundType == "Random" && _gameTicker.LobbyType != null)
{
backgroundType = _gameTicker.LobbyType;
}
if (!Enum.TryParse(backgroundType, out LobbyBackgroundType lobbyBackgroundType))
{
lobbyBackgroundType = LobbyBackgroundType.Parallax; // Default
}
// Only load the resource for the currently selected background type
switch (lobbyBackgroundType)
{
case LobbyBackgroundType.Animation:
var currentAnimation = _cfg.GetCVar(SunriseCCVars.LobbyAnimation);
if (currentAnimation != null)
{
if (currentAnimation == "Random")
{
// For Random, use the game ticker's selected animation
if (_gameTicker.LobbyAnimation != null)
{
SetLobbyAnimation(_gameTicker.LobbyAnimation);
}
}
else
{
// For specific animation, always try to set it
SetLobbyAnimation(currentAnimation);
}
}
break;
case LobbyBackgroundType.Art:
var currentArt = _cfg.GetCVar(SunriseCCVars.LobbyArt);
if (currentArt != null)
{
var artToSet = currentArt == "Random" ? _gameTicker.LobbyArt : currentArt;
if (artToSet != null)
{
SetLobbyArt(artToSet);
}
}
break;
case LobbyBackgroundType.Parallax:
// Parallax doesn't need network resources, it uses local resources
// But we can update it if needed
var currentParallax = _cfg.GetCVar(SunriseCCVars.LobbyParallax);
if (currentParallax != null)
{
var parallaxToSet = currentParallax == "Random" ? _gameTicker.LobbyParallax : currentParallax;
if (parallaxToSet != null)
{
SetLobbyParallax(parallaxToSet);
}
}
break;
}
}
/// <summary>
/// Unloads a resource from video memory by disposing it.
/// TODO: Full resource unloading from cache is not currently possible due to sandbox restrictions.
/// The ResourceCache does not expose a public API to remove resources from its internal cache.
/// Reflection cannot be used in the client sandbox environment.
/// This method currently only disposes the resource, but it remains in the cache.
/// When the engine provides a proper API for resource cache management, this should be updated.
/// </summary>
private void UnloadResource(ResPath resourcePath)
{
try
{
bool unloaded = false;
// Try to unload RSI resource
if (_resourceCache.TryGetResource<RSIResource>(resourcePath, out var rsiResource))
{
rsiResource.Dispose();
unloaded = true;
_sawmill.Debug($"Disposed RSI resource: {resourcePath} (still in cache due to sandbox limitations)");
}
// Try to unload texture resource
else if (_resourceCache.TryGetResource<TextureResource>(resourcePath, out var textureResource))
{
textureResource.Dispose();
unloaded = true;
_sawmill.Debug($"Disposed texture resource: {resourcePath} (still in cache due to sandbox limitations)");
}
if (!unloaded)
{
_sawmill.Debug($"Resource not found in cache: {resourcePath}");
}
}
catch (Exception ex)
{
_sawmill.Warning($"Failed to unload resource {resourcePath}: {ex.Message}");
}
}
// Sunrise-end
private void SetReady(bool newReady)

View file

@ -13,6 +13,7 @@
<ui:OptionDropDown Name="DropDownLobbyAnimation" Title="{Loc 'ui-options-lobby-animation'}" />
<ui:OptionDropDown Name="DropDownLobbyParallax" Title="{Loc 'ui-options-lobby-parallax'}" />
<ui:OptionSlider Name="LobbyOpacitySlider" Title="{Loc 'ui-options-lobby-opacity'}" />
<CheckBox Name="LobbyUnloadResourcesCheckBox" Text="{Loc 'ui-options-lobby-unload-resources'}" />
<!-- Combat -->
<Label Text="{Loc 'ui-options-sunrise-general-combat'}"

View file

@ -90,6 +90,7 @@ public sealed partial class ExtraTab : Control
Control.AddOptionDropDown(SunriseCCVars.LobbyAnimation, DropDownLobbyAnimation, lobbyAnimations);
Control.AddOptionDropDown(SunriseCCVars.LobbyParallax, DropDownLobbyParallax, lobbyParallaxes);
Control.AddOptionPercentSlider(SunriseCCVars.LobbyOpacity, LobbyOpacitySlider);
Control.AddOptionCheckBox(SunriseCCVars.LobbyUnloadResources, LobbyUnloadResourcesCheckBox);
Control.AddOptionCheckBox(SunriseCCVars.DamageOverlayEnable, DamageOverlayEnableCheckBox);
Control.AddOptionCheckBox(SunriseCCVars.DamageOverlaySelf, DamageOverlaySelfCheckBox);
Control.AddOptionCheckBox(SunriseCCVars.DamageOverlayStructures, DamageOverlayStructuresCheckBox);

View file

@ -1,10 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Content.Client._Sunrise;
using Content.Client.Resources;
using Content.Client.IoC;
using Robust.Client.Graphics;
using Robust.Shared.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.ContentPack;
using Robust.Shared.Network;
using Robust.Shared.Utility;
namespace Content.Client.Parallax.Data;
@ -19,9 +22,102 @@ public sealed partial class ImageParallaxTextureSource : IParallaxTextureSource
[DataField("path", required: true)]
public ResPath Path { get; private set; } = default!;
Task<Texture> IParallaxTextureSource.GenerateTexture(CancellationToken cancel)
async Task<Texture> IParallaxTextureSource.GenerateTexture(CancellationToken cancel)
{
return Task.FromResult(StaticIoC.ResC.GetTexture(Path));
// Check if this is a network texture (starts with /NetTextures/)
var pathStr = Path.ToString();
if (pathStr.StartsWith("/NetTextures/", System.StringComparison.Ordinal))
{
// Use NetTexturesManager for dynamic loading
var netTexturesManager = IoCManager.Resolve<NetTexturesManager>();
var resourceCache = IoCManager.Resolve<IResourceCache>();
var resourceManager = IoCManager.Resolve<IResourceManager>();
var netManager = IoCManager.Resolve<IClientNetManager>();
// If client is not connected to server, fallback to local resources
if (!netManager.IsConnected)
{
// Try to load from local resources as fallback
// Convert /NetTextures/Parallaxes/... to /Textures/Parallaxes/... for local fallback
var fallbackPath = pathStr.Replace("/NetTextures/", "/Textures/");
var fallbackResPath = new ResPath(fallbackPath);
if (resourceManager.ContentFileExists(fallbackResPath))
{
return StaticIoC.ResC.GetTexture(fallbackResPath);
}
// If fallback path doesn't exist, try to use a default texture
// This ensures we always have something to display before connecting
var defaultPath = new ResPath("/Textures/Parallaxes/layer1.png");
if (resourceManager.ContentFileExists(defaultPath))
{
return StaticIoC.ResC.GetTexture(defaultPath);
}
// Last resort: try original path (might fail, but at least we tried)
return StaticIoC.ResC.GetTexture(Path);
}
// Ensure the resource is available
var isAvailable = netTexturesManager.EnsureResource(pathStr);
ResPath targetPath = netTexturesManager.GetUploadedPath(pathStr);
if (!isAvailable)
{
// Resource is being requested, wait for it to load
var tcs = new TaskCompletionSource<bool>();
void OnResourceLoaded(string loadedPath)
{
if (loadedPath == pathStr)
{
netTexturesManager.ResourceLoaded -= OnResourceLoaded;
tcs.TrySetResult(true);
}
}
netTexturesManager.ResourceLoaded += OnResourceLoaded;
try
{
// Also check immediately in case it loads very fast
var relativePath = new ResPath(pathStr).ToRelativePath();
var checkPath = new ResPath("/Uploaded") / relativePath;
if (resourceManager.ContentFileExists(checkPath.ToRootedPath()))
{
netTexturesManager.ResourceLoaded -= OnResourceLoaded;
tcs.TrySetResult(true);
}
else
{
// Wait for the resource to load (with cancellation support)
using (cancel.Register(() =>
{
netTexturesManager.ResourceLoaded -= OnResourceLoaded;
tcs.TrySetCanceled();
}))
{
await tcs.Task;
}
}
}
catch (TaskCanceledException)
{
// Cancellation requested, fallback to original path
return StaticIoC.ResC.GetTexture(Path);
}
}
// Try to get the texture resource
if (resourceCache.TryGetResource<TextureResource>(targetPath, out var textureResource))
{
return textureResource.Texture;
}
// Fallback to original path if network texture loading failed
return StaticIoC.ResC.GetTexture(Path);
}
// For non-network textures, use the original method
return StaticIoC.ResC.GetTexture(Path);
}
}

View file

@ -1,6 +1,5 @@
using System.Numerics;
using Content.Client.Parallax.Data;
using System.Linq; // Sunrise-Edit
using Content.Client.Parallax.Managers;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
@ -8,8 +7,6 @@ using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.ViewVariables;
using Robust.Shared.Prototypes; // Sunrise-Edit
using Content.Shared._Sunrise.Lobby; // Sunrise-Edit
namespace Content.Client.Parallax;
@ -21,7 +18,6 @@ public sealed class ParallaxControl : Control
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IParallaxManager _parallaxManager = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!; // Sunrise-Edit
private string _parallaxPrototype = "FastSpace";
@ -39,9 +35,6 @@ public sealed class ParallaxControl : Control
_parallaxManager.LoadParallaxByName(value);
}
}
[ViewVariables(VVAccess.ReadWrite)] public string CurrentParallax { get; private set; } = "FastSpace"; // Sunrise-Edit
private readonly HashSet<string> _invalidParallaxes = new(); // Sunrise-Edit
public ParallaxControl()
{
@ -50,65 +43,21 @@ public sealed class ParallaxControl : Control
Offset = new Vector2(_random.Next(0, 1000), _random.Next(0, 1000));
RectClipContent = true;
// Sunrise-Edit-Start
SelectRandomParallax();
}
private void SelectRandomParallax()
{
var parallaxes = _prototypeManager.EnumeratePrototypes<LobbyParallaxPrototype>()
.Where(p => !_invalidParallaxes.Contains(p.Parallax))
.ToList();
if (parallaxes.Any())
{
var selectedParallax = _random.Pick(parallaxes);
CurrentParallax = selectedParallax.Parallax;
}
else
{
CurrentParallax = "FastSpace";
}
_parallaxManager.LoadParallaxByName(CurrentParallax);
// Sunrise-Edit-End
_parallaxManager.LoadParallaxByName(_parallaxPrototype);
}
protected override void Draw(DrawingHandleScreen handle)
{
if (Size.X <= 0 || Size.Y <= 0)
return;
// Sunrise-Edit-Start
var layers = _parallaxManager.GetParallaxLayers(CurrentParallax).ToList();
if (!layers.Any())
{
_invalidParallaxes.Add(CurrentParallax);
SelectRandomParallax();
return;
}
var currentTime = (float) _timing.RealTime.TotalSeconds;
var offset = Offset + new Vector2(currentTime * SpeedX, currentTime * SpeedY);
var hasValidLayers = false;
foreach (var layer in layers)
foreach (var layer in _parallaxManager.GetParallaxLayers(_parallaxPrototype))
{
var tex = layer.Texture;
if (tex.Size.X <= 0 || tex.Size.Y <= 0)
continue;
var scale = layer.Config.Scale.Floored();
if (scale.X <= 0 || scale.Y <= 0)
continue;
var texSize = new Vector2i(
(int)(tex.Size.X * Size.X * layer.Config.Scale.X / 1920 * ScaleX),
(int)(tex.Size.Y * Size.X * layer.Config.Scale.Y / 1920 * ScaleY)
);
if (texSize.X <= 0 || texSize.Y <= 0)
continue;
hasValidLayers = true;
var ourSize = PixelSize;
//Protection from division by zero.
@ -117,10 +66,16 @@ public sealed class ParallaxControl : Control
if (layer.Config.Tiled)
{
// Multiply offset by slowness to match normal parallax
var scaledOffset = (offset * layer.Config.Slowness).Floored();
// Then modulo the scaled offset by the size to prevent drawing a bunch of offscreen tiles for really small images.
scaledOffset.X %= texSize.X;
scaledOffset.Y %= texSize.Y;
// Note: scaledOffset must never be below 0 or there will be visual issues.
// It could be allowed to be >= texSize on a given axis but that would be wasteful.
for (var x = -scaledOffset.X; x < ourSize.X; x += texSize.X)
{
for (var y = -scaledOffset.Y; y < ourSize.Y; y += texSize.Y)
@ -135,13 +90,6 @@ public sealed class ParallaxControl : Control
handle.DrawTextureRect(tex, UIBox2.FromDimensions(origin, texSize));
}
}
// Sunrise-Edit-Start
if (!hasValidLayers)
{
_invalidParallaxes.Add(CurrentParallax);
SelectRandomParallax();
}
// Sunrise-Edit-End
}
}

View file

@ -0,0 +1,239 @@
using Content.Shared._Sunrise.NetTextures;
using Robust.Client.Upload;
using Robust.Shared.ContentPack;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client._Sunrise;
/// <summary>
/// Manager that handles dynamic loading of network textures from server.
/// Textures are loaded into MemoryContentRoot on the client.
/// </summary>
public sealed class NetTexturesManager
{
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly IResourceManager _resourceManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly NetworkResourceManager _networkResourceManager = default!;
[Dependency] private readonly ILogManager _logManager = 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
/// <summary>
/// Event fired when a network texture becomes available.
/// </summary>
public event Action<string>? ResourceLoaded; // resourcePath
public void Initialize()
{
_sawmill = _logManager.GetSawmill("network.textures");
// NetworkResourceUploadMessage is already registered by SharedNetworkResourceManager
// We'll check for loaded resources in Update() method very frequently
}
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 for meta.json file
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
checkPath = relativePath / "meta.json";
exists = _networkResourceManager.FileExists(checkPath);
}
else
{
// Single file
checkPath = relativePath;
exists = _networkResourceManager.FileExists(checkPath);
}
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 = _networkResourceManager.FileExists(metaPath) || _resourceManager.ContentFileExists(metaUploadedPath);
}
else
{
// Single file
isAvailable = _networkResourceManager.FileExists(relativePath) || _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 for meta.json file
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
var metaRelativePath = relativePath / "meta.json";
exists = _networkResourceManager.FileExists(metaRelativePath);
}
else
{
// Single file
exists = _networkResourceManager.FileExists(relativePath);
}
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
}
}

View file

@ -85,7 +85,7 @@ public static class ClientPackaging
await RobustClientPackaging.WriteClientResources(
contentDir,
inputPass,
SharedPackaging.AdditionalIgnoredResources,
SharedPackaging.AdditionalIgnoredResources.Union(SharedPackaging.ClientOnlyIgnoredResources).ToHashSet(),
cancel);
inputPass.InjectFinished();

View file

@ -7,4 +7,11 @@ public sealed class SharedPackaging
// MapRenderer outputs into Resources. Avoid these getting included in packaging.
"MapImages",
};
// Sunrise-Start
public static readonly IReadOnlySet<string> ClientOnlyIgnoredResources = new HashSet<string>
{
"NetTextures",
};
// Sunrise-End
}

View file

@ -1,3 +1,4 @@
using Content.Server._Sunrise;
using Content.Server._Sunrise.Contributors;
using Content.Server._Sunrise.Entry;
using Content.Server._Sunrise.PlayerCache;
@ -90,6 +91,7 @@ namespace Content.Server.Entry
[Dependency] private readonly ContributorsManager _contributorsManager = default!; // Sunrise-Edit
[Dependency] private readonly PlayerCacheManager _playerCacheManager = default!; // Sunrise-Edit
[Dependency] private readonly TTSManager _ttsManager = default!; // Sunrise-Edit
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!; // Sunrise-Edit
[Dependency] private readonly DiscordWebhook _discord = default!; // Sunrise-Edit
[Dependency] private readonly IIPBlockingSystem _ipBlockingSystem = default!;
private ISharedSponsorsManager? _sponsorsManager; // Sunrise-Sponsors
@ -146,6 +148,7 @@ namespace Content.Server.Entry
// Sunrise-Start
_ttsManager.Initialize();
_netTexturesManager.Initialize();
_ipBlockingSystem.Initialize();
SunriseServerEntry.Init();
IoCManager.Instance!.TryResolveType(out _sponsorsManager);

View file

@ -1,3 +1,4 @@
using Content.Server._Sunrise;
using Content.Server._Sunrise.Contributors;
using Content.Server._Sunrise.IoC;
using Content.Server._Sunrise.PlayerCache;
@ -93,6 +94,7 @@ internal static class ServerContentIoC
deps.Register<ContributorsManager>();
deps.Register<PlayerCacheManager>();
deps.Register<TTSManager>();
deps.Register<NetTexturesManager>();
deps.Register<IIPBlockingSystem, IPBlockingSystem>();
SunriseServerContentIoC.Register();
// Sunrise-End

View file

@ -0,0 +1,192 @@
using System.Linq;
using Content.Shared._Sunrise.NetTextures;
using Robust.Server.Player;
using Robust.Shared.ContentPack;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Upload;
using Robust.Shared.Utility;
namespace Content.Server._Sunrise;
/// <summary>
/// Manager that handles dynamic loading of network textures from server to client.
/// Textures are loaded into MemoryContentRoot on the client.
/// </summary>
public sealed class NetTexturesManager
{
[Dependency] private readonly IResourceManager _resourceManager = default!;
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
private ISawmill _sawmill = default!;
private const string AllowedPrefix = "/NetTextures/";
public void Initialize()
{
_sawmill = _logManager.GetSawmill("network.textures");
_netManager.RegisterNetMessage<RequestNetworkResourceMessage>(OnRequestNetworkResource);
}
private void OnRequestNetworkResource(RequestNetworkResourceMessage msg)
{
if (!_playerManager.TryGetSessionByChannel(msg.MsgChannel, out var session))
return;
// Normalize the path - ensure it's rooted
var resourcePath = msg.ResourcePath;
ResPath resPath;
if (resourcePath.StartsWith("/"))
{
resPath = new ResPath(resourcePath);
}
else
{
resPath = new ResPath("/") / resourcePath;
}
// Clean the path to remove any .. sequences
resPath = resPath.Clean();
// Validate the path to prevent path traversal attacks
if (!ValidateResourcePath(resPath, out var errorMessage))
{
_sawmill.Warning($"Rejected resource request from {session.Name}: {errorMessage} (path: {msg.ResourcePath})");
return;
}
SendResource(session, resPath);
}
/// <summary>
/// Validates that a resource path is safe and within allowed directories.
/// Prevents path traversal attacks by ensuring paths don't escape allowed directories.
/// </summary>
private bool ValidateResourcePath(ResPath path, out string? errorMessage)
{
errorMessage = null;
// Path must be rooted
if (!path.IsRooted)
{
errorMessage = "Path must be rooted";
return false;
}
// Check for dangerous path traversal sequences in the original string representation
// Even after Clean(), we should verify the path doesn't contain .. in segments
var pathStr = path.ToString();
if (pathStr.Contains("../") || pathStr.Contains("..\\") || pathStr.StartsWith(".."))
{
errorMessage = "Path contains traversal sequences";
return false;
}
// Only allow paths that start with /NetTextures/
// This ensures clients can only access resources from the NetTextures directory
if (!pathStr.StartsWith(AllowedPrefix, StringComparison.Ordinal))
{
errorMessage = $"Path must start with {AllowedPrefix}";
return false;
}
// Additional check: ensure the cleaned path doesn't escape the allowed directory
// by checking that it still starts with the allowed prefix after cleaning
var relativePath = path.ToRelativePath();
var relativePathStr = relativePath.ToString();
if (!relativePathStr.StartsWith("NetTextures/", StringComparison.Ordinal))
{
errorMessage = "Path escapes allowed directory after normalization";
return false;
}
return true;
}
/// <summary>
/// Sends a resource (file or directory) to the client.
/// 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.
/// </summary>
private void SendResource(ICommonSession session, ResPath resourcePath)
{
// Check if it's a directory (RSI files are directories)
// Try to find files in the directory first
var files = _resourceManager.ContentFindFiles(resourcePath).ToList();
if (files.Count == 0)
{
// No files found in directory, try as single file
if (!_resourceManager.ContentFileExists(resourcePath))
{
_sawmill.Warning($"Resource not found: {resourcePath}");
return;
}
SendSingleFile(session, resourcePath);
}
else
{
// Directory - send all files
foreach (var filePath in files)
{
if (!filePath.TryRelativeTo(resourcePath, out var relativePath))
continue;
// relativePath is guaranteed to be non-null here because TryRelativeTo returned true
var relativePathValue = relativePath.Value;
if (!_resourceManager.TryContentFileRead(filePath, out var stream))
{
_sawmill.Warning($"Failed to read file: {filePath}");
continue;
}
using (stream)
{
var data = new byte[stream.Length];
stream.Read(data, 0, data.Length);
// Calculate uploaded path: preserve the original path structure relative to Resources root
// Remove leading / and use as relative path for MemoryContentRoot
var relativeUploadPath = resourcePath.ToRelativePath();
var uploadedPath = relativeUploadPath / relativePathValue;
var uploadMsg = new NetworkResourceUploadMessage(data, uploadedPath);
session.Channel.SendMessage(uploadMsg);
}
}
_sawmill.Debug($"Sent resource directory {resourcePath} ({files.Count} files) to {session.Name}");
}
}
/// <summary>
/// Sends a single file resource to the client.
/// </summary>
private void SendSingleFile(ICommonSession session, ResPath filePath)
{
if (!_resourceManager.TryContentFileRead(filePath, out var stream))
{
_sawmill.Warning($"Failed to read file: {filePath}");
return;
}
using (stream)
{
var data = new byte[stream.Length];
stream.Read(data, 0, data.Length);
// 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);
}
_sawmill.Debug($"Sent resource file {filePath} to {session.Name}");
}
}

View file

@ -0,0 +1,35 @@
using Lidgren.Network;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.NetTextures;
/// <summary>
/// Message sent from client to server to request a network resource by path.
/// The resource will be loaded into MemoryContentRoot on the client.
/// </summary>
public sealed class RequestNetworkResourceMessage : NetMessage
{
public override MsgGroups MsgGroup => MsgGroups.String;
/// <summary>
/// Path to the resource to request (e.g., "/NetTextures/Lobby/Animations/bar.rsi" or "NetTextures/Lobby/Arts/image.webp").
/// Can be absolute (starting with /) or relative to Resources root.
/// </summary>
public string ResourcePath { get; set; } = string.Empty;
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
{
ResourcePath = buffer.ReadString();
}
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
{
buffer.Write(ResourcePath);
}
}

View file

@ -216,6 +216,12 @@ public sealed partial class SunriseCCVars : CVars
public static readonly CVarDef<string> LobbyParallax =
CVarDef.Create("lobby.parallax", "Random", CVar.CLIENTONLY | CVar.ARCHIVE);
/// <summary>
/// Whether to unload lobby resources from video memory when switching backgrounds or entering round.
/// </summary>
public static readonly CVarDef<bool> LobbyUnloadResources =
CVarDef.Create("lobby.unload_resources", true, CVar.CLIENTONLY | CVar.ARCHIVE);
public static readonly CVarDef<float> LobbyOpacity =
CVarDef.Create("lobby.lobby_opacity", 0.90f, CVar.CLIENTONLY | CVar.ARCHIVE);

View file

@ -3,6 +3,7 @@ ui-options-lobby-background-type = Тип фона лобби
ui-options-lobby-art = Арт лобби
ui-options-lobby-animation = Анимация лобби
ui-options-lobby-parallax = Паралакс лобби
ui-options-lobby-unload-resources = Выгружать ресурсы лобби из видеопамяти
ui-options-damage-overlay-enable = Оверлей урона
ui-options-damage-overlay-structures = Показывать урон по структурам
ui-options-damage-overlay-self = Показывать урон по себе

View file

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View file

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

View file

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View file

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View file

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

View file

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

View file

Before

Width:  |  Height:  |  Size: 859 KiB

After

Width:  |  Height:  |  Size: 859 KiB

View file

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

View file

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View file

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

View file

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View file

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View file

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

View file

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

View file

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 351 KiB

View file

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

View file

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

View file

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View file

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Some files were not shown because too many files have changed in this diff Show more