Compare commits

...

44 Commits

Author SHA1 Message Date
PJB3005
0a47ecc5b5 Version: 260.2.4 2025-12-01 16:07:18 +01:00
PJB3005
f694f0e311 Backport BitArray .NET 10 serializer fix
83ad6042a7 & b267cd6fb4

Does not include test code to avoid risking merge conflicts.

(cherry picked from commit 415585a30d74fcae61f581808220a7aaeca3eaf5)
(cherry picked from commit e36628a6d436ea08d6d31441c101a88a5504c515)
2025-12-01 16:07:18 +01:00
PJB3005
cd90cc6e26 Version: 260.2.3 2025-09-26 13:40:44 +02:00
PJB3005
3923dd39ae Validate that content assemblies have a limited list of names.
Also, only read assemblies once from disk

(cherry picked from commit 443a8dfca65be7d60c4bd46181b4c749b4756114)
2025-09-26 13:40:44 +02:00
PJB3005
3caffa04da Version: 260.2.2 2025-09-19 09:17:28 +02:00
Skye
06b377d1d5 Fix resource loading on non-Windows platforms (#6201)
(cherry picked from commit 51bbc5dc45)
2025-09-19 09:17:28 +02:00
PJB3005
41fb191dda Version: 260.2.1 2025-09-14 14:55:52 +02:00
PJB3005
d4bcc1dc05 Squashed commit of the following:
commit d4f265c314
Author: PJB3005 <pieterjan.briers+git@gmail.com>
Date:   Sun Sep 14 14:32:44 2025 +0200

    Fix incorrect path combine in DirLoader and WritableDirProvider

    This (and the other couple past commits) reported by Elelzedel.

commit 7654d38612
Author: PJB3005 <pieterjan.briers+git@gmail.com>
Date:   Sat Sep 13 22:50:51 2025 +0200

    Move CEF cache out of data directory

    Don't want content messing with this...

commit cdcc255123
Author: PJB3005 <pieterjan.briers+git@gmail.com>
Date:   Sat Sep 13 19:11:16 2025 +0200

    Make Robust.Client.WebView.Cef.Program internal.

commit 2f56a6a110
Author: PJB3005 <pieterjan.briers+git@gmail.com>
Date:   Sat Sep 13 19:10:46 2025 +0200

    Update SpaceWizards.NFluidSynth to 0.2.2

commit 16fc48cef2
Author: PJB3005 <pieterjan.briers+git@gmail.com>
Date:   Sat Sep 13 19:09:43 2025 +0200

    Hide IWritableDirProvider.RootDir on client

    This shouldn't be exposed.

(cherry picked from commit 2f07159336bc640e41fbbccfdec4133a68c13bdb)
(cherry picked from commit d6c3212c74373ed2420cc4be2cf10fcd899c2106)
(cherry picked from commit bfa70d7e2ca6758901b680547fcfa9b24e0610b7)
(cherry picked from commit 06e52f5d58efc1491915822c2650f922673c82c6)
2025-09-14 14:55:51 +02:00
metalgearsloth
84dcd658aa Version: 260.2.0 2025-05-21 23:30:58 +10:00
metalgearsloth
a634d6bd04 Add WorldNormal to StartCollideEvent (#5954)
We already have the value just a matter of adding it to the event.
2025-05-21 20:41:57 +10:00
DrSmugleaf
36f9df3079 Add System.Text.StringBuilder Insert(int, string) to sandbox.yml (#5955) 2025-05-21 11:20:10 +02:00
keronshb
824c018a69 Version: 260.1.0 2025-05-19 13:11:32 -04:00
Tayrtahn
4b6b688c72 Cleanup warnings in PlacementManager (#5939) 2025-05-18 19:14:16 +10:00
Tayrtahn
71df25b251 Cleanup warning in Clyde.Sprite (#5940) 2025-05-18 18:51:27 +10:00
metalgearsloth
be14a3c249 Expose CompFactory to systems (#5941) 2025-05-18 00:56:09 -04:00
metalgearsloth
3c2a4d5c79 Version: 260.0.0 2025-05-18 03:07:24 +10:00
metalgearsloth
44180b3ee0 Fix / remove startcollidevent worldpoint (#5936)
Now it's worldpoints because it may not necessarily be 1 pointr and internally we fix the actual points themselves.
2025-05-18 03:03:12 +10:00
metalgearsloth
bb0e77e937 Add some EntProtoId overloads (#5938)
Need it for some content stuff didn't feel like doing the rest yet.
2025-05-17 18:28:12 +10:00
ArtisticRoomba
684b9bc852 Add new Vertical property to progress bars (#5932) 2025-05-17 18:27:44 +10:00
Tayrtahn
9f3db6693e Add SpriteSystem dependency to VisualizerSystem (#5935)
* Add protected SpriteSystem reference to VisualizerSystem

* Capital S
2025-05-17 13:26:40 +10:00
metalgearsloth
40d869948d Version: 259.0.0 2025-05-15 20:26:10 +10:00
Tayrtahn
5c97b15849 Mark Entity methods as readonly (#5919)
* Mark Entity methods as readonly

* Add to GenericEntityPrint

* No but really
2025-05-15 20:23:29 +10:00
Tayrtahn
3d8a9a41fa Combine TileChangedEvents in SetTiles (#5912)
* Combine TileChangedEvents in SetTiles

* Raise event after regenerating collision

* continue, not return

* No need for GetComponent

* Swap TileRef for Tile + Vector2i

* Estimate size of tileChanges
2025-05-15 20:22:05 +10:00
metalgearsloth
92fc8722da Version: 258.0.1 2025-05-15 19:28:25 +10:00
metalgearsloth
73f6555624 Fix static ent collision spawn (#5933)
* Fix static ent collision spawn

* Fix test

* cool
2025-05-15 19:11:20 +10:00
metalgearsloth
2ac7bc3ce4 Version: 258.0.0 2025-05-15 00:51:12 +10:00
Leon Friedrich
05cb4bb1c9 Make SpriteSystem.LayerMapReserve not throw (#5930)
* Make SpriteSystem.LayerMapReserve not throw

* fix SpriteComponent.Visible

* remove region
2025-05-14 23:23:51 +10:00
Leon Friedrich
a393efc87a Modify markup tag interfaces and fix some bugs (#5442)
* Modify markup tag interfaces

* Why are nullable structs like this.

* AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

* Avoid breaking changes

* Replace IMarkupTag with IMarkupTagHandler in engine

* Its a breaking change now I guess

* cleanup
2025-05-12 14:09:18 +10:00
Leon Friedrich
4d47cfa1a6 Minor respath improvements (#5876)
* Minor respath improvements

* Add helpers

* tweak helper

* Throw on more than 1 char

* comments

* No emoji separators
2025-05-12 13:04:40 +10:00
DrSmugleaf
2b1d755d9f Fix Container state handling not forcing inserts (#5916) 2025-05-11 22:45:31 +10:00
ElectroJr
db7de0a99f Version: 257.0.2 2025-05-11 23:47:14 +12:00
Leon Friedrich
47f18703af Fix unshaded sprite layers (#5924)
* Fix unshaded sprite layers

* update comment
2025-05-11 21:42:40 +10:00
Leon Friedrich
97c1548301 Add SpriteBoundsTest (#5922) 2025-05-11 15:30:14 +10:00
ElectroJr
cd97f1583f Version: 257.0.1 2025-05-11 13:56:27 +12:00
Leon Friedrich
5fbe25ec9d Fix sprite layer bounds (#5920) 2025-05-11 11:52:20 +10:00
metalgearsloth
516ee47b51 Version: 257.0.0 2025-05-10 22:12:35 +10:00
metalgearsloth
89be682e24 Don't raise wake events for terminating contacts (#5757) 2025-05-10 22:02:22 +10:00
metalgearsloth
6086076559 Avoid checking grid traversal for rotation events (#5778)
* Avoid checking grid traversal for rotation events

* Also this one
2025-05-10 22:01:43 +10:00
beck-thompson
5bd90c908a Optimization RSI preloading / atlas creation (#5817)
* First commit

* Fix multiatlas bug

* Use ValueList instead

* Add FFDH sorting for atlases

* Minor cleanup and value lists
2025-05-10 22:00:03 +10:00
Leon Friedrich
a3d0921cc9 Pause entities that leave PVS range (#5878)
* Pause entities that leave PVS range

* Fix merge

---------

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
2025-05-10 21:57:04 +10:00
Leon Friedrich
15d5b9aa02 Move parts of SpriteComponent to SpriteSystem (#5602)
* Partial sprite component ECS

* release notes

* tests

* Why

* SetSnapCardinals

* NoRotation

* DirectionOverride

* This is why I love distinct overrides that take in object

* LayerSetData

* ISerializationHooks continue to haunt me

* Relocate SetShader

* LayerSetSprite

* LayerSetTexture

* yipeeeee

* LayerSetRsi

* Remove GetFallbackState

* LayerSet Scale,Rotation,Color,Visible

* Fix LayerSetRsi

* LayerSetOffset

* LayerSetDirOffset

* Add overrides that take in a Layer

* LayerSetAnimationTime

* LayerSetRenderingStrategy

* Reduce Resolves, Add Layer.Index

* Access

* Try fix NREs

* Asserts

* LayerGetState

* Cleanup

* Merge helper partial classes

* partial rendering

* GetLayerDirectionCount

* Cache local bounds

* RenderLayer

* RefreshCachedState

* RoundToCardinalAngle

* Fix the pr

* Fix debug assert

---------

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
2025-05-10 21:56:53 +10:00
Leon Friedrich
d24854d94f Improve yaml validation errors for ignored prototypes (#5886)
* Improve yaml validation errors for ignored prototypes

* release notes

* Comments
2025-05-10 21:37:42 +10:00
Tayrtahn
b3cf427013 Catch NotYamlSerializable DataFields with analyzer (#5704)
* Catch NotYamlSerializable DataFields with analyzer

* Extract common defs into shared source
2025-05-10 21:36:33 +10:00
Leon Friedrich
c458abdc69 Move TestPair & PoolManager to engine (#5877)
* Engine pool manager

* Move documentation

* Move namespace

* Move TestMapData to engine

* Option to prevent loading test assembly

* release notes

* Rename to avoid conflicts
2025-05-10 21:35:28 +10:00
109 changed files with 5676 additions and 1681 deletions

View File

@@ -57,7 +57,7 @@
<PackageVersion Include="SharpZstd.Interop" Version="1.5.2-beta2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageVersion Include="SpaceWizards.HttpListener" Version="0.1.1" />
<PackageVersion Include="SpaceWizards.NFluidsynth" Version="0.1.1" />
<PackageVersion Include="SpaceWizards.NFluidsynth" Version="0.2.2" />
<PackageVersion Include="SpaceWizards.SharpFont" Version="1.0.2" />
<PackageVersion Include="SpaceWizards.Sodium" Version="0.2.1" />
<PackageVersion Include="TerraFX.Interop.Windows" Version="10.0.26100.1" />

View File

@@ -1,4 +1,4 @@
<Project>
<!-- This file automatically reset by Tools/version.py -->
<!-- This file automatically reset by Tools/version.py -->

View File

@@ -54,6 +54,128 @@ END TEMPLATE-->
*None yet*
## 260.2.4
## 260.2.3
## 260.2.2
## 260.2.1
## 260.2.0
### New features
* Add `StringBuilder.Insert(int, string)` to sandbox.
* Add the WorldNormal to the StartCollideEvent.
## 260.1.0
### New features
* `ComponentFactory` is now exposed to `EntitySystem` as `Factory`
### Other
* Cleanup warnings in PLacementManager
* Cleanup warnings in Clide.Sprite
## 260.0.0
### Breaking changes
* Fix / change `StartCollideEvent.WorldPoint` to return all points for the collision which may be up to 2 instead of 1.
### New features
* Add SpriteSystem dependency to VisualizerSystem.
* Add Vertical property to progress bars
* Add some `EntProtoId` overloads for group entity spawn methods.
## 259.0.0
### Breaking changes
* TileChangedEvent now has an array of tile changed entries rather than raising an individual event for every single tile changed.
### Other
* `Entity<T>` methods were marked as `readonly` as appropriate.
## 258.0.1
### Bugfixes
* Fix static physics bodies not generating contacts if they spawn onto sleeping bodies.
## 258.0.0
### Breaking changes
* `IMarkupTag` and related methods in `MarkupTagManager` have been obsoleted and should be replaced with the new `IMarkupTagHandler` interface. Various engine tags (e.g., `BoldTag`, `ColorTag`, etc) no longer implement the old interface.
### New features
* Add IsValidPath to ResPath and make some minor performance improvements.
### Bugfixes
* OutputPanel and RichTextLabel now remove controls associated with rich text tags when the text is updated.
* Fix `SpriteComponent.Visible` datafield not being read from yaml.
* Fix container state handling not forcing inserts.
### Other
* `SpriteSystem.LayerMapReserve()` no longer throws an exception if the specified layer already exists. This makes it behave like the obsoleted `SpriteComponent.LayerMapReserveBlank()`.
## 257.0.2
### Bugfixes
* Fix unshaded sprite layers not rendering correctly.
## 257.0.1
### Bugfixes
* Fix sprite layer bounding box calculations. This was causing various sprite rendering & render-tree lookup issues.
## 257.0.0
### Breaking changes
* The client will now automatically pause any entities that leave their PVS range.
* Contacts for terminating entities no longer raise wake events.
### New features
* Added `IPrototypeManager.IsIgnored()` for checking whether a given prototype kind has been marked as ignored via `RegisterIgnore()`.
* Added `PoolManager` & `TestPair` classes to `Robust.UnitTesting`. These classes make it easier to create & use pooled server/client instance pairs in integration tests.
* Catch NotYamlSerializable DataFields with an analyzer.
* Optimized RSI preloading and texture atlas creation.
### Bugfixes
* Fix clients unintentionally un-pausing paused entities that re-enter pvs range
### Other
* The yaml prototype id serialiser now provides better feedback when trying to validate an id for a prototype kind that has been ignored via `IPrototypeManager.RegisterIgnore()`
* Several SpriteComponent methods have been marked as obsolete, and should be replaced with new methods in SpriteSystem.
* Rotation events no longer check for grid traversal.
## 256.0.0
### Breaking changes

View File

@@ -21,47 +21,53 @@ public sealed class DataDefinitionAnalyzerTest
},
};
test.TestState.Sources.Add(("TestTypeDefs.cs", TestTypeDefs));
// ExpectedDiagnostics cannot be set, so we need to AddRange here...
test.TestState.ExpectedDiagnostics.AddRange(expected);
return test.RunAsync();
}
private const string TestTypeDefs = """
using System;
namespace Robust.Shared.ViewVariables
{
public sealed class ViewVariablesAttribute : Attribute
{
public readonly VVAccess Access = VVAccess.ReadOnly;
public ViewVariablesAttribute() { }
public ViewVariablesAttribute(VVAccess access)
{
Access = access;
}
}
public enum VVAccess : byte
{
ReadOnly = 0,
ReadWrite = 1,
}
}
namespace Robust.Shared.Serialization.Manager.Attributes
{
public class DataFieldBaseAttribute : Attribute;
public class DataFieldAttribute : DataFieldBaseAttribute;
public sealed class DataDefinitionAttribute : Attribute;
public sealed class NotYamlSerializableAttribute : Attribute;
}
""";
[Test]
public async Task Test()
public async Task NoVVReadOnlyTest()
{
const string code = """
using System;
using Robust.Shared.ViewVariables;
using Robust.Shared.Serialization.Manager.Attributes;
namespace Robust.Shared.ViewVariables
{
public sealed class ViewVariablesAttribute : Attribute
{
public readonly VVAccess Access = VVAccess.ReadOnly;
public ViewVariablesAttribute() { }
public ViewVariablesAttribute(VVAccess access)
{
Access = access;
}
}
public enum VVAccess : byte
{
ReadOnly = 0,
ReadWrite = 1,
}
}
namespace Robust.Shared.Serialization.Manager.Attributes
{
public class DataFieldBaseAttribute : Attribute;
public class DataFieldAttribute : DataFieldBaseAttribute;
public sealed class DataDefinitionAttribute : Attribute;
}
[DataDefinition]
public sealed partial class Foo
{
@@ -83,8 +89,8 @@ public sealed class DataDefinitionAnalyzerTest
""";
await Verifier(code,
// /0/Test0.cs(35,17): info RA0028: Data field Bad in data definition Foo has ViewVariables attribute with ReadWrite access, which is redundant
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldNoVVReadWriteRule).WithSpan(35, 17, 35, 50).WithArguments("Bad", "Foo")
// /0/Test0.cs(7,17): info RA0028: Data field Bad in data definition Foo has ViewVariables attribute with ReadWrite access, which is redundant
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldNoVVReadWriteRule).WithSpan(7, 17, 7, 50).WithArguments("Bad", "Foo")
);
}
@@ -92,16 +98,8 @@ public sealed class DataDefinitionAnalyzerTest
public async Task ReadOnlyFieldTest()
{
const string code = """
using System;
using Robust.Shared.Serialization.Manager.Attributes;
namespace Robust.Shared.Serialization.Manager.Attributes
{
public class DataFieldBaseAttribute : Attribute;
public class DataFieldAttribute : DataFieldBaseAttribute;
public sealed class DataDefinitionAttribute : Attribute;
}
[DataDefinition]
public sealed partial class Foo
{
@@ -114,8 +112,8 @@ public sealed class DataDefinitionAnalyzerTest
""";
await Verifier(code,
// /0/Test0.cs(15,12): error RA0019: Data field Bad in data definition Foo is readonly
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldWritableRule).WithSpan(15, 12, 15, 20).WithArguments("Bad", "Foo")
// /0/Test0.cs(7,12): error RA0019: Data field Bad in data definition Foo is readonly
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldWritableRule).WithSpan(7, 12, 7, 20).WithArguments("Bad", "Foo")
);
}
@@ -123,16 +121,8 @@ public sealed class DataDefinitionAnalyzerTest
public async Task ReadOnlyPropertyTest()
{
const string code = """
using System;
using Robust.Shared.Serialization.Manager.Attributes;
namespace Robust.Shared.Serialization.Manager.Attributes
{
public class DataFieldBaseAttribute : Attribute;
public class DataFieldAttribute : DataFieldBaseAttribute;
public sealed class DataDefinitionAttribute : Attribute;
}
[DataDefinition]
public sealed partial class Foo
{
@@ -145,8 +135,40 @@ public sealed class DataDefinitionAnalyzerTest
""";
await Verifier(code,
// /0/Test0.cs(15,20): error RA0020: Data field property Bad in data definition Foo does not have a setter
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldPropertyWritableRule).WithSpan(15, 20, 15, 28).WithArguments("Bad", "Foo")
// /0/Test0.cs(7,20): error RA0020: Data field property Bad in data definition Foo does not have a setter
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldPropertyWritableRule).WithSpan(7, 20, 7, 28).WithArguments("Bad", "Foo")
);
}
[Test]
public async Task NotYamlSerializableTest()
{
const string code = """
using Robust.Shared.Serialization.Manager.Attributes;
[NotYamlSerializable]
public sealed class NotSerializableClass { }
[DataDefinition]
public sealed partial class Foo
{
[DataField]
public NotSerializableClass BadField;
[DataField]
public NotSerializableClass BadProperty { get; set; }
public NotSerializableClass GoodField; // Not a DataField, not a problem
public NotSerializableClass GoodProperty { get; set; } // Not a DataField, not a problem
}
""";
await Verifier(code,
// /0/Test0.cs(10,12): error RA0033: Data field BadField in data definition Foo is type NotSerializableClass, which is not YAML serializable
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldYamlSerializableRule).WithSpan(10, 12, 10, 32).WithArguments("BadField", "Foo", "NotSerializableClass"),
// /0/Test0.cs(13,12): error RA0033: Data field BadProperty in data definition Foo is type NotSerializableClass, which is not YAML serializable
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldYamlSerializableRule).WithSpan(13, 12, 13, 32).WithArguments("BadProperty", "Foo", "NotSerializableClass")
);
}
}

View File

@@ -18,6 +18,7 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
private const string ImplicitDataDefinitionNamespace = "Robust.Shared.Serialization.Manager.Attributes.ImplicitDataDefinitionForInheritorsAttribute";
private const string DataFieldBaseNamespace = "Robust.Shared.Serialization.Manager.Attributes.DataFieldBaseAttribute";
private const string ViewVariablesNamespace = "Robust.Shared.ViewVariables.ViewVariablesAttribute";
private const string NotYamlSerializableName = "Robust.Shared.Serialization.Manager.Attributes.NotYamlSerializableAttribute";
private const string DataFieldAttributeName = "DataField";
private const string ViewVariablesAttributeName = "ViewVariables";
@@ -81,9 +82,19 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
"Make sure to remove the ViewVariables attribute."
);
public static readonly DiagnosticDescriptor DataFieldYamlSerializableRule = new(
Diagnostics.IdDataFieldYamlSerializable,
"Data field type is not YAML serializable",
"Data field {0} in data definition {1} is type {2}, which is not YAML serializable",
"Usage",
DiagnosticSeverity.Error,
true,
"Make sure to use a type that is YAML serializable."
);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(
DataDefinitionPartialRule, NestedDataDefinitionPartialRule, DataFieldWritableRule, DataFieldPropertyWritableRule,
DataFieldRedundantTagRule, DataFieldNoVVReadWriteRule
DataFieldRedundantTagRule, DataFieldNoVVReadWriteRule, DataFieldYamlSerializableRule
);
public override void Initialize(AnalysisContext context)
@@ -164,6 +175,19 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
TryGetAttributeLocation(field, ViewVariablesAttributeName, out var location);
context.ReportDiagnostic(Diagnostic.Create(DataFieldNoVVReadWriteRule, location, fieldSymbol.Name, type.Name));
}
if (context.SemanticModel.GetSymbolInfo(field.Declaration.Type).Symbol is not ITypeSymbol fieldTypeSymbol)
continue;
if (IsNotYamlSerializable(fieldSymbol, fieldTypeSymbol))
{
context.ReportDiagnostic(Diagnostic.Create(DataFieldYamlSerializableRule,
(context.Node as FieldDeclarationSyntax)?.Declaration.Type.GetLocation(),
fieldSymbol.Name,
type.Name,
fieldTypeSymbol.MetadataName
));
}
}
}
@@ -201,6 +225,19 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
TryGetAttributeLocation(property, ViewVariablesAttributeName, out var location);
context.ReportDiagnostic(Diagnostic.Create(DataFieldNoVVReadWriteRule, location, propertySymbol.Name, type.Name));
}
if (context.SemanticModel.GetSymbolInfo(property.Type).Symbol is not ITypeSymbol propertyTypeSymbol)
return;
if (IsNotYamlSerializable(propertySymbol, propertyTypeSymbol))
{
context.ReportDiagnostic(Diagnostic.Create(DataFieldYamlSerializableRule,
(context.Node as PropertyDeclarationSyntax)?.Type.GetLocation(),
propertySymbol.Name,
type.Name,
propertyTypeSymbol.Name
));
}
}
private static bool IsReadOnlyDataField(ITypeSymbol type, ISymbol field)
@@ -383,6 +420,14 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
return (VVAccess)accessByte == VVAccess.ReadWrite;
}
private static bool IsNotYamlSerializable(ISymbol field, ITypeSymbol type)
{
if (!IsDataField(field, out _, out _))
return false;
return HasAttribute(type, NotYamlSerializableName);
}
private static bool IsImplicitDataDefinition(ITypeSymbol type)
{
if (HasAttribute(type, ImplicitDataDefinitionNamespace))

View File

@@ -6,7 +6,7 @@ using Xilium.CefGlue;
namespace Robust.Client.WebView.Cef
{
public static class Program
internal static class Program
{
// This was supposed to be the main entry for the subprocess program... It doesn't work.
public static int Main(string[] args)

View File

@@ -5,6 +5,7 @@ using System.Net;
using System.Reflection;
using System.Text;
using Robust.Client.Console;
using Robust.Client.Utility;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.IoC;
@@ -24,6 +25,7 @@ namespace Robust.Client.WebView.Cef
[Dependency] private readonly IDependencyCollection _dependencyCollection = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IGameControllerInternal _gameController = default!;
[Dependency] private readonly IResourceManagerInternal _resourceManager = default!;
[Dependency] private readonly IClientConsoleHost _consoleHost = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
@@ -61,7 +63,10 @@ namespace Robust.Client.WebView.Cef
var cachePath = "";
if (_resourceManager.UserData is WritableDirProvider userData)
cachePath = userData.GetFullPath(new ResPath("/cef_cache"));
{
var rootDir = UserDataDir.GetRootUserDataDir(_gameController);
cachePath = Path.Combine(rootDir, "cef_cache", "0");
}
var settings = new CefSettings()
{

View File

@@ -2,6 +2,7 @@ using System.Numerics;
using Robust.Client.GameObjects;
using Robust.Shared.ComponentTrees;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Physics;
@@ -9,25 +10,7 @@ namespace Robust.Client.ComponentTrees;
public sealed class SpriteTreeSystem : ComponentTreeSystem<SpriteTreeComponent, SpriteComponent>
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<SpriteComponent, QueueSpriteTreeUpdateEvent>(OnQueueUpdate);
}
private void OnQueueUpdate(EntityUid uid, SpriteComponent component, ref QueueSpriteTreeUpdateEvent args)
=> QueueTreeUpdate(uid, component, args.Xform);
// TODO remove this when finally ECSing sprite components
[ByRefEvent]
internal readonly struct QueueSpriteTreeUpdateEvent
{
public readonly TransformComponent Xform;
public QueueSpriteTreeUpdateEvent(TransformComponent xform)
{
Xform = xform;
}
}
[Dependency] private readonly SpriteSystem _sprite = default!;
#region Component Tree Overrides
protected override bool DoFrameUpdate => true;
@@ -36,6 +19,11 @@ public sealed class SpriteTreeSystem : ComponentTreeSystem<SpriteTreeComponent,
protected override int InitialCapacity => 1024;
protected override Box2 ExtractAabb(in ComponentTreeEntry<SpriteComponent> entry, Vector2 pos, Angle rot)
=> entry.Component.CalculateRotatedBoundingBox(pos, rot, default).CalcBoundingBox();
{
// TODO SPRITE optimize this
// Because the just take the BB of the rotated BB, I'mt pretty sure we do a lot of unnecessary maths.
return _sprite.CalculateBounds((entry.Uid, entry.Component), pos, rot, default).CalcBoundingBox();
}
#endregion
}

View File

@@ -387,7 +387,7 @@ namespace Robust.Client
_prof.Initialize();
_resManager.Initialize(Options.LoadConfigAndUserData ? userDataDir : null);
_resManager.Initialize(Options.LoadConfigAndUserData ? userDataDir : null, hideUserDataDir: true);
var mountOptions = _commandLineArgs != null
? MountOptions.Merge(_commandLineArgs.MountOptions, Options.MountOptions)

View File

@@ -1,9 +1,11 @@
using Robust.Shared.GameObjects;
using System;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Maths;
namespace Robust.Client.GameObjects
{
[Obsolete]
public partial interface IRenderableComponent : IComponent
{
int DrawDepth { get; set; }

View File

@@ -1,6 +1,5 @@
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Shared.Graphics;
using Robust.Shared.Graphics.RSI;
using Robust.Shared.Maths;

View File

@@ -1,15 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Robust.Shared.Collections;
using Robust.Shared.Containers;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.IoC;
using Robust.Shared.Map;
using Robust.Shared.Utility;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Robust.Shared.Serialization;
using Robust.Shared.Utility;
using static Robust.Shared.Containers.ContainerManagerComponent;
namespace Robust.Client.GameObjects
@@ -58,7 +57,7 @@ namespace Robust.Client.GameObjects
if (!RemoveExpectedEntity(meta.NetEntity, out var container))
return;
Insert((uid, TransformQuery.GetComponent(uid), MetaQuery.GetComponent(uid), null), container);
Insert((uid, TransformQuery.GetComponent(uid), MetaQuery.GetComponent(uid), null), container, force: true);
}
public override void ShutdownContainer(BaseContainer container)
@@ -232,7 +231,7 @@ namespace Robust.Client.GameObjects
return;
}
Insert(message.Entity, container);
Insert(message.Entity, container, force: true);
}
public void AddExpectedEntity(NetEntity netEntity, BaseContainer container)

View File

@@ -0,0 +1,149 @@
using System;
using System.Linq;
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Shared.GameObjects;
using Robust.Shared.Graphics.RSI;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using static Robust.Client.GameObjects.SpriteComponent;
namespace Robust.Client.GameObjects;
// This partial class contains code related to updating a sprites bounding boxes and its position in the sprite tree.
public sealed partial class SpriteSystem
{
/// <summary>
/// Get a sprite's local bounding box. The returned bounds do factor in the sprite's scale but not the rotation or
/// offset.
/// </summary>
public Box2 GetLocalBounds(Entity<SpriteComponent> sprite)
{
if (!sprite.Comp.BoundsDirty)
{
DebugTools.Assert(sprite.Comp.Layers.All(x => !x.BoundsDirty || !x.Drawn));
return sprite.Comp._bounds;
}
var bounds = new Box2();
foreach (var layer in sprite.Comp.Layers)
{
if (layer.Drawn)
bounds = bounds.Union(GetLocalBounds(layer));
}
sprite.Comp._bounds = bounds;
sprite.Comp.BoundsDirty = false;
return sprite.Comp._bounds;
}
/// <summary>
/// Get a layer's local bounding box relative to its owning sprite. Unlike the sprite variant of this method, this
/// does account for the layer's rotation and offset.
/// </summary>
public Box2 GetLocalBounds(Layer layer)
{
if (!layer.BoundsDirty)
{
DebugTools.Assert(layer.Bounds.EqualsApprox(CalculateLocalBounds(layer)));
return layer.Bounds;
}
layer.Bounds = CalculateLocalBounds(layer);
layer.BoundsDirty = false;
return layer.Bounds;
}
internal Box2 CalculateLocalBounds(Layer layer)
{
var textureSize = (Vector2) layer.PixelSize / EyeManager.PixelsPerMeter;
var longestSide = MathF.Max(textureSize.X, textureSize.Y);
var longestRotatedSide = Math.Max(longestSide, (textureSize.X + textureSize.Y) / MathF.Sqrt(2));
Vector2 size;
var sprite = layer.Owner.Comp;
// If this layer has any form of arbitrary rotation, return a bounding box big enough to cover
// any possible rotation.
if (layer._rotation != 0)
{
size = new Vector2(longestRotatedSide, longestRotatedSide);
return Box2.CenteredAround(layer.Offset, size * layer._scale);
}
var snapToCardinals = sprite.SnapCardinals;
if (sprite.GranularLayersRendering && layer.RenderingStrategy != LayerRenderingStrategy.UseSpriteStrategy)
{
snapToCardinals = layer.RenderingStrategy == LayerRenderingStrategy.SnapToCardinals;
}
if (snapToCardinals)
{
// Snapping to cardinals only makes sense for 1-directional layers/sprites
DebugTools.Assert(layer._actualState == null || layer._actualState.RsiDirections == RsiDirectionType.Dir1);
// We won't know the actual direction it snaps to, so we ahve to assume the box is given by the longest side.
size = new Vector2(longestSide, longestSide);
return Box2.CenteredAround(layer.Offset, size * layer._scale);
}
// Build the bounding box based on how many directions the sprite has
size = (layer._actualState?.RsiDirections) switch
{
RsiDirectionType.Dir4 => new Vector2(longestSide, longestSide),
RsiDirectionType.Dir8 => new Vector2(longestRotatedSide, longestRotatedSide),
_ => textureSize
};
return Box2.CenteredAround(layer.Offset, size * layer._scale);
}
/// <summary>
/// Gets a sprite's bounding box in world coordinates.
/// </summary>
public Box2Rotated CalculateBounds(Entity<SpriteComponent> sprite, Vector2 worldPos, Angle worldRot, Angle eyeRot)
{
// fast check for invisible sprites
if (!sprite.Comp.Visible || sprite.Comp.Layers.Count == 0)
return new Box2Rotated(new Box2(worldPos, worldPos), Angle.Zero, worldPos);
// We need to modify world rotation so that it lies between 0 and 2pi.
// This matters for 4 or 8 directional sprites deciding which quadrant (octant?) they lie in.
// the 0->2pi convention is set by the sprite-rendering code that selects the layers.
// See RenderInternal().
worldRot = worldRot.Reduced();
if (worldRot.Theta < 0)
worldRot = new Angle(worldRot.Theta + Math.Tau);
// Next, what we do is take the box2 and apply the sprite's transform, and then the entity's transform. We
// could do this via Matrix3.TransformBox, but that only yields bounding boxes. So instead we manually
// transform our box by the combination of these matrices:
var finalRotation = sprite.Comp.NoRotation
? sprite.Comp.Rotation - eyeRot
: sprite.Comp.Rotation + worldRot;
var bounds = GetLocalBounds(sprite);
// slightly faster path if offset == 0 (true for 99.9% of sprites)
if (sprite.Comp.Offset == Vector2.Zero)
return new Box2Rotated(bounds.Translated(worldPos), finalRotation, worldPos);
var adjustedOffset = sprite.Comp.NoRotation
? (-eyeRot).RotateVec(sprite.Comp.Offset)
: worldRot.RotateVec(sprite.Comp.Offset);
var position = adjustedOffset + worldPos;
return new Box2Rotated(bounds.Translated(position), finalRotation, position);
}
private void DirtyBounds(Entity<SpriteComponent> sprite)
{
sprite.Comp.BoundsDirty = true;
foreach (var layer in sprite.Comp.Layers)
{
layer.BoundsDirty = true;
}
}
}

View File

@@ -1,4 +1,8 @@
using System.Linq;
using System;
using System.Collections.Generic;
using Robust.Shared.GameObjects;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
namespace Robust.Client.GameObjects;
@@ -41,4 +45,66 @@ public sealed partial class SpriteSystem
layer.AnimationTimeLeft = (float) -(time % state.TotalDelay);
layer.AnimationFrame = 0;
}
public void CopySprite(Entity<SpriteComponent?> source, Entity<SpriteComponent?> target)
{
if (!Resolve(source.Owner, ref source.Comp))
return;
if (!Resolve(target.Owner, ref target.Comp))
return;
target.Comp._baseRsi = source.Comp._baseRsi;
target.Comp._bounds = source.Comp._bounds;
target.Comp._visible = source.Comp._visible;
target.Comp.color = source.Comp.color;
target.Comp.offset = source.Comp.offset;
target.Comp.rotation = source.Comp.rotation;
target.Comp.scale = source.Comp.scale;
target.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
in target.Comp.offset,
in target.Comp.rotation,
in target
.Comp.scale);
target.Comp.drawDepth = source.Comp.drawDepth;
target.Comp.NoRotation = source.Comp.NoRotation;
target.Comp.DirectionOverride = source.Comp.DirectionOverride;
target.Comp.EnableDirectionOverride = source.Comp.EnableDirectionOverride;
target.Comp.Layers = new List<SpriteComponent.Layer>(source.Comp.Layers.Count);
foreach (var otherLayer in source.Comp.Layers)
{
var layer = new SpriteComponent.Layer(otherLayer, target.Comp);
layer.Index = target.Comp.Layers.Count;
layer.Owner = target!;
target.Comp.Layers.Add(layer);
}
target.Comp.IsInert = source.Comp.IsInert;
target.Comp.LayerMap = source.Comp.LayerMap.ShallowClone();
target.Comp.PostShader = source.Comp.PostShader is {Mutable: true}
? source.Comp.PostShader.Duplicate()
: source.Comp.PostShader;
target.Comp.RenderOrder = source.Comp.RenderOrder;
target.Comp.GranularLayersRendering = source.Comp.GranularLayersRendering;
DirtyBounds(target!);
_tree.QueueTreeUpdate(target!);
}
/// <summary>
/// Adds a sprite to a queue that will update <see cref="SpriteComponent.IsInert"/> next frame.
/// </summary>
public void QueueUpdateIsInert(Entity<SpriteComponent> sprite)
{
if (sprite.Comp._inertUpdateQueued)
return;
sprite.Comp._inertUpdateQueued = true;
_inertUpdateQueue.Enqueue(sprite);
}
[Obsolete("Use QueueUpdateIsInert")]
public void QueueUpdateInert(EntityUid uid, SpriteComponent sprite) => QueueUpdateIsInert(new (uid, sprite));
}

View File

@@ -1,11 +1,153 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Robust.Client.GameObjects;
// This partial class contains various public helper methods, including methods for extracting textures/icons from
// sprite specifiers and entity prototypes.
public sealed partial class SpriteSystem
{
private readonly Dictionary<string, IRsiStateLike> _cachedPrototypeIcons = new();
public Texture Frame0(EntityPrototype prototype)
{
return GetPrototypeIcon(prototype).Default;
}
public Texture Frame0(SpriteSpecifier specifier)
{
return RsiStateLike(specifier).Default;
}
public IRsiStateLike RsiStateLike(SpriteSpecifier specifier)
{
switch (specifier)
{
case SpriteSpecifier.Texture tex:
return GetTexture(tex);
case SpriteSpecifier.Rsi rsi:
return GetState(rsi);
case SpriteSpecifier.EntityPrototype prototypeIcon:
return GetPrototypeIcon(prototypeIcon.EntityPrototypeId);
default:
throw new NotSupportedException();
}
}
public Texture GetIcon(IconComponent icon)
{
return GetState(icon.Icon).Frame0;
}
/// <summary>
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
/// This method caches the result based on the prototype identifier.
/// </summary>
public IRsiStateLike GetPrototypeIcon(string prototype)
{
// Check if this prototype has been cached before, and if so return the result.
if (_cachedPrototypeIcons.TryGetValue(prototype, out var cachedResult))
return cachedResult;
if (!_proto.TryIndex<EntityPrototype>(prototype, out var entityPrototype))
{
// The specified prototype doesn't exist, return the fallback "error" sprite.
_sawmill.Error("Failed to load PrototypeIcon {0}", prototype);
return GetFallbackState();
}
// Generate the icon and cache it in case it's ever needed again.
var result = GetPrototypeIcon(entityPrototype);
_cachedPrototypeIcons[prototype] = result;
return result;
}
/// <summary>
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
/// This method does NOT cache the result.
/// </summary>
public IRsiStateLike GetPrototypeIcon(EntityPrototype prototype)
{
// IconComponent takes precedence. If it has a valid icon, return that. Otherwise, continue as normal.
if (prototype.Components.TryGetValue("Icon", out var compData)
&& compData.Component is IconComponent icon)
{
return GetIcon(icon);
}
// If the prototype doesn't have a SpriteComponent, then there's nothing we can do but return the fallback.
if (!prototype.Components.ContainsKey("Sprite"))
{
return GetFallbackState();
}
// Finally, we use spawn a dummy entity to get its icon.
var dummy = Spawn(prototype.ID, MapCoordinates.Nullspace);
var spriteComponent = EnsureComp<SpriteComponent>(dummy);
var result = spriteComponent.Icon ?? GetFallbackState();
Del(dummy);
return result;
}
[Pure]
public RSI.State GetFallbackState()
{
return _resourceCache.GetFallback<RSIResource>().RSI["error"];
}
public Texture GetFallbackTexture()
{
return _resourceCache.GetFallback<TextureResource>().Texture;
}
[Pure]
public RSI.State GetState(SpriteSpecifier.Rsi rsiSpecifier)
{
if (_resourceCache.TryGetResource<RSIResource>(
TextureRoot / rsiSpecifier.RsiPath,
out var theRsi) &&
theRsi.RSI.TryGetState(rsiSpecifier.RsiState, out var state))
{
return state;
}
_sawmill.Error("Failed to load RSI {0}", rsiSpecifier.RsiPath);
return GetFallbackState();
}
public Texture GetTexture(SpriteSpecifier.Texture texSpecifier)
{
return _resourceCache
.GetResource<TextureResource>(TextureRoot / texSpecifier.TexturePath)
.Texture;
}
private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
{
if (!args.TryGetModified<EntityPrototype>(out var modified))
return;
// Remove all changed prototypes from the cache, if they're there.
foreach (var prototype in modified)
{
// Let's be lazy and not regenerate them until something needs them again.
_cachedPrototypeIcons.Remove(prototype);
}
}
/// <summary>
/// Gets an entity's sprite position in world terms.
/// </summary>

View File

@@ -0,0 +1,257 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.GameObjects;
using Robust.Shared.Utility;
using static Robust.Client.GameObjects.SpriteComponent;
namespace Robust.Client.GameObjects;
// This partial class contains various public methods for managing a sprite's layers.
// This setter methods for modifying a layer's properties are in a separate file.
public sealed partial class SpriteSystem
{
public bool LayerExists(Entity<SpriteComponent?> sprite, int index)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return false;
return index > 0 && index < sprite.Comp.Layers.Count;
}
public bool TryGetLayer(
Entity<SpriteComponent?> sprite,
int index,
[NotNullWhen(true)] out Layer? layer,
bool logMissing)
{
layer = null;
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
if (index >= 0 && index < sprite.Comp.Layers.Count)
{
layer = sprite.Comp.Layers[index];
DebugTools.AssertEqual(layer.Owner, sprite!);
DebugTools.AssertEqual(layer.Index, index);
return true;
}
if (logMissing)
Log.Error($"Layer index '{index}' on entity {ToPrettyString(sprite)} does not exist! Trace:\n{Environment.StackTrace}");
return false;
}
public bool RemoveLayer(Entity<SpriteComponent?> sprite, int index, bool logMissing = true)
{
return RemoveLayer(sprite.Owner, index, out _, logMissing);
}
public bool RemoveLayer(
Entity<SpriteComponent?> sprite,
int index,
[NotNullWhen(true)] out Layer? layer,
bool logMissing = true)
{
layer = null;
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
if (!TryGetLayer(sprite, index, out layer, logMissing))
return false;
sprite.Comp.Layers.RemoveAt(index);
foreach (var otherLayer in sprite.Comp.Layers[index..])
{
otherLayer.Index--;
}
// TODO SPRITE track inverse-mapping?
foreach (var (key, value) in sprite.Comp.LayerMap)
{
if (value == index)
sprite.Comp.LayerMap.Remove(key);
else if (value > index)
{
sprite.Comp.LayerMap[key]--;
}
}
layer.Owner = default;
layer.Index = -1;
#if DEBUG
foreach (var otherLayer in sprite.Comp.Layers)
{
DebugTools.AssertEqual(otherLayer, sprite.Comp.Layers[otherLayer.Index]);
}
#endif
sprite.Comp.BoundsDirty = true;
_tree.QueueTreeUpdate(sprite!);
QueueUpdateIsInert(sprite!);
return true;
}
#region AddLayer
/// <summary>
/// Add the given sprite layer. If an index is specified, this will insert the layer with the given index, resulting
/// in all other layers being reshuffled.
/// </summary>
public int AddLayer(Entity<SpriteComponent?> sprite, Layer layer, int? index = null)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
{
layer.Index = -1;
layer.Owner = default;
return -1;
}
layer.Owner = sprite!;
if (index is { } i && i != sprite.Comp.Layers.Count)
{
foreach (var otherLayer in sprite.Comp.Layers[i..])
{
otherLayer.Index++;
}
// TODO SPRITE track inverse-mapping?
sprite.Comp.Layers.Insert(i, layer);
layer.Index = i;
foreach (var (key, value) in sprite.Comp.LayerMap)
{
if (value >= i)
sprite.Comp.LayerMap[key]++;
}
}
else
{
layer.Index = sprite.Comp.Layers.Count;
sprite.Comp.Layers.Add(layer);
}
#if DEBUG
foreach (var otherLayer in sprite.Comp.Layers)
{
DebugTools.AssertEqual(otherLayer, sprite.Comp.Layers[otherLayer.Index]);
}
#endif
layer.BoundsDirty = true;
if (!layer.Blank)
{
sprite.Comp.BoundsDirty = true;
_tree.QueueTreeUpdate(sprite!);
QueueUpdateIsInert(sprite!);
}
return layer.Index;
}
/// <summary>
/// Add a layer corresponding to the given RSI state.
/// </summary>
/// <param name="sprite">The sprite</param>
/// <param name="stateId">The RSI state</param>
/// <param name="rsi">The RSI to use. If not specified, it will default to using <see cref="SpriteComponent.BaseRSI"/></param>
/// <param name="index">The layer index to use for the new sprite.</param>
/// <returns></returns>
public int AddRsiLayer(Entity<SpriteComponent?> sprite, RSI.StateId stateId, RSI? rsi = null, int? index = null)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
var layer = AddBlankLayer(sprite!, index);
if (rsi != null)
LayerSetRsi(layer, rsi, stateId);
else
LayerSetRsiState(layer, stateId);
return layer.Index;
}
/// <summary>
/// Add a layer corresponding to the given RSI state.
/// </summary>
/// <param name="sprite">The sprite</param>
/// <param name="state">The RSI state</param>
/// <param name="path">The path to the RSI.</param>
/// <param name="index">The layer index to use for the new sprite.</param>
/// <returns></returns>
public int AddRsiLayer(Entity<SpriteComponent?> sprite, RSI.StateId state, ResPath path, int? index = null)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
if (!_resourceCache.TryGetResource<RSIResource>(TextureRoot / path, out var res))
Log.Error($"Unable to load RSI '{path}'. Trace:\n{Environment.StackTrace}");
if (path.Extension != "rsi")
Log.Error($"Expected rsi path but got '{path}'?");
return AddRsiLayer(sprite, state, res?.RSI, index);
}
public int AddTextureLayer(Entity<SpriteComponent?> sprite, ResPath path, int? index = null)
{
if (_resourceCache.TryGetResource<TextureResource>(TextureRoot / path, out var texture))
return AddTextureLayer(sprite, texture?.Texture, index);
if (path.Extension == "rsi")
Log.Error($"Expected texture but got rsi '{path}', did you mean 'sprite:' instead of 'texture:'?");
Log.Error($"Unable to load texture '{path}'. Trace:\n{Environment.StackTrace}");
return AddTextureLayer(sprite, texture?.Texture, index);
}
public int AddTextureLayer(Entity<SpriteComponent?> sprite, Texture? texture, int? index = null)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
var layer = new Layer {Texture = texture};
return AddLayer(sprite, layer, index);
}
public int AddLayer(Entity<SpriteComponent?> sprite, SpriteSpecifier specifier, int? newIndex = null)
{
return specifier switch
{
SpriteSpecifier.Texture tex => AddTextureLayer(sprite, tex.TexturePath, newIndex),
SpriteSpecifier.Rsi rsi => AddRsiLayer(sprite, rsi.RsiState, rsi.RsiPath, newIndex),
_ => throw new NotImplementedException()
};
}
/// <summary>
/// Add a new sprite layer and populate it using the provided layer data.
/// </summary>
public int AddLayer(Entity<SpriteComponent?> sprite, PrototypeLayerData layerDatum, int? index)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
var layer = AddBlankLayer(sprite!, index);
LayerSetData(layer, layerDatum);
return layer.Index;
}
/// <summary>
/// Add a blank sprite layer.
/// </summary>
public Layer AddBlankLayer(Entity<SpriteComponent> sprite, int? index = null)
{
var layer = new Layer();
AddLayer(sprite!, layer, index);
return layer;
}
#endregion
}

View File

@@ -0,0 +1,150 @@
using System;
using Robust.Client.Graphics;
using Robust.Shared.GameObjects;
using Robust.Shared.Graphics.RSI;
using static Robust.Client.GameObjects.SpriteComponent;
using static Robust.Client.Graphics.RSI;
namespace Robust.Client.GameObjects;
// This partial class contains various public methods for reading a layer's properties
public sealed partial class SpriteSystem
{
#region RsiState
/// <summary>
/// Get the RSI state being used by the current layer. Note that the return value may be an invalid state. E.g.,
/// this might be a texture layer that does not use RSIs.
/// </summary>
public StateId LayerGetRsiState(Entity<SpriteComponent?> sprite, int index)
{
if (TryGetLayer(sprite, index, out var layer, true))
return layer.StateId;
return StateId.Invalid;
}
/// <summary>
/// Get the RSI state being used by the current layer. Note that the return value may be an invalid state. E.g.,
/// this might be a texture layer that does not use RSIs.
/// </summary>
public StateId LayerGetRsiState(Entity<SpriteComponent?> sprite, string key, StateId state)
{
if (TryGetLayer(sprite, key, out var layer, true))
return layer.StateId;
return StateId.Invalid;
}
/// <summary>
/// Get the RSI state being used by the current layer. Note that the return value may be an invalid state. E.g.,
/// this might be a texture layer that does not use RSIs.
/// </summary>
public StateId LayerGetRsiState(Entity<SpriteComponent?> sprite, Enum key, StateId state)
{
if (TryGetLayer(sprite, key, out var layer, true))
return layer.StateId;
return StateId.Invalid;
}
#endregion
#region RsiState
/// <summary>
/// Returns the RSI being used by the layer to resolve it's RSI state. If the layer does not specify an RSI, this
/// will just be the base RSI of the owning sprite (<see cref="SpriteComponent.BaseRSI"/>).
/// </summary>
public RSI? LayerGetEffectiveRsi(Entity<SpriteComponent?> sprite, int index)
{
TryGetLayer(sprite, index, out var layer, true);
return layer?.ActualRsi;
}
/// <summary>
/// Returns the RSI being used by the layer to resolve it's RSI state. If the layer does not specify an RSI, this
/// will just be the base RSI of the owning sprite (<see cref="SpriteComponent.BaseRSI"/>).
/// </summary>
public RSI? LayerGetEffectiveRsi(Entity<SpriteComponent?> sprite, string key, StateId state)
{
TryGetLayer(sprite, key, out var layer, true);
return layer?.ActualRsi;
}
/// <summary>
/// Returns the RSI being used by the layer to resolve it's RSI state. If the layer does not specify an RSI, this
/// will just be the base RSI of the owning sprite (<see cref="SpriteComponent.BaseRSI"/>).
/// </summary>
public RSI? LayerGetEffectiveRsi(Entity<SpriteComponent?> sprite, Enum key, StateId state)
{
TryGetLayer(sprite, key, out var layer, true);
return layer?.ActualRsi;
}
#endregion
#region Directions
public RsiDirectionType LayerGetDirections(Entity<SpriteComponent?> sprite, int index)
{
return TryGetLayer(sprite, index, out var layer, true)
? LayerGetDirections(layer)
: RsiDirectionType.Dir1;
}
public RsiDirectionType LayerGetDirections(Entity<SpriteComponent?> sprite, Enum key)
{
return TryGetLayer(sprite, key, out var layer, true)
? LayerGetDirections(layer)
: RsiDirectionType.Dir1;
}
public RsiDirectionType LayerGetDirections(Entity<SpriteComponent?> sprite, string key)
{
return TryGetLayer(sprite, key, out var layer, true)
? LayerGetDirections(layer)
: RsiDirectionType.Dir1;
}
public RsiDirectionType LayerGetDirections(Layer layer)
{
if (!layer.StateId.IsValid)
return RsiDirectionType.Dir1;
// Pull texture from RSI state instead.
if (layer.ActualRsi is not {} rsi || !rsi.TryGetState(layer.StateId, out var state))
return RsiDirectionType.Dir1;
return state.RsiDirections;
}
public int LayerGetDirectionCount(Entity<SpriteComponent?> sprite, int index)
{
return TryGetLayer(sprite, index, out var layer, true) ? LayerGetDirectionCount(layer) : 1;
}
public int LayerGetDirectionCount(Entity<SpriteComponent?> sprite, Enum key)
{
return TryGetLayer(sprite, key, out var layer, true) ? LayerGetDirectionCount(layer) : 1;
}
public int LayerGetDirectionCount(Entity<SpriteComponent?> sprite, string key)
{
return TryGetLayer(sprite, key, out var layer, true) ? LayerGetDirectionCount(layer) : 1;
}
public int LayerGetDirectionCount(Layer layer)
{
return LayerGetDirections(layer) switch
{
RsiDirectionType.Dir1 => 1,
RsiDirectionType.Dir4 => 4,
RsiDirectionType.Dir8 => 8,
_ => throw new ArgumentOutOfRangeException()
};
}
#endregion
}

View File

@@ -0,0 +1,299 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Robust.Shared.GameObjects;
using static Robust.Client.GameObjects.SpriteComponent;
namespace Robust.Client.GameObjects;
// This partial class contains various public methods for manipulating layer mappings.
public sealed partial class SpriteSystem
{
/// <summary>
/// Map an enum to a layer index.
/// </summary>
public void LayerMapSet(Entity<SpriteComponent?> sprite, Enum key, int index)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (index < 0 || index >= sprite.Comp.Layers.Count)
throw new ArgumentOutOfRangeException(nameof(index));
sprite.Comp.LayerMap[key] = index;
}
/// <summary>
/// Map string to a layer index. If possible, it is preferred to use an enum key.
/// string keys mainly exist to make it easier to define custom layer keys in yaml.
/// </summary>
public void LayerMapSet(Entity<SpriteComponent?> sprite, string key, int index)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (index < 0 || index >= sprite.Comp.Layers.Count)
throw new ArgumentOutOfRangeException(nameof(index));
sprite.Comp.LayerMap[key] = index;
}
/// <summary>
/// Map an enum to a layer index.
/// </summary>
public void LayerMapAdd(Entity<SpriteComponent?> sprite, Enum key, int index)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (index < 0 || index >= sprite.Comp.Layers.Count)
throw new ArgumentOutOfRangeException(nameof(index));
sprite.Comp.LayerMap.Add(key, index);
}
/// <summary>
/// Map a string to a layer index. If possible, it is preferred to use an enum key.
/// string keys mainly exist to make it easier to define custom layer keys in yaml.
/// </summary>
public void LayerMapAdd(Entity<SpriteComponent?> sprite, string key, int index)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (index < 0 || index >= sprite.Comp.Layers.Count)
throw new ArgumentOutOfRangeException(nameof(index));
sprite.Comp.LayerMap.Add(key, index);
}
/// <summary>
/// Remove an enum mapping.
/// </summary>
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, Enum key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return false;
return sprite.Comp.LayerMap.Remove(key);
}
/// <summary>
/// Remove a string mapping.
/// </summary>
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, string key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return false;
return sprite.Comp.LayerMap.Remove(key);
}
/// <summary>
/// Remove an enum mapping.
/// </summary>
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, Enum key, out int index)
{
if (_query.Resolve(sprite.Owner, ref sprite.Comp))
return sprite.Comp.LayerMap.Remove(key, out index);
index = 0;
return false;
}
/// <summary>
/// Remove a string mapping.
/// </summary>
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, string key, out int index)
{
if (_query.Resolve(sprite.Owner, ref sprite.Comp))
return sprite.Comp.LayerMap.Remove(key, out index);
index = 0;
return false;
}
/// <summary>
/// Attempt to resolve an enum mapping.
/// </summary>
public bool LayerMapTryGet(Entity<SpriteComponent?> sprite, Enum key, out int index, bool logMissing)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
{
index = 0;
return false;
}
if (sprite.Comp.LayerMap.TryGetValue(key, out index))
return true;
if (logMissing)
Log.Error($"Layer with key '{key}' does not exist on entity {ToPrettyString(sprite)}! Trace:\n{Environment.StackTrace}");
return false;
}
/// <summary>
/// Attempt to resolve a string mapping.
/// </summary>
public bool LayerMapTryGet(Entity<SpriteComponent?> sprite, string key, out int index, bool logMissing)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
{
index = 0;
return false;
}
if (sprite.Comp.LayerMap.TryGetValue(key, out index))
return true;
if (logMissing)
Log.Error($"Layer with key '{key}' does not exist on entity {ToPrettyString(sprite)}! Trace:\n{Environment.StackTrace}");
return false;
}
/// <summary>
/// Attempt to resolve an enum mapping.
/// </summary>
public bool TryGetLayer(Entity<SpriteComponent?> sprite, Enum key, [NotNullWhen(true)] out Layer? layer, bool logMissing)
{
layer = null;
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
return LayerMapTryGet(sprite, key, out var index, logMissing)
&& TryGetLayer(sprite, index, out layer, logMissing);
}
/// <summary>
/// Attempt to resolve a string mapping.
/// </summary>
public bool TryGetLayer(Entity<SpriteComponent?> sprite, string key, [NotNullWhen(true)] out Layer? layer, bool logMissing)
{
layer = null;
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
return LayerMapTryGet(sprite, key, out var index, logMissing)
&& TryGetLayer(sprite, index, out layer, logMissing);
}
public int LayerMapGet(Entity<SpriteComponent?> sprite, Enum key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
return sprite.Comp.LayerMap[key];
}
public int LayerMapGet(Entity<SpriteComponent?> sprite, string key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
return sprite.Comp.LayerMap[key];
}
public bool LayerExists(Entity<SpriteComponent?> sprite, string key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return false;
return sprite.Comp.LayerMap.TryGetValue(key, out var index)
&& LayerExists(sprite, index);
}
public bool LayerExists(Entity<SpriteComponent?> sprite, Enum key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return false;
return sprite.Comp.LayerMap.TryGetValue(key, out var index)
&& LayerExists(sprite, index);
}
/// <summary>
/// Ensures that a layer with the given key exists and return the layer's index.
/// If the layer does not yet exist, this will create and add a blank layer.
/// </summary>
public int LayerMapReserve(Entity<SpriteComponent?> sprite, Enum key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
if (LayerMapTryGet(sprite, key, out var layerIndex, false))
return layerIndex;
var layer = AddBlankLayer(sprite!);
LayerMapSet(sprite, key, layer.Index);
return layer.Index;
}
/// <inheritdoc cref="LayerMapReserve(Entity{SpriteComponent?},System.Enum)"/>
public int LayerMapReserve(Entity<SpriteComponent?> sprite, string key)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return -1;
if (LayerMapTryGet(sprite, key, out var layerIndex, false))
return layerIndex;
var layer = AddBlankLayer(sprite!);
LayerMapSet(sprite, key, layer.Index);
return layer.Index;
}
public bool RemoveLayer(Entity<SpriteComponent?> sprite, string key, bool logMissing = true)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
return false;
return RemoveLayer(sprite, index, logMissing);
}
public bool RemoveLayer(Entity<SpriteComponent?> sprite, Enum key, bool logMissing = true)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
return false;
return RemoveLayer(sprite, index, logMissing);
}
public bool RemoveLayer(
Entity<SpriteComponent?> sprite,
string key,
[NotNullWhen(true)] out Layer? layer,
bool logMissing = true)
{
layer = null;
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
return false;
return RemoveLayer(sprite, index, out layer, logMissing);
}
public bool RemoveLayer(
Entity<SpriteComponent?> sprite,
Enum key,
[NotNullWhen(true)] out Layer? layer,
bool logMissing = true)
{
layer = null;
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
return false;
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
return false;
return RemoveLayer(sprite, index, out layer, logMissing);
}
}

View File

@@ -0,0 +1,605 @@
using System;
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.GameObjects;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using static Robust.Client.GameObjects.SpriteComponent;
using static Robust.Client.Graphics.RSI;
#pragma warning disable CS0618 // Type or member is obsolete
namespace Robust.Client.GameObjects;
// This partial class contains various public methods for modifying a layer's properties.
public sealed partial class SpriteSystem
{
#region SetData
public void LayerSetData(Entity<SpriteComponent?> sprite, int index, PrototypeLayerData data)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetData(layer, data);
}
public void LayerSetData(Entity<SpriteComponent?> sprite, string key, PrototypeLayerData data)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetData(layer, data);
}
public void LayerSetData(Entity<SpriteComponent?> sprite, Enum key, PrototypeLayerData data)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetData(layer, data);
}
public void LayerSetData(Layer layer, PrototypeLayerData data)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
// TODO SPRITE ECS
layer._parent.LayerSetData(layer, data);
}
#endregion
#region SpriteSpecifier
public void LayerSetSprite(Entity<SpriteComponent?> sprite, int index, SpriteSpecifier specifier)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetSprite(layer, specifier);
}
public void LayerSetSprite(Entity<SpriteComponent?> sprite, string key, SpriteSpecifier specifier)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetSprite(layer, specifier);
}
public void LayerSetSprite(Entity<SpriteComponent?> sprite, Enum key, SpriteSpecifier specifier)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetSprite(layer, specifier);
}
public void LayerSetSprite(Layer layer, SpriteSpecifier specifier)
{
switch (specifier)
{
case SpriteSpecifier.Texture tex:
LayerSetTexture(layer, tex.TexturePath);
break;
case SpriteSpecifier.Rsi rsi:
LayerSetRsi(layer, rsi.RsiPath, rsi.RsiState);
break;
default:
throw new NotImplementedException();
}
}
#endregion
#region Texture
public void LayerSetTexture(Entity<SpriteComponent?> sprite, int index, Texture? texture)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetTexture(layer, texture);
}
public void LayerSetTexture(Entity<SpriteComponent?> sprite, string key, Texture? texture)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetTexture(layer, texture);
}
public void LayerSetTexture(Entity<SpriteComponent?> sprite, Enum key, Texture? texture)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetTexture(layer, texture);
}
public void LayerSetTexture(Layer layer, Texture? texture)
{
LayerSetRsiState(layer, StateId.Invalid, refresh: true);
layer.Texture = texture;
}
public void LayerSetTexture(Entity<SpriteComponent?> sprite, int index, ResPath path)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetTexture(layer, path);
}
public void LayerSetTexture(Entity<SpriteComponent?> sprite, string key, ResPath path)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetTexture(layer, path);
}
public void LayerSetTexture(Entity<SpriteComponent?> sprite, Enum key, ResPath path)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetTexture(layer, path);
}
private void LayerSetTexture(Layer layer, ResPath path)
{
if (!_resourceCache.TryGetResource<TextureResource>(TextureRoot / path, out var texture))
{
if (path.Extension == "rsi")
Log.Error($"Expected texture but got rsi '{path}', did you mean 'sprite:' instead of 'texture:'?");
Log.Error($"Unable to load texture '{path}'. Trace:\n{Environment.StackTrace}");
}
LayerSetTexture(layer, texture?.Texture);
}
#endregion
#region RsiState
public void LayerSetRsiState(Entity<SpriteComponent?> sprite, int index, StateId state)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetRsiState(layer, state);
}
public void LayerSetRsiState(Entity<SpriteComponent?> sprite, string key, StateId state)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRsiState(layer, state);
}
public void LayerSetRsiState(Entity<SpriteComponent?> sprite, Enum key, StateId state)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRsiState(layer, state);
}
public void LayerSetRsiState(Layer layer, StateId state, bool refresh = false)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (layer.StateId == state && !refresh)
return;
layer.StateId = state;
RefreshCachedState(layer, true, null);
_tree.QueueTreeUpdate(layer.Owner);
QueueUpdateIsInert(layer.Owner);
layer.BoundsDirty = true;
layer.Owner.Comp.BoundsDirty = true;
}
#endregion
#region Rsi
public void LayerSetRsi(Entity<SpriteComponent?> sprite, int index, RSI? rsi, StateId? state = null)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetRsi(layer, rsi, state);
}
public void LayerSetRsi(Entity<SpriteComponent?> sprite, string key, RSI? rsi, StateId? state = null)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRsi(layer, rsi, state);
}
public void LayerSetRsi(Entity<SpriteComponent?> sprite, Enum key, RSI? rsi, StateId? state = null)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRsi(layer, rsi, state);
}
public void LayerSetRsi(Layer layer, RSI? rsi, StateId? state = null)
{
layer._rsi = rsi;
LayerSetRsiState(layer, state ?? layer.StateId, refresh: true);
}
public void LayerSetRsi(Entity<SpriteComponent?> sprite, int index, ResPath rsi, StateId? state = null)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetRsi(layer, rsi, state);
}
public void LayerSetRsi(Entity<SpriteComponent?> sprite, string key, ResPath rsi, StateId? state = null)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRsi(layer, rsi, state);
}
public void LayerSetRsi(Entity<SpriteComponent?> sprite, Enum key, ResPath rsi, StateId? state = null)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRsi(layer, rsi, state);
}
public void LayerSetRsi(Layer layer, ResPath rsi, StateId? state = null)
{
if (!_resourceCache.TryGetResource<RSIResource>(TextureRoot / rsi, out var res))
Log.Error($"Unable to load RSI '{rsi}' for entity {ToPrettyString(layer.Owner)}. Trace:\n{Environment.StackTrace}");
LayerSetRsi(layer, res?.RSI, state);
}
#endregion
#region Scale
public void LayerSetScale(Entity<SpriteComponent?> sprite, int index, Vector2 value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetScale(layer, value);
}
public void LayerSetScale(Entity<SpriteComponent?> sprite, string key, Vector2 value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetScale(layer, value);
}
public void LayerSetScale(Entity<SpriteComponent?> sprite, Enum key, Vector2 value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetScale(layer, value);
}
public void LayerSetScale(Layer layer, Vector2 value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (layer._scale.EqualsApprox(value))
return;
if (!ValidateScale(layer.Owner, value))
return;
layer._scale = value;
layer.UpdateLocalMatrix();
_tree.QueueTreeUpdate(layer.Owner);
layer.BoundsDirty = true;
layer.Owner.Comp.BoundsDirty = true;
}
#endregion
#region Rotation
public void LayerSetRotation(Entity<SpriteComponent?> sprite, int index, Angle value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetRotation(layer, value);
}
public void LayerSetRotation(Entity<SpriteComponent?> sprite, string key, Angle value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRotation(layer, value);
}
public void LayerSetRotation(Entity<SpriteComponent?> sprite, Enum key, Angle value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRotation(layer, value);
}
public void LayerSetRotation(Layer layer, Angle value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (layer._rotation.EqualsApprox(value))
return;
layer._rotation = value;
layer.UpdateLocalMatrix();
_tree.QueueTreeUpdate(layer.Owner);
layer.BoundsDirty = true;
layer.Owner.Comp.BoundsDirty = true;
}
#endregion
#region Offset
public void LayerSetOffset(Entity<SpriteComponent?> sprite, int index, Vector2 value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetOffset(layer, value);
}
public void LayerSetOffset(Entity<SpriteComponent?> sprite, string key, Vector2 value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetOffset(layer, value);
}
public void LayerSetOffset(Entity<SpriteComponent?> sprite, Enum key, Vector2 value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetOffset(layer, value);
}
public void LayerSetOffset(Layer layer, Vector2 value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (layer._offset.EqualsApprox(value))
return;
layer._offset = value;
layer.UpdateLocalMatrix();
_tree.QueueTreeUpdate(layer.Owner);
layer.BoundsDirty = true;
layer.Owner.Comp.BoundsDirty = true;
}
#endregion
#region Visible
public void LayerSetVisible(Entity<SpriteComponent?> sprite, int index, bool value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetVisible(layer, value);
}
public void LayerSetVisible(Entity<SpriteComponent?> sprite, string key, bool value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetVisible(layer, value);
}
public void LayerSetVisible(Entity<SpriteComponent?> sprite, Enum key, bool value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetVisible(layer, value);
}
public void LayerSetVisible(Layer layer, bool value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (layer._visible == value)
return;
layer._visible = value;
QueueUpdateIsInert(layer.Owner);
_tree.QueueTreeUpdate(layer.Owner);
layer.Owner.Comp.BoundsDirty = true;
}
#endregion
#region Color
public void LayerSetColor(Entity<SpriteComponent?> sprite, int index, Color value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetColor(layer, value);
}
public void LayerSetColor(Entity<SpriteComponent?> sprite, string key, Color value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetColor(layer, value);
}
public void LayerSetColor(Entity<SpriteComponent?> sprite, Enum key, Color value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetColor(layer, value);
}
public void LayerSetColor(Layer layer, Color value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
layer.Color = value;
}
#endregion
#region DirOffset
public void LayerSetDirOffset(Entity<SpriteComponent?> sprite, int index, DirectionOffset value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetDirOffset(layer, value);
}
public void LayerSetDirOffset(Entity<SpriteComponent?> sprite, string key, DirectionOffset value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetDirOffset(layer, value);
}
public void LayerSetDirOffset(Entity<SpriteComponent?> sprite, Enum key, DirectionOffset value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetDirOffset(layer, value);
}
public void LayerSetDirOffset(Layer layer, DirectionOffset value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
layer.DirOffset = value;
}
#endregion
#region AnimationTime
public void LayerSetAnimationTime(Entity<SpriteComponent?> sprite, int index, float value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetAnimationTime(layer, value);
}
public void LayerSetAnimationTime(Entity<SpriteComponent?> sprite, string key, float value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetAnimationTime(layer, value);
}
public void LayerSetAnimationTime(Entity<SpriteComponent?> sprite, Enum key, float value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetAnimationTime(layer, value);
}
public void LayerSetAnimationTime(Layer layer, float value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (!layer.StateId.IsValid)
return;
if (layer.ActualRsi is not { } rsi)
return;
var state = rsi[layer.StateId];
if (value > layer.AnimationTime)
{
// Handle advancing differently from going backwards.
layer.AnimationTimeLeft -= (value - layer.AnimationTime);
}
else
{
// Going backwards we re-calculate from zero.
// Definitely possible to optimize this for going backwards but I'm too lazy to figure that out.
layer.AnimationTimeLeft = -value + state.GetDelay(0);
layer.AnimationFrame = 0;
}
layer.AnimationTime = value;
layer.AdvanceFrameAnimation(state);
layer.SetAnimationTime(value);
}
#endregion
#region AutoAnimated
public void LayerSetAutoAnimated(Entity<SpriteComponent?> sprite, int index, bool value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetAutoAnimated(layer, value);
}
public void LayerSetAutoAnimated(Entity<SpriteComponent?> sprite, string key, bool value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetAutoAnimated(layer, value);
}
public void LayerSetAutoAnimated(Entity<SpriteComponent?> sprite, Enum key, bool value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetAutoAnimated(layer, value);
}
public void LayerSetAutoAnimated(Layer layer, bool value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
if (layer._autoAnimated == value)
return;
layer._autoAnimated = value;
QueueUpdateIsInert(layer.Owner);
}
#endregion
#region LayerSetRenderingStrategy
public void LayerSetRenderingStrategy(Entity<SpriteComponent?> sprite, int index, LayerRenderingStrategy value)
{
if (TryGetLayer(sprite, index, out var layer, true))
LayerSetRenderingStrategy(layer, value);
}
public void LayerSetRenderingStrategy(Entity<SpriteComponent?> sprite, string key, LayerRenderingStrategy value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRenderingStrategy(layer, value);
}
public void LayerSetRenderingStrategy(Entity<SpriteComponent?> sprite, Enum key, LayerRenderingStrategy value)
{
if (TryGetLayer(sprite, key, out var layer, true))
LayerSetRenderingStrategy(layer, value);
}
public void LayerSetRenderingStrategy(Layer layer, LayerRenderingStrategy value)
{
DebugTools.Assert(layer.Owner != default);
DebugTools.AssertNotNull(layer.Owner.Comp);
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
layer.RenderingStrategy = value;
layer.BoundsDirty = true;
layer.Owner.Comp.BoundsDirty = true;
_tree.QueueTreeUpdate(layer.Owner);
}
#endregion
/// <summary>
/// Refreshes an RSI layer's cached RSI state.
/// </summary>
private void RefreshCachedState(Layer layer, bool logErrors, RSI.State? fallback)
{
if (!layer.StateId.IsValid)
{
layer._actualState = null;
}
else if (layer.ActualRsi is not { } rsi)
{
layer._actualState = fallback ?? GetFallbackState();
if (logErrors)
Log.Error(
$"{ToPrettyString(layer.Owner)} has no RSI to pull new state from! Trace:\n{Environment.StackTrace}");
}
else if (!rsi.TryGetState(layer.StateId, out layer._actualState))
{
layer._actualState = fallback ?? GetFallbackState();
if (logErrors)
Log.Error(
$"{ToPrettyString(layer.Owner)}'s state '{layer.StateId}' does not exist in RSI {rsi.Path}. Trace:\n{Environment.StackTrace}");
}
layer.AnimationFrame = 0;
layer.AnimationTime = 0;
layer.AnimationTimeLeft = layer._actualState?.GetDelay(0) ?? 0f;
}
}

View File

@@ -0,0 +1,196 @@
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Client.Graphics.Clyde;
using Robust.Client.Utility;
using Robust.Shared.GameObjects;
using Robust.Shared.Graphics.RSI;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using static Robust.Client.GameObjects.SpriteComponent;
using Vector4 = Robust.Shared.Maths.Vector4;
using SysVec4 = System.Numerics.Vector4;
namespace Robust.Client.GameObjects;
// This partial class contains code related to actually rendering sprites.
public sealed partial class SpriteSystem
{
public void RenderSprite(
Entity<SpriteComponent> sprite,
DrawingHandleWorld drawingHandle,
Angle eyeRotation,
Angle worldRotation,
Vector2 worldPosition)
{
RenderSprite(sprite,
drawingHandle,
eyeRotation,
worldRotation,
worldPosition,
sprite.Comp.EnableDirectionOverride ? sprite.Comp.DirectionOverride : null);
}
public void RenderSprite(
Entity<SpriteComponent> sprite,
DrawingHandleWorld drawingHandle,
Angle eyeRotation,
Angle worldRotation,
Vector2 worldPosition,
Direction? overrideDirection)
{
// TODO SPRITE RENDERING
// Add fast path for simple sprites.
// I.e., when a sprite is modified, check if it is "simple". If it is. cache texture information in a struct
// and use a fast path here.
// E.g., simple 1-directional, 1-layer sprites can basically become a direct texture draw call. (most in game items).
// Similarly, 1-directional multi-layer sprites can become a sequence of direct draw calls (most in game walls).
if (!sprite.Comp.IsInert)
_queuedFrameUpdate.Add(sprite);
var angle = worldRotation + eyeRotation; // angle on-screen. Used to decide the direction of 4/8 directional RSIs
angle = angle.Reduced().FlipPositive(); // Reduce the angles to fix math shenanigans
var cardinal = Angle.Zero;
// If we have a 1-directional sprite then snap it to try and always face it south if applicable.
if (sprite.Comp is {NoRotation: false, SnapCardinals: true})
cardinal = angle.RoundToCardinalAngle();
// worldRotation + eyeRotation should be the angle of the entity on-screen. If no-rot is enabled this is just set to zero.
// However, at some point later the eye-matrix is applied separately, so we subtract -eye rotation for now:
var entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, sprite.Comp.NoRotation ? -eyeRotation : worldRotation - cardinal);
var spriteMatrix = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
// Fast path for when all sprites use the same transform matrix
if (!sprite.Comp.GranularLayersRendering)
{
foreach (var layer in sprite.Comp.Layers)
{
RenderLayer(layer, drawingHandle, ref spriteMatrix, angle, overrideDirection);
}
return;
}
//Default rendering (NoRotation = false)
entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, worldRotation);
var transformDefault = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
//Snap to cardinals
entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, worldRotation - angle.RoundToCardinalAngle());
var transformSnap = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
//No rotation
entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, -eyeRotation);
var transformNoRot = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
foreach (var layer in sprite.Comp.Layers)
{
switch (layer.RenderingStrategy)
{
case LayerRenderingStrategy.UseSpriteStrategy:
RenderLayer(layer, drawingHandle, ref spriteMatrix, angle, overrideDirection);
break;
case LayerRenderingStrategy.Default:
RenderLayer(layer, drawingHandle, ref transformDefault, angle, overrideDirection);
break;
case LayerRenderingStrategy.NoRotation:
RenderLayer(layer, drawingHandle, ref transformNoRot, angle, overrideDirection);
break;
case LayerRenderingStrategy.SnapToCardinals:
RenderLayer(layer, drawingHandle, ref transformSnap, angle, overrideDirection);
break;
default:
Log.Error($"Tried to render a layer with unknown rendering stragegy: {layer.RenderingStrategy}");
break;
}
}
}
/// <summary>
/// Render a layer. This assumes that the input angle is between 0 and 2pi.
/// </summary>
private void RenderLayer(Layer layer, DrawingHandleWorld drawingHandle, ref Matrix3x2 spriteMatrix, Angle angle, Direction? overrideDirection)
{
if (!layer.Visible || layer.Blank)
return;
var state = layer._actualState;
var dir = state == null ? RsiDirection.South : Layer.GetDirection(state.RsiDirections, angle);
// Set the drawing transform for this layer
layer.GetLayerDrawMatrix(dir, out var layerMatrix, layer.Owner.Comp.NoRotation);
// The direction used to draw the sprite can differ from the one that the angle would naively suggest,
// due to direction overrides or offsets.
if (overrideDirection != null && state != null)
dir = overrideDirection.Value.Convert(state.RsiDirections);
dir = dir.OffsetRsiDir(layer.DirOffset);
var texture = state?.GetFrame(dir, layer.AnimationFrame) ?? layer.Texture ?? GetFallbackTexture();
// TODO SPRITE
// Refactor shader-param-layers to a separate layer type after layers are split into types & collections.
// I.e., separate Layer -> RsiLayer, TextureLayer, LayerCollection, SpriteLayer, and ShaderLayer
if (layer.CopyToShaderParameters != null)
{
HandleShaderLayer(layer, texture, layer.CopyToShaderParameters);
return;
}
// Set the drawing transform for this layer
var transformMatrix = Matrix3x2.Multiply(layerMatrix, spriteMatrix);
drawingHandle.SetTransform(in transformMatrix);
if (layer.Shader != null)
drawingHandle.UseShader(layer.Shader);
var layerColor = layer.Owner.Comp.color * layer.Color;
var textureSize = texture.Size / (float) EyeManager.PixelsPerMeter;
var quad = Box2.FromDimensions(textureSize / -2, textureSize);
if (layer.UnShaded)
{
DebugTools.AssertNull(layer.Shader);
DebugTools.Assert(layerColor is {R: >= 0, G: >= 0, B: >= 0, A: >= 0}, "Default shader should not be used with negative color modulation.");
// Negative color modulation values are by the default shader to disable light shading.
// Specifically we set colour = - 1 - colour
// This is good enough to ensure that non-negative values become negative & is trivially invertible.
layerColor = new(new SysVec4(-1) - layerColor.RGBA);
}
drawingHandle.DrawTextureRectRegion(texture, quad, layerColor);
if (layer.Shader != null)
drawingHandle.UseShader(null);
}
/// <summary>
/// Handle a a "fake layer" that just exists to modify the parameters of a shader being used by some other
/// layer.
/// </summary>
private void HandleShaderLayer(Layer layer, Texture texture, CopyToShaderParameters @params)
{
// Multiple atrocities to god being committed right here.
var otherLayerIdx = layer._parent.LayerMap[@params.LayerKey!];
var otherLayer = layer._parent.Layers[otherLayerIdx];
if (otherLayer.Shader is not { } shader)
return;
if (!shader.Mutable)
otherLayer.Shader = shader = shader.Duplicate();
var clydeTexture = Clyde.RenderHandle.ExtractTexture(texture, null, out var csr);
if (@params.ParameterTexture is { } paramTexture)
shader.SetParameter(paramTexture, clydeTexture);
if (@params.ParameterUV is not { } paramUV)
return;
var sr = Clyde.RenderHandle.WorldTextureBoundsToUV(clydeTexture, csr);
var uv = new Vector4(sr.Left, sr.Bottom, sr.Right, sr.Top);
shader.SetParameter(paramUV, uv);
}
}

View File

@@ -0,0 +1,166 @@
using System;
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Shared.GameObjects;
using Robust.Shared.Maths;
namespace Robust.Client.GameObjects;
// This partial class contains various public methods for setting sprite component data.
public sealed partial class SpriteSystem
{
private bool ValidateScale(Entity<SpriteComponent> sprite, Vector2 scale)
{
if (!(MathF.Abs(scale.X) < 0.005f) && !(MathF.Abs(scale.Y) < 0.005f))
return true;
// Scales of ~0.0025 or lower can lead to singular matrices due to rounding errors.
Log.Error(
$"Attempted to set layer sprite scale to very small values. Entity: {ToPrettyString(sprite)}. Scale: {scale}");
return false;
}
#region Transform
public void SetScale(Entity<SpriteComponent?> sprite, Vector2 value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (!ValidateScale(sprite!, value))
return;
sprite.Comp._bounds = sprite.Comp._bounds.Scale(value / sprite.Comp.scale);
sprite.Comp.scale = value;
sprite.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
in sprite.Comp.offset,
in sprite.Comp.rotation,
in sprite.Comp.scale);
}
public void SetRotation(Entity<SpriteComponent?> sprite, Angle value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
sprite.Comp.rotation = value;
sprite.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
in sprite.Comp.offset,
in sprite.Comp.rotation,
in sprite.Comp.scale);
}
public void SetOffset(Entity<SpriteComponent?> sprite, Vector2 value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
sprite.Comp.offset = value;
sprite.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
in sprite.Comp.offset,
in sprite.Comp.rotation,
in sprite.Comp.scale);
}
#endregion
public void SetVisible(Entity<SpriteComponent?> sprite, bool value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (sprite.Comp.Visible == value)
return;
sprite.Comp._visible = value;
_tree.QueueTreeUpdate(sprite!);
}
public void SetDrawDepth(Entity<SpriteComponent?> sprite, int value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
sprite.Comp.drawDepth = value;
}
public void SetColor(Entity<SpriteComponent?> sprite, Color value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
sprite.Comp.color = value;
}
/// <summary>
/// Modify a sprites base RSI. This is the RSI that is used by any RSI layers that do not specify their own.
/// Note that changing the base RSI may result in existing layers having an invalid state. This will not log errors
/// under the assumption that the states of each layers will be updated after the base RSI has changed.
/// </summary>
public void SetBaseRsi(Entity<SpriteComponent?> sprite, RSI? value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (value == sprite.Comp._baseRsi)
return;
sprite.Comp._baseRsi = value;
if (value == null)
return;
var fallback = GetFallbackState();
for (var i = 0; i < sprite.Comp.Layers.Count; i++)
{
var layer = sprite.Comp.Layers[i];
if (!layer.State.IsValid || layer.RSI != null)
continue;
RefreshCachedState(layer, logErrors: false, fallback);
if (value.TryGetState(layer.State, out var state))
{
layer.AnimationTimeLeft = state.GetDelay(0);
}
else
{
Log.Error($"Layer {i} no longer has state '{layer.State}' due to base RSI change. Trace:\n{Environment.StackTrace}");
layer.Texture = null;
}
}
}
public void SetContainerOccluded(Entity<SpriteComponent?> sprite, bool value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
sprite.Comp._containerOccluded = value;
_tree.QueueTreeUpdate(sprite!);
}
public void SetSnapCardinals(Entity<SpriteComponent?> sprite, bool value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (value == sprite.Comp._snapCardinals)
return;
sprite.Comp._snapCardinals = value;
_tree.QueueTreeUpdate(sprite!);
DirtyBounds(sprite!);
}
public void SetGranularLayersRendering(Entity<SpriteComponent?> sprite, bool value)
{
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
return;
if (value == sprite.Comp.GranularLayersRendering)
return;
sprite.Comp.GranularLayersRendering = value;
_tree.QueueTreeUpdate(sprite!);
DirtyBounds(sprite!);
}
}

View File

@@ -1,137 +0,0 @@
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.Utility;
using Robust.Shared.Graphics;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
using Robust.Shared.Utility;
namespace Robust.Client.GameObjects;
public sealed partial class SpriteSystem
{
private readonly Dictionary<string, IRsiStateLike> _cachedPrototypeIcons = new();
public Texture Frame0(EntityPrototype prototype)
{
return GetPrototypeIcon(prototype).Default;
}
public Texture Frame0(SpriteSpecifier specifier)
{
return RsiStateLike(specifier).Default;
}
public IRsiStateLike RsiStateLike(SpriteSpecifier specifier)
{
switch (specifier)
{
case SpriteSpecifier.Texture tex:
return tex.GetTexture(_resourceCache);
case SpriteSpecifier.Rsi rsi:
return GetState(rsi);
case SpriteSpecifier.EntityPrototype prototypeIcon:
return GetPrototypeIcon(prototypeIcon.EntityPrototypeId);
default:
throw new NotSupportedException();
}
}
public Texture GetIcon(IconComponent icon)
{
return GetState(icon.Icon).Frame0;
}
/// <summary>
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
/// This method caches the result based on the prototype identifier.
/// </summary>
public IRsiStateLike GetPrototypeIcon(string prototype)
{
// Check if this prototype has been cached before, and if so return the result.
if (_cachedPrototypeIcons.TryGetValue(prototype, out var cachedResult))
return cachedResult;
if (!_proto.TryIndex<EntityPrototype>(prototype, out var entityPrototype))
{
// The specified prototype doesn't exist, return the fallback "error" sprite.
_sawmill.Error("Failed to load PrototypeIcon {0}", prototype);
return GetFallbackState();
}
// Generate the icon and cache it in case it's ever needed again.
var result = GetPrototypeIcon(entityPrototype);
_cachedPrototypeIcons[prototype] = result;
return result;
}
/// <summary>
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
/// This method does NOT cache the result.
/// </summary>
public IRsiStateLike GetPrototypeIcon(EntityPrototype prototype)
{
// IconComponent takes precedence. If it has a valid icon, return that. Otherwise, continue as normal.
if (prototype.Components.TryGetValue("Icon", out var compData)
&& compData.Component is IconComponent icon)
{
return GetIcon(icon);
}
// If the prototype doesn't have a SpriteComponent, then there's nothing we can do but return the fallback.
if (!prototype.Components.ContainsKey("Sprite"))
{
return GetFallbackState();
}
// Finally, we use spawn a dummy entity to get its icon.
var dummy = Spawn(prototype.ID, MapCoordinates.Nullspace);
var spriteComponent = EnsureComp<SpriteComponent>(dummy);
var result = spriteComponent.Icon ?? GetFallbackState();
Del(dummy);
return result;
}
[Pure]
public RSI.State GetFallbackState()
{
return _resourceCache.GetFallback<RSIResource>().RSI["error"];
}
[Pure]
public RSI.State GetState(SpriteSpecifier.Rsi rsiSpecifier)
{
if (_resourceCache.TryGetResource<RSIResource>(
SpriteSpecifierSerializer.TextureRoot / rsiSpecifier.RsiPath,
out var theRsi) &&
theRsi.RSI.TryGetState(rsiSpecifier.RsiState, out var state))
{
return state;
}
_sawmill.Error("Failed to load RSI {0}", rsiSpecifier.RsiPath);
return GetFallbackState();
}
private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
{
if (!args.TryGetModified<EntityPrototype>(out var modified))
return;
// Remove all changed prototypes from the cache, if they're there.
foreach (var prototype in modified)
{
// Let's be lazy and not regenerate them until something needs them again.
_cachedPrototypeIcons.Remove(prototype);
}
}
}

View File

@@ -13,10 +13,9 @@ using Robust.Shared.GameObjects;
using Robust.Shared.Graphics.RSI;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Physics;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using static Robust.Client.GameObjects.SpriteComponent;
@@ -36,24 +35,20 @@ namespace Robust.Client.GameObjects
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly SharedTransformSystem _xforms = default!;
[Dependency] private readonly SpriteTreeSystem _tree = default!;
public static readonly ProtoId<ShaderPrototype> UnshadedId = "unshaded";
private readonly Queue<SpriteComponent> _inertUpdateQueue = new();
public static readonly ResPath TextureRoot = SpriteSpecifierSerializer.TextureRoot;
/// <summary>
/// Entities that require a sprite frame update.
/// </summary>
private readonly HashSet<EntityUid> _queuedFrameUpdate = new();
private ISawmill _sawmill = default!;
internal void Render(EntityUid uid, SpriteComponent sprite, DrawingHandleWorld drawingHandle, Angle eyeRotation, in Angle worldRotation, in Vector2 worldPosition)
{
if (!sprite.IsInert)
_queuedFrameUpdate.Add(uid);
sprite.RenderInternal(drawingHandle, eyeRotation, worldRotation, worldPosition, sprite.EnableDirectionOverride ? sprite.DirectionOverride : null);
}
private EntityQuery<SpriteComponent> _query;
public override void Initialize()
{
@@ -62,11 +57,11 @@ namespace Robust.Client.GameObjects
UpdatesAfter.Add(typeof(SpriteTreeSystem));
SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypesReloaded);
SubscribeLocalEvent<SpriteComponent, SpriteUpdateInertEvent>(QueueUpdateInert);
SubscribeLocalEvent<SpriteComponent, ComponentInit>(OnInit);
Subs.CVar(_cfg, CVars.RenderSpriteDirectionBias, OnBiasChanged, true);
_sawmill = _logManager.GetSawmill("sprite");
_query = GetEntityQuery<SpriteComponent>();
}
public bool IsVisible(Layer layer)
@@ -85,18 +80,6 @@ namespace Robust.Client.GameObjects
SpriteComponent.DirectionBias = value;
}
private void QueueUpdateInert(EntityUid uid, SpriteComponent sprite, ref SpriteUpdateInertEvent ev)
=> QueueUpdateInert(uid, sprite);
public void QueueUpdateInert(EntityUid uid, SpriteComponent sprite)
{
if (sprite._inertUpdateQueued)
return;
sprite._inertUpdateQueued = true;
_inertUpdateQueue.Enqueue(sprite);
}
private void DoUpdateIsInert(SpriteComponent component)
{
component._inertUpdateQueued = false;

View File

@@ -11,6 +11,7 @@ public abstract class VisualizerSystem<T> : EntitySystem
{
[Dependency] protected readonly AppearanceSystem AppearanceSystem = default!;
[Dependency] protected readonly AnimationPlayerSystem AnimationSystem = default!;
[Dependency] protected readonly SpriteSystem SpriteSystem = default!;
public override void Initialize()
{

View File

@@ -1306,6 +1306,11 @@ namespace Robust.Client.GameStates
meta.LastStateApplied = lastStateApplied.Value;
var xform = xforms.GetComponent(ent.Value);
// TODO PVS DETACH
// Why is this if block here again? If a null-space entity gets sent to a player via some PVS override,
// and then later on it gets removed, you would assume that the client marks it as detached?
// I.e., modifying the metadata flag & pausing the entity should probably happen outside of this block.
if (xform.ParentUid.IsValid())
{
lookupSys.RemoveFromEntityTree(ent.Value, xform);
@@ -1326,6 +1331,13 @@ namespace Robust.Client.GameStates
xformSys.DetachEntity(ent.Value, xform);
DebugTools.Assert((meta.Flags & MetaDataFlags.InContainer) == 0);
// We mark the entity as paused, without raising a pause-event.
// The entity gets un-paused when the metadata's comp-state is reapplied (which also does not raise
// an un-pause event). The assumption is that game logic that has to handle the pausing should be
// getting networked anyway. And if its some client-side timer on a networked entity, the timer
// shouldn't actually be getting paused just because the entity has left the players view.
meta.PauseTime = TimeSpan.Zero;
if (container != null)
containerSys.AddExpectedEntity(netEntity, container);
}

View File

@@ -412,8 +412,11 @@ namespace Robust.Client.Graphics.Clyde
private void _updateTileMapOnUpdate(ref TileChangedEvent args)
{
var gridData = _mapChunkData.GetOrNew(args.Entity);
if (gridData.TryGetValue(args.ChunkIndex, out var data))
data.Dirty = true;
foreach (var change in args.Changes)
{
if (gridData.TryGetValue(change.ChunkIndex, out var data))
data.Dirty = true;
}
}
private void _updateOnGridCreated(GridStartupEvent ev)

View File

@@ -318,7 +318,7 @@ namespace Robust.Client.Graphics.Clyde
screenSpriteSize.Y++;
bool exit = false;
if (entry.Sprite.GetScreenTexture)
if (entry.Sprite.GetScreenTexture && entry.Sprite.PostShader != null)
{
FlushRenderQueue();
var tex = CopyScreenTexture(viewport.RenderTarget);
@@ -369,7 +369,7 @@ namespace Robust.Client.Graphics.Clyde
}
}
spriteSystem.Render(entry.Uid, entry.Sprite, _renderHandle.DrawingHandleWorld, eye.Rotation, in entry.WorldRot, in entry.WorldPos);
spriteSystem.RenderSprite(new(entry.Uid, entry.Sprite), _renderHandle.DrawingHandleWorld, eye.Rotation, entry.WorldRot, entry.WorldPos);
if (entry.Sprite.PostShader != null && entityPostRenderTarget != null)
{

View File

@@ -613,6 +613,8 @@ namespace Robust.Client.Graphics.Clyde
EnsureBatchSpaceAvailable(4, GetQuadBatchIndexCount());
EnsureBatchState(texture, true, GetQuadBatchPrimitiveType(), _queuedShader);
// TODO RENDERING
// It's probably better to do this on the GPU.
bl = Vector2.Transform(bl, _currentMatrixModel);
br = Vector2.Transform(br, _currentMatrixModel);
tr = Vector2.Transform(tr, _currentMatrixModel);

View File

@@ -153,7 +153,7 @@ internal partial class Clyde
// special casing angle = n*pi/2 to avoid box rotation & bounding calculations doesn't seem to give significant speedups.
data.SpriteScreenBB = TransformCenteredBox(
data.Sprite.Bounds,
_spriteSystem.GetLocalBounds((data.Uid, data.Sprite)),
finalRotation,
pos + batch.PreScaleViewOffset,
batch.ViewScale);

View File

@@ -10,6 +10,7 @@ internal sealed partial class Clyde
private MapSystem _mapSystem = default!;
private LightTreeSystem _lightTreeSystem = default!;
private TransformSystem _transformSystem = default!;
private SpriteSystem _spriteSystem = default!;
private SpriteTreeSystem _spriteTreeSystem = default!;
private ClientOccluderSystem _occluderSystem = default!;
@@ -24,6 +25,7 @@ internal sealed partial class Clyde
_mapSystem = _entitySystemManager.GetEntitySystem<MapSystem>();
_lightTreeSystem = _entitySystemManager.GetEntitySystem<LightTreeSystem>();
_transformSystem = _entitySystemManager.GetEntitySystem<TransformSystem>();
_spriteSystem = _entitySystemManager.GetEntitySystem<SpriteSystem>();
_spriteTreeSystem = _entitySystemManager.GetEntitySystem<SpriteTreeSystem>();
_occluderSystem = _entitySystemManager.GetEntitySystem<ClientOccluderSystem>();
}
@@ -33,6 +35,7 @@ internal sealed partial class Clyde
_mapSystem = null!;
_lightTreeSystem = null!;
_transformSystem = null!;
_spriteSystem = null!;
_spriteTreeSystem = null!;
_occluderSystem = null!;
}

View File

@@ -47,6 +47,7 @@ namespace Robust.Client.Placement
private SharedMapSystem Maps => EntityManager.System<SharedMapSystem>();
private SharedTransformSystem XformSystem => EntityManager.System<SharedTransformSystem>();
private SpriteSystem Sprite => EntityManager.System<SpriteSystem>();
/// <summary>
/// How long before a pending tile change is dropped.
@@ -359,12 +360,15 @@ namespace Robust.Client.Placement
private void HandleTileChanged(ref TileChangedEvent args)
{
var coords = Maps.GridTileToLocal(
args.NewTile.GridUid,
EntityManager.GetComponent<MapGridComponent>(args.NewTile.GridUid),
args.NewTile.GridIndices);
foreach (var change in args.Changes)
{
var coords = Maps.GridTileToLocal(
args.Entity,
args.Entity.Comp,
change.GridIndices);
_pendingTileChanges.RemoveAll(c => c.Item1 == coords);
_pendingTileChanges.RemoveAll(c => c.Item1 == coords);
}
}
/// <inheritdoc />
@@ -708,11 +712,11 @@ namespace Robust.Client.Placement
CurrentPlacementOverlayEntity = null;
}
private SpriteComponent SetupPlacementOverlayEntity()
private Entity<SpriteComponent> SetupPlacementOverlayEntity()
{
EnsureNoPlacementOverlayEntity();
CurrentPlacementOverlayEntity = EntityManager.SpawnEntity(null, MapCoordinates.Nullspace);
return EntityManager.EnsureComponent<SpriteComponent>(CurrentPlacementOverlayEntity.Value);
return (CurrentPlacementOverlayEntity.Value, EntityManager.EnsureComponent<SpriteComponent>(CurrentPlacementOverlayEntity.Value));
}
private void PreparePlacement(string templateName)
@@ -729,10 +733,16 @@ namespace Robust.Client.Placement
EntityManager.GetComponent<MetaDataComponent>(CurrentPlacementOverlayEntity.Value));
}
public void PreparePlacementSprite(SpriteComponent sprite)
public void PreparePlacementSprite(Entity<SpriteComponent> sprite)
{
var sc = SetupPlacementOverlayEntity();
sc.CopyFrom(sprite);
Sprite.CopySprite(sprite.AsNullable(), sc.AsNullable());
}
[Obsolete("Use the Entity<SpriteComponent> overload.")]
public void PreparePlacementSprite(SpriteComponent sprite)
{
PreparePlacementSprite((sprite.Owner, sprite));
}
public void PreparePlacementTexList(List<IDirectionalTextureProvider>? texs, bool noRot, EntityPrototype? prototype)
@@ -743,27 +753,27 @@ namespace Robust.Client.Placement
// This one covers most cases (including Construction)
foreach (var v in texs)
{
if (v is RSI.State)
if (v is RSI.State st)
{
var st = (RSI.State) v;
sc.AddLayer(st.StateId, st.RSI);
Sprite.AddRsiLayer(sc.AsNullable(), st.StateId, st.RSI);
}
else
{
// Fallback
sc.AddLayer(v.Default);
Sprite.AddTextureLayer(sc.AsNullable(), v.Default);
}
}
}
else
{
sc.AddLayer(new ResPath("/Textures/Interface/tilebuildoverlay.png"));
Sprite.AddTextureLayer(sc.AsNullable(), new ResPath("/Textures/Interface/tilebuildoverlay.png"));
}
sc.NoRotation = noRot;
sc.Comp.NoRotation = noRot;
if (prototype != null && prototype.TryGetComponent<SpriteComponent>("Sprite", out var spriteComp))
{
sc.Scale = spriteComp.Scale;
Sprite.SetScale(sc.AsNullable(), spriteComp.Scale);
}
}
@@ -771,7 +781,7 @@ namespace Robust.Client.Placement
private void PreparePlacementTile()
{
var sc = SetupPlacementOverlayEntity();
sc.AddLayer(new ResPath("/Textures/Interface/tilebuildoverlay.png"));
Sprite.AddTextureLayer(sc.AsNullable(), new ResPath("/Textures/Interface/tilebuildoverlay.png"));
IsActive = true;
}

View File

@@ -126,7 +126,7 @@ namespace Robust.Client.Placement
sprite.Color = IsValidPosition(coordinate) ? ValidPlaceColor : InvalidPlaceColor;
var rot = args.Viewport.Eye?.Rotation ?? default;
spriteSys.Render(uid.Value, sprite, args.WorldHandle, rot, worldRot, worldPos);
spriteSys.RenderSprite((uid.Value, sprite), args.WorldHandle, rot, worldRot, worldPos);
}
}

View File

@@ -166,7 +166,7 @@ namespace Robust.Client.Player
{
if (_client.RunLevel != ClientRunLevel.SinglePlayerGame)
Sawmill.Warning($"Attaching local player to an entity {EntManager.ToPrettyString(uid)} without an eye. This eye will not be netsynced and may cause issues.");
var eye = (EyeComponent) Factory.GetComponent(typeof(EyeComponent));
var eye = Factory.GetComponent<EyeComponent>();
eye.NetSyncEnabled = false;
EntManager.AddComponent(uid.Value, eye);
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using Robust.Client.Graphics;
using Robust.Client.Utility;
using Robust.Shared;
using Robust.Shared.Audio;
using Robust.Shared.Collections;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.Graphics;
@@ -191,65 +193,143 @@ namespace Robust.Client.ResourceManagement
// TODO allow RSIs to opt out (useful for very big & rare RSIs)
// TODO combine with (non-rsi) texture atlas?
Array.Sort(atlasList, (b, a) => (b.AtlasSheet?.Height ?? 0).CompareTo(a.AtlasSheet?.Height ?? 0));
// We now need to insert the RSIs into the atlas. This specific problem is 2BP|O|F - the items are oriented
// and cutting is free. The sorting is done by a slightly modified FFDH algorithm. The algorithm is exactly
// the same as the standard FFDH algorithm with one main difference: We create new "levels" above placed
// blocks. For example if the first block was 10x20, then the second was 10x10 units, we would create a
// 10x10 level above the second block that would be treated as a normal level. This increases the packing
// efficiency from ~85% to ~95% with very little extra computational effort. The algorithm appears to be
// ~97% effective for storing SS14s RSIs.
//
// Here are some more resources about the strip packing problem!
// - https://en.wikipedia.org/w/index.php?title=Strip_packing_problem&oldid=1263496949#First-fit_decreasing-height_(FFDH)
// - https://www.csc.liv.ac.uk/~epa/surveyhtml.html
// - https://www.dei.unipd.it/~fisch/ricop/tesi/tesi_dottorato_Lodi_1999.pdf
// The array must be sorted from biggest to smallest first.
Array.Sort(atlasList, (b, a) => a.AtlasSheet.Height.CompareTo(b.AtlasSheet.Height));
// Each RSI sub atlas has a different size.
// Even if we iterate through them once to estimate total area, I have NFI how to sanely estimate an optimal square-texture size.
// So fuck it, just default to letting it be as large as it needs to and crop it as needed?
var maxSize = Math.Min(GL.GetInteger(GetPName.MaxTextureSize), _configurationManager.GetCVar(CVars.ResRSIAtlasSize));
var sheet = new Image<Rgba32>(maxSize, maxSize);
var deltaY = 0;
Vector2i offset = default;
int finalized = -1;
int atlasCount = 0;
for (int i = 0; i < atlasList.Length; i++)
// THIS IS NOT GUARANTEED TO HAVE ANY PARTICULARLY LOGICAL ORDERING.
// E.G you could have atlas 1 RSIs appear *before* you're done seeing atlas 2 RSIs.
var levels = new ValueList<Level>();
// List of all the image atlases.
var imageAtlases = new ValueList<Image<Rgba32>>();
// List of all the actual atlases.
var finalAtlases = new ValueList<OwnedTexture>();
// Number of total pixels in each atlas.
var finalPixels = new ValueList<int>();
// First we just find the location of all the RSIs in the atlas before actually placing them.
// This allows us to effectively determine how much space we need to allocate for the images.
var currentHeight = 0;
var currentAtlasIndex = 0;
foreach (var rsi in atlasList)
{
var rsi = atlasList[i];
if (rsi.Bad)
var insertHeight = rsi.AtlasSheet.Height;
var insertWidth = rsi.AtlasSheet.Width;
var found = false;
for (var i = 0; i < levels.Count && !found; i++)
{
var levelPosition = levels[i].Position;
var levelWidth = levels[i].Width;
var levelHeight = levels[i].Height;
// Check if it can fit in this level.
if (levelHeight < insertHeight || levelWidth + insertWidth > levels[i].MaxWidth)
continue;
found = true;
levels[i].Width += insertWidth;
rsi.AtlasOffset = levelPosition + new Vector2i(levelWidth, 0);
levels[i].RSIList.Add(rsi);
// Creating the extra "free" space above blocks that can be used for inserting more items.
// This differs from the FFDH spec which just ignores this space.
Debug.Assert(levelHeight >= insertHeight); // Must be true because the array needs to be sorted
if (levelHeight - insertHeight == 0)
continue;
var freeLevel = new Level
{
AtlasId = levels[i].AtlasId,
Position = levelPosition + new Vector2i(levelWidth, insertHeight),
Height = levelHeight - insertHeight,
Width = 0,
MaxWidth = insertWidth,
RSIList = [ ]
};
levels.Add(freeLevel);
}
if (found)
continue;
DebugTools.Assert(rsi.AtlasSheet.Width < sheet.Width);
DebugTools.Assert(rsi.AtlasSheet.Height < sheet.Height);
if (offset.X + rsi.AtlasSheet.Width > sheet.Width)
// Ran out of space, we need to move on to the next atlas.
// This also isn't in the normal FFDH algorithm (obviously) but its close enough.
if (currentHeight + insertHeight > maxSize)
{
offset.X = 0;
offset.Y += deltaY;
imageAtlases.Add(new Image<Rgba32>(maxSize, currentHeight));
finalPixels.Add(0);
currentHeight = 0;
currentAtlasIndex++;
}
if (offset.Y + rsi.AtlasSheet.Height > sheet.Height)
{
FinalizeMetaAtlas(i-1, sheet);
sheet = new Image<Rgba32>(maxSize, maxSize);
deltaY = 0;
offset = default;
}
rsi.AtlasOffset = new Vector2i(0, currentHeight);
deltaY = Math.Max(deltaY, rsi.AtlasSheet.Height);
var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height);
rsi.AtlasSheet.Blit(box, sheet, offset);
rsi.AtlasOffset = offset;
offset.X += rsi.AtlasSheet.Width;
var newLevel = new Level
{
AtlasId = currentAtlasIndex,
Position = new Vector2i(0, currentHeight),
Height = insertHeight,
Width = insertWidth,
MaxWidth = maxSize,
RSIList = [ rsi ]
};
levels.Add(newLevel);
currentHeight += insertHeight;
}
var height = offset.Y + deltaY;
var croppedSheet = new Image<Rgba32>(maxSize, height);
sheet.Blit(new UIBox2i(0, 0, maxSize, height), croppedSheet, default);
FinalizeMetaAtlas(atlasList.Length - 1, croppedSheet);
// This allocation takes a long time.
imageAtlases.Add(new Image<Rgba32>(maxSize, currentHeight));
finalPixels.Add(0);
void FinalizeMetaAtlas(int toIndex, Image<Rgba32> sheet)
// Put all textures on the atlases
foreach (var level in levels)
{
var fromIndex = finalized + 1;
var atlas = Clyde.LoadTextureFromImage(sheet, $"Meta atlas {fromIndex}-{toIndex}");
for (int i = fromIndex; i <= toIndex; i++)
foreach (var rsi in level.RSIList)
{
var rsi = atlasList[i];
rsi.AtlasTexture = atlas;
}
var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height);
finalized = toIndex;
atlasCount++;
rsi.AtlasSheet.Blit(box, imageAtlases[level.AtlasId], rsi.AtlasOffset);
finalPixels[level.AtlasId] += rsi.AtlasSheet.Width * rsi.AtlasSheet.Height;
}
}
// Finalize the atlases.
for (var i = 0; i < imageAtlases.Count; i++)
{
var atlasTexture = Clyde.LoadTextureFromImage(imageAtlases[i], $"Meta atlas {i}");
finalAtlases.Add(atlasTexture);
sawmill.Debug($"(Meta atlas {i}) - cropped utilization: {(float)finalPixels[i] / (maxSize * imageAtlases[i].Height):P2}, fill percentage: {(float)imageAtlases[i].Height / maxSize:P2}");
}
// Finally, reference the actual atlas from the RSIs.
foreach (var level in levels)
{
foreach (var rsi in level.RSIList)
{
rsi.AtlasTexture = finalAtlases[level.AtlasId];
}
}
Parallel.ForEach(rsiList, data =>
@@ -294,7 +374,7 @@ namespace Robust.Client.ResourceManagement
sawmill.Debug(
"Preloaded {CountLoaded} RSIs into {CountAtlas} Atlas(es?) ({CountNotAtlas} not atlassed, {CountErrored} errored) in {LoadTime}",
rsiList.Length,
atlasCount,
finalAtlases.Count,
nonAtlasList.Length,
errors,
sw.Elapsed);
@@ -305,4 +385,38 @@ namespace Robust.Client.ResourceManagement
return rsi.MetaAtlas && rsi.LoadParameters == TextureLoadParameters.Default;
}
}
/// <summary>
/// A "Level" to place boxes. Similar to FFDH levels, but with more parameters so we can fit in "free" levels
/// above placed boxes.
/// </summary>
internal sealed class Level
{
/// <summary>
/// Index of the atlas this is located.
/// </summary>
public required int AtlasId;
/// <summary>
/// Bottom left of the location for the RSIs.
/// </summary>
public required Vector2i Position;
/// <summary>
/// The current width of the level.
/// </summary>
/// <remarks>This can (and will) be 0. Will change.</remarks>
public required int Width;
/// <summary>
/// The current height of the level.
/// </summary>
/// <remarks>This value should never change.</remarks>
public required int Height;
/// <summary>
/// Maximum width of the level.
/// </summary>
public required int MaxWidth;
/// <summary>
/// List of all the RSIs stored in this level. RSIs are ordered from tallest to smallest per level.
/// </summary>
public required List<RSIResource.LoadStepData> RSIList;
}
}

View File

@@ -95,6 +95,12 @@ namespace Robust.Client.UserInterface.Controls
public void Clear()
{
_firstLine = true;
foreach (var entry in _entries)
{
entry.RemoveControls();
}
_entries.Clear();
_totalContentHeight = 0;
_scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight);
@@ -104,6 +110,7 @@ namespace Robust.Client.UserInterface.Controls
public void RemoveEntry(Index index)
{
var entry = _entries[index];
entry.RemoveControls();
_entries.RemoveAt(index.GetOffset(_entries.Count));
var font = _getFont();
@@ -189,6 +196,9 @@ namespace Robust.Client.UserInterface.Controls
if (entryOffset > contentBox.Height)
{
entry.HideControls();
// We know that every subsequent entry will also fail the test, but we also need to
// hide all the controls, so we cannot simply break out of the loop
continue;
}

View File

@@ -14,6 +14,27 @@ namespace Robust.Client.UserInterface.Controls
private StyleBox? _backgroundStyleBoxOverride;
private StyleBox? _foregroundStyleBoxOverride;
private bool _vertical;
/// <summary>
/// Whether the progress bar is oriented vertically.
/// </summary>
/// <remarks>
/// A vertical progress bar fills from bottom to top.
/// </remarks>
public bool Vertical
{
get => _vertical;
set
{
if (_vertical != value)
{
_vertical = value;
InvalidateMeasure();
}
}
}
public StyleBox? BackgroundStyleBoxOverride
{
get => _backgroundStyleBoxOverride;
@@ -70,11 +91,23 @@ namespace Robust.Client.UserInterface.Controls
{
return;
}
var minSize = fg.MinimumSize;
var size = PixelWidth * GetAsRatio() - minSize.X;
if (size > 0)
if (_vertical)
{
fg.Draw(handle, UIBox2.FromDimensions(0, 0, minSize.X + size, PixelHeight), UIScale);
var size = PixelHeight * GetAsRatio();
if (size > 0)
{
fg.Draw(handle, UIBox2.FromDimensions(0, PixelHeight - size, PixelWidth, size), UIScale);
}
}
else
{
var minSize = fg.MinimumSize;
var size = PixelWidth * GetAsRatio() - minSize.X;
if (size > 0)
{
fg.Draw(handle, UIBox2.FromDimensions(0, 0, minSize.X + size, PixelHeight), UIScale);
}
}
}

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using JetBrains.Annotations;
using Robust.Client.Graphics;
@@ -15,8 +17,7 @@ namespace Robust.Client.UserInterface.Controls
{
[Dependency] private readonly MarkupTagManager _tagManager = default!;
private FormattedMessage? _message;
private RichTextEntry _entry;
private RichTextEntry? _entry;
private float _lineHeightScale = 1;
private bool _lineHeightOverride;
@@ -40,19 +41,26 @@ namespace Robust.Client.UserInterface.Controls
public string? Text
{
get => _message?.ToMarkup();
get => _entry?.Message.ToMarkup();
set
{
if (value == null)
{
_message?.Clear();
return;
}
SetMessage(FormattedMessage.FromMarkupPermissive(value));
Clear();
else
SetMessage(FormattedMessage.FromMarkupPermissive(value));
}
}
public void Clear()
{
_entry?.RemoveControls();
_entry = null;
InvalidateMeasure();
}
public IEnumerable<Control> Controls => _entry?.Controls?.Values ?? Enumerable.Empty<Control>();
public IReadOnlyList<MarkupNode> Nodes => _entry?.Message.Nodes ?? Array.Empty<MarkupNode>();
public RichTextLabel()
{
IoCManager.InjectDependencies(this);
@@ -61,8 +69,8 @@ namespace Robust.Client.UserInterface.Controls
public void SetMessage(FormattedMessage message, Type[]? tagsAllowed = null, Color? defaultColor = null)
{
_message = message;
_entry = new RichTextEntry(_message, this, _tagManager, tagsAllowed, defaultColor);
_entry?.RemoveControls();
_entry = new RichTextEntry(message, this, _tagManager, tagsAllowed, defaultColor);
InvalidateMeasure();
}
@@ -73,31 +81,31 @@ namespace Robust.Client.UserInterface.Controls
SetMessage(msg, tagsAllowed, defaultColor);
}
public string? GetMessage() => _message?.ToMarkup();
public string? GetMessage() => _entry?.Message.ToMarkup();
/// <summary>
/// Returns a copy of the currently used formatted message.
/// </summary>
public FormattedMessage? GetFormattedMessage() => _entry == null ? null : new FormattedMessage(_entry.Value.Message);
protected override Vector2 MeasureOverride(Vector2 availableSize)
{
if (_message == null)
{
if (_entry == null)
return Vector2.Zero;
}
var font = _getFont();
_entry.Update(_tagManager, font, availableSize.X * UIScale, UIScale, LineHeightScale);
return new Vector2(_entry.Width / UIScale, _entry.Height / UIScale);
// _entry is nullable struct.
// cannot just call _entry.Value.Update() as that doesn't actually update _entry.
_entry = _entry.Value.Update(_tagManager, font, availableSize.X * UIScale, UIScale, LineHeightScale);
return new Vector2(_entry.Value.Width / UIScale, _entry.Value.Height / UIScale);
}
protected internal override void Draw(DrawingHandleScreen handle)
{
base.Draw(handle);
if (_message == null)
{
return;
}
_entry.Draw(_tagManager, handle, _getFont(), SizeBox, 0, new MarkupDrawingContext(), UIScale, LineHeightScale);
_entry?.Draw(_tagManager, handle, _getFont(), SizeBox, 0, new MarkupDrawingContext(), UIScale, LineHeightScale);
}
[Pure]

View File

@@ -5,7 +5,7 @@ using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public sealed class BoldItalicTag : IMarkupTag
public sealed class BoldItalicTag : IMarkupTagHandler
{
public const string BoldItalicFont = "DefaultBoldItalic";

View File

@@ -6,7 +6,7 @@ using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public sealed class BoldTag : IMarkupTag
public sealed class BoldTag : IMarkupTagHandler
{
public const string BoldFont = "DefaultBold";

View File

@@ -2,7 +2,7 @@ using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public sealed class BulletTag : IMarkupTag
public sealed class BulletTag : IMarkupTagHandler
{
public string Name => "bullet";

View File

@@ -6,7 +6,7 @@ namespace Robust.Client.UserInterface.RichText;
/// <summary>
/// Colors the text inside its opening and closing nodes
/// </summary>
public sealed class ColorTag : IMarkupTag
public sealed class ColorTag : IMarkupTagHandler
{
public static readonly Color DefaultColor = new(200, 200, 200);

View File

@@ -8,14 +8,14 @@ using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public sealed class CommandLinkTag : IMarkupTag
public sealed class CommandLinkTag : IMarkupTagHandler
{
[Dependency] private readonly IClientConsoleHost _clientConsoleHost = default!;
public string Name => "cmdlink";
/// <inheritdoc/>
public bool TryGetControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
public bool TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
{
if (!node.Value.TryGetString(out var text)
|| !node.Attributes.TryGetValue("command", out var commandParameter)

View File

@@ -11,7 +11,7 @@ namespace Robust.Client.UserInterface.RichText;
/// Applies the font provided as the tags parameter to the markup drawing context.
/// Definitely not save for user supplied markup
/// </summary>
public sealed class FontTag : IMarkupTag
public sealed class FontTag : IMarkupTagHandler
{
public const string DefaultFont = "Default";
public const int DefaultSize = 12;

View File

@@ -6,7 +6,7 @@ using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public sealed class HeadingTag : IMarkupTag
public sealed class HeadingTag : IMarkupTagHandler
{
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;

View File

@@ -1,9 +1,16 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public interface IMarkupTag
/// <summary>
/// Classes that implement this interface will be instantiated by <see cref="MarkupTagManager"/> and used to handle
/// the parsing and behaviour of markup tags. Note that each class is only ever instantiated once by the tag manager,
/// and wil be used to handle all tags of that kind, and thus should not contain state information relevant to a
/// specific tag.
/// </summary>
public interface IMarkupTagHandler
{
/// <summary>
/// The string used as the tags name when writing rich text
@@ -54,17 +61,32 @@ public interface IMarkupTag
}
/// <summary>
/// Called inside the constructor of <see cref="RichTextEntry"/> to
/// supply a control that gets rendered inline before this tags children<br/>
/// Text continues to the right of the control until the next line and then continues bellow it
/// Called inside the constructor of <see cref="RichTextEntry"/> to supply a control that gets rendered inline
/// before this tags children. The returned control must be new instance to avoid issues with shallow cloning
/// <see cref="FormattedMessage"/> nodes. Text continues to the right of the control until the next line and
/// then continues bellow it.
/// </summary>
/// <param name="node">The markup node containing the parameter and attributes</param>
/// <param name="control">A UI control for placing in line with this tags children</param>
/// <returns>true if this tag supplies a control</returns>
public bool TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
{
control = null;
return false;
}
}
[Obsolete("Use IMarkupTagHandler")]
public interface IMarkupTag : IMarkupTagHandler
{
bool IMarkupTagHandler.TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
{
return TryGetControl(node, out control);
}
public bool TryGetControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
{
control = null;
return false;
}
}

View File

@@ -6,7 +6,7 @@ using Robust.Shared.Utility;
namespace Robust.Client.UserInterface.RichText;
public sealed class ItalicTag : IMarkupTag
public sealed class ItalicTag : IMarkupTagHandler
{
public const string ItalicFont = "DefaultItalic";

View File

@@ -16,7 +16,7 @@ public sealed class MarkupTagManager
/// <summary>
/// Tags defined in engine need to be instantiated here because of sandboxing
/// </summary>
private readonly Dictionary<string, IMarkupTag> _markupTagTypes = new IMarkupTag[] {
private readonly Dictionary<string, IMarkupTagHandler> _markupTagTypes = new IMarkupTagHandler[] {
new BoldItalicTag(),
new BoldTag(),
new BulletTag(),
@@ -44,13 +44,13 @@ public sealed class MarkupTagManager
public void Initialize()
{
foreach (var type in _reflectionManager.GetAllChildren<IMarkupTag>())
foreach (var type in _reflectionManager.GetAllChildren<IMarkupTagHandler>())
{
//Prevent tags defined inside engine from being instantiated
if (_engineTypes.Contains(type))
continue;
var instance = (IMarkupTag)_sandboxHelper.CreateInstance(type);
var instance = (IMarkupTagHandler)_sandboxHelper.CreateInstance(type);
_markupTagTypes[instance.Name.ToLower()] = instance;
}
@@ -60,22 +60,48 @@ public sealed class MarkupTagManager
}
}
[Obsolete("Use GetMarkupTagHandler")]
public IMarkupTag? GetMarkupTag(string name)
{
return _markupTagTypes.GetValueOrDefault(name) as IMarkupTag;
}
public IMarkupTagHandler? GetMarkupTagHandler(string name)
{
return _markupTagTypes.GetValueOrDefault(name);
}
public bool TryGetMarkupTag(string name, Type[]? tagsAllowed, [NotNullWhen(true)] out IMarkupTag? tag)
/// <summary>
/// Attempt to get the tag handler with the corresponding name.
/// </summary>
/// <param name="name">The name of the tag, as specified by <see cref="IMarkupTag.Name"/></param>
/// <param name="tagsAllowed">List of allowed tag types. If null, all types are allowed.</param>
/// <param name="handler">The instance responsible for handling tags of this type.</param>
/// <returns></returns>
public bool TryGetMarkupTagHandler(string name, Type[]? tagsAllowed, [NotNullWhen(true)] out IMarkupTagHandler? handler)
{
if (_markupTagTypes.TryGetValue(name, out var markupTag)
// Using a whitelist prevents new tags from sneaking in.
&& (tagsAllowed == null || Array.IndexOf(tagsAllowed, markupTag.GetType()) != -1))
{
tag = markupTag;
handler = markupTag;
return true;
}
tag = null;
handler = null;
return false;
}
[Obsolete("Use TryGetMarkupTagHandler")]
public bool TryGetMarkupTag(string name, Type[]? tagsAllowed, [NotNullWhen(true)] out IMarkupTag? tag)
{
if (!TryGetMarkupTagHandler(name, tagsAllowed, out var handler) || handler is not IMarkupTag cast)
{
tag = null;
return false;
}
tag = cast;
return true;
}
}

View File

@@ -13,6 +13,9 @@ namespace Robust.Client.UserInterface
{
/// <summary>
/// Used by <see cref="OutputPanel"/> and <see cref="RichTextLabel"/> to handle rich text layout.
/// Note that if this text is ever removed or modified without removing the owning control,
/// then <see cref="RemoveControls"/> should be called to ensure that any controls that were added by this
/// entry are also removed.
/// </summary>
internal struct RichTextEntry
{
@@ -36,7 +39,7 @@ namespace Robust.Client.UserInterface
/// </summary>
public ValueList<int> LineBreaks;
private readonly Dictionary<int, Control>? _tagControls;
public readonly Dictionary<int, Control>? Controls;
public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Type[]? tagsAllowed = null, Color? defaultColor = null)
{
@@ -56,15 +59,35 @@ namespace Robust.Client.UserInterface
if (node.Name == null)
continue;
if (!tagManager.TryGetMarkupTag(node.Name, _tagsAllowed, out var tag) || !tag.TryGetControl(node, out var control))
if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var handler) || !handler.TryCreateControl(node, out var control))
continue;
// Markup tag handler instances are shared across controls. We need to ensure that the hanlder doesn't
// store state information and return the same control for each rich text entry.
DebugTools.Assert(handler.TryCreateControl(node, out var other) && other != control);
parent.Children.Add(control);
tagControls ??= new Dictionary<int, Control>();
tagControls.Add(nodeIndex, control);
}
_tagControls = tagControls;
Controls = tagControls;
}
// TODO RICH TEXT
// Somehow ensure that this **has** to be called when removing rich text from some control.
/// <summary>
/// Remove all owned controls from their parents.
/// </summary>
public readonly void RemoveControls()
{
if (Controls == null)
return;
foreach (var ctrl in Controls.Values)
{
ctrl.Orphan();
}
}
/// <summary>
@@ -74,7 +97,7 @@ namespace Robust.Client.UserInterface
/// <param name="maxSizeX">The maximum horizontal size of the container of this entry.</param>
/// <param name="uiScale"></param>
/// <param name="lineHeightScale"></param>
public void Update(MarkupTagManager tagManager, Font defaultFont, float maxSizeX, float uiScale, float lineHeightScale = 1)
public RichTextEntry Update(MarkupTagManager tagManager, Font defaultFont, float maxSizeX, float uiScale, float lineHeightScale = 1)
{
// This method is gonna suck due to complexity.
// Bear with me here.
@@ -112,10 +135,10 @@ namespace Robust.Client.UserInterface
continue;
if (ProcessMetric(ref this, metrics, out breakLine))
return;
return this;
}
if (_tagControls == null || !_tagControls.TryGetValue(nodeIndex, out var control))
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
continue;
control.Measure(new Vector2(Width, Height));
@@ -128,12 +151,14 @@ namespace Robust.Client.UserInterface
desiredSize.Y);
if (ProcessMetric(ref this, controlMetrics, out breakLine))
return;
return this;
}
Width = wordWrap.FinalizeText(out breakLine);
CheckLineBreak(ref this, breakLine);
return this;
bool ProcessRune(ref RichTextEntry src, Rune rune, out int? outBreakLine)
{
wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip);
@@ -166,9 +191,10 @@ namespace Robust.Client.UserInterface
internal readonly void HideControls()
{
if (_tagControls == null)
if (Controls == null)
return;
foreach (var control in _tagControls.Values)
foreach (var control in Controls.Values)
{
control.Visible = false;
}
@@ -220,7 +246,7 @@ namespace Robust.Client.UserInterface
globalBreakCounter += 1;
}
if (_tagControls == null || !_tagControls.TryGetValue(nodeIndex, out var control))
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
continue;
// Controls may have been previously hidden via HideControls due to being "out-of frame".
@@ -243,7 +269,7 @@ namespace Robust.Client.UserInterface
return node.Value.StringValue ?? "";
//Skip the node if there is no markup tag for it.
if (!tagManager.TryGetMarkupTag(node.Name, _tagsAllowed, out var tag))
if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var tag))
return "";
if (!node.Closing)

View File

@@ -17,6 +17,7 @@ namespace Robust.Client.Utility
/// </summary>
public static class SpriteSpecifierExt
{
[Obsolete("Use SpriteSystem.GetTexture() instead")]
public static Texture GetTexture(this SpriteSpecifier.Texture texSpecifier, IResourceCache cache)
{
return cache
@@ -24,13 +25,14 @@ namespace Robust.Client.Utility
.Texture;
}
[Obsolete("Use SpriteSystem")]
[Obsolete("Use SpriteSystem.GetState() instead")]
public static RSI.State GetState(this SpriteSpecifier.Rsi rsiSpecifier, IResourceCache cache)
{
if (!cache.TryGetResource<RSIResource>(SpriteSpecifierSerializer.TextureRoot / rsiSpecifier.RsiPath, out var theRsi))
{
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
Logger.Error("SpriteSpecifier failed to load RSI {0}", rsiSpecifier.RsiPath);
return SpriteComponent.GetFallbackState(cache);
return sys.GetFallbackState();
}
if (theRsi.RSI.TryGetState(rsiSpecifier.RsiState, out var state))
@@ -39,21 +41,22 @@ namespace Robust.Client.Utility
}
Logger.Error($"SpriteSpecifier has invalid RSI state '{rsiSpecifier.RsiState}' for RSI: {rsiSpecifier.RsiPath}");
return SpriteComponent.GetFallbackState(cache);
return IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>().GetFallbackState();
}
[Obsolete("Use SpriteSystem")]
[Obsolete("Use SpriteSystem.Frame0() instead")]
public static Texture Frame0(this SpriteSpecifier specifier)
{
return specifier.RsiStateLike().Default;
}
[Obsolete("Use SpriteSystem.RsiStateLike() instead")]
public static IDirectionalTextureProvider DirFrame0(this SpriteSpecifier specifier)
{
return specifier.RsiStateLike();
}
[Obsolete("Use SpriteSystem")]
[Obsolete("Use SpriteSystem.RsiStateLike() instead")]
public static IRsiStateLike RsiStateLike(this SpriteSpecifier specifier)
{
var resC = IoCManager.Resolve<IResourceCache>();
@@ -67,10 +70,11 @@ namespace Robust.Client.Utility
case SpriteSpecifier.EntityPrototype prototypeIcon:
var protMgr = IoCManager.Resolve<IPrototypeManager>();
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
if (!protMgr.TryIndex<EntityPrototype>(prototypeIcon.EntityPrototypeId, out var prototype))
{
Logger.Error("Failed to load PrototypeIcon {0}", prototypeIcon.EntityPrototypeId);
return SpriteComponent.GetFallbackState(resC);
return sys.GetFallbackState();
}
return SpriteComponent.GetPrototypeIcon(prototype, resC);

View File

@@ -39,6 +39,7 @@ public static class Diagnostics
public const string IdForbidLiteral = "RA0033";
public const string IdObsoleteInheritance = "RA0034";
public const string IdObsoleteInheritanceWithMessage = "RA0035";
public const string IdDataFieldYamlSerializable = "RA0036";
public static SuppressionDescriptor MeansImplicitAssignment =>
new SuppressionDescriptor("RADC1000", "CS0649", "Marked as implicitly assigned.");

View File

@@ -297,7 +297,7 @@ namespace Robust.Server
: null;
// Set up the VFS
_resources.Initialize(dataDir);
_resources.Initialize(dataDir, hideUserDataDir: false);
var mountOptions = _commandLineArgs != null
? MountOptions.Merge(_commandLineArgs.MountOptions, Options.MountOptions) : Options.MountOptions;

View File

@@ -104,6 +104,16 @@ namespace Robust.Shared.Maths
return (Direction) (Math.Floor((ang + CardinalOffset) / CardinalSegment) * 2 % 8);
}
/// <summary>
/// Rounds the angle to the nearest cardinal direction. This behaves similarly to a combination of
/// <see cref="GetCardinalDir"/> and Direction.ToAngle(), however this may return an angle outside of the range
/// returned by those methods (-pi to pi).
/// </summary>
public Angle RoundToCardinalAngle()
{
return new Angle(CardinalSegment * Math.Floor((Theta + CardinalOffset) / CardinalSegment));
}
/// <summary>
/// Rotates the vector counter-clockwise around its origin by the value of Theta.
/// </summary>

View File

@@ -6,4 +6,3 @@
[assembly: InternalsVisibleTo("Robust.Client")]
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
[assembly: InternalsVisibleTo("Content.Benchmarks")]

View File

@@ -111,6 +111,11 @@ public abstract class ComponentTreeSystem<TTreeComp, TComp> : EntitySystem
component.TreeUpdateQueued = true;
_updateQueue.Enqueue((component, xform));
}
public void QueueTreeUpdate(Entity<TComp> entity, TransformComponent? xform = null)
{
QueueTreeUpdate(entity.Owner, entity.Comp, xform);
}
#endregion
#region Component Management

View File

@@ -88,6 +88,7 @@ namespace Robust.Shared.ContentPack
public string SystemAssemblyName = default!;
public HashSet<VerifierError> AllowedVerifierErrors = default!;
public List<string> WhitelistedNamespaces = default!;
public List<string> AllowedAssemblyPrefixes = default!;
public Dictionary<string, Dictionary<string, TypeConfig>> Types = default!;
}

View File

@@ -131,6 +131,16 @@ namespace Robust.Shared.ContentPack
return false;
}
#pragma warning disable RA0004
var loadedConfig = _config.Result;
#pragma warning restore RA0004
if (!loadedConfig.AllowedAssemblyPrefixes.Any(allowedNamePrefix => asmName.StartsWith(allowedNamePrefix)))
{
_sawmill.Error($"Assembly name '{asmName}' is not allowed for a content assembly");
return false;
}
if (VerifyIL)
{
if (!DoVerifyIL(asmName, resolver, peReader, reader))
@@ -179,10 +189,6 @@ namespace Robust.Shared.ContentPack
return true;
}
#pragma warning disable RA0004
var loadedConfig = _config.Result;
#pragma warning restore RA0004
var badRefs = new ConcurrentBag<EntityHandle>();
// We still do explicit type reference scanning, even though the actual whitelists work with raw members.

View File

@@ -60,9 +60,7 @@ namespace Robust.Shared.ContentPack
internal string GetPath(ResPath relPath)
{
return Path.GetFullPath(Path.Combine(_directory.FullName, relPath.ToRelativeSystemPath()))
// Sanitise platform-specific path and standardize it for engine use.
.Replace(Path.DirectorySeparatorChar, '/');
return PathHelpers.SafeGetResourcePath(_directory.FullName, relPath);
}
/// <inheritdoc />

View File

@@ -14,7 +14,11 @@ namespace Robust.Shared.ContentPack
/// The directory to use for user data.
/// If null, a virtual temporary file system is used instead.
/// </param>
void Initialize(string? userData);
/// <param name="hideUserDataDir">
/// If true, <see cref="IWritableDirProvider.RootDir"/> will be hidden on
/// <see cref="IResourceManager.UserData"/>.
/// </param>
void Initialize(string? userData, bool hideUserDataDir);
/// <summary>
/// Mounts a single stream as a content file. Useful for unit testing.

View File

@@ -13,7 +13,7 @@ namespace Robust.Shared.ContentPack
{
/// <summary>
/// The root path of this provider.
/// Can be null if it's a virtual provider.
/// Can be null if it's a virtual provider or the path is protected (e.g. on the client).
/// </summary>
string? RootDir { get; }

View File

@@ -93,19 +93,23 @@ namespace Robust.Shared.ContentPack
{
var sw = Stopwatch.StartNew();
Sawmill.Debug("LOADING modules");
var files = new Dictionary<string, (ResPath Path, string[] references)>();
var files = new Dictionary<string, (ResPath Path, MemoryStream data, string[] references)>();
// Find all modules we want to load.
foreach (var fullPath in paths)
{
using var asmFile = _res.ContentFileRead(fullPath);
var refData = GetAssemblyReferenceData(asmFile);
var ms = new MemoryStream();
asmFile.CopyTo(ms);
ms.Position = 0;
var refData = GetAssemblyReferenceData(ms);
if (refData == null)
continue;
var (asmRefs, asmName) = refData.Value;
if (!files.TryAdd(asmName, (fullPath, asmRefs)))
if (!files.TryAdd(asmName, (fullPath, ms, asmRefs)))
{
Sawmill.Error("Found multiple modules with the same assembly name " +
$"'{asmName}', A: {files[asmName].Path}, B: {fullPath}.");
@@ -122,10 +126,10 @@ namespace Robust.Shared.ContentPack
Parallel.ForEach(files, pair =>
{
var (name, (path, _)) = pair;
var (name, (_, data, _)) = pair;
using var stream = _res.ContentFileRead(path);
if (!typeChecker.CheckAssembly(stream, resolver))
data.Position = 0;
if (!typeChecker.CheckAssembly(data, resolver))
{
throw new TypeCheckFailedException($"Assembly {name} failed type checks.");
}
@@ -137,14 +141,15 @@ namespace Robust.Shared.ContentPack
var nodes = TopologicalSort.FromBeforeAfter(
files,
kv => kv.Key,
kv => kv.Value.Path,
kv => kv.Value,
_ => Array.Empty<string>(),
kv => kv.Value.references,
allowMissing: true); // missing refs would be non-content assemblies so allow that.
// Actually load them in the order they depend on each other.
foreach (var path in TopologicalSort.Sort(nodes))
foreach (var item in TopologicalSort.Sort(nodes))
{
var (path, memory, _) = item;
Sawmill.Debug($"Loading module: '{path}'");
try
{
@@ -156,9 +161,9 @@ namespace Robust.Shared.ContentPack
}
else
{
using var assemblyStream = _res.ContentFileRead(path);
memory.Position = 0;
using var symbolsStream = _res.ContentFileReadOrNull(path.WithExtension("pdb"));
LoadGameAssembly(assemblyStream, symbolsStream, skipVerify: true);
LoadGameAssembly(memory, symbolsStream, skipVerify: true);
}
}
catch (Exception e)
@@ -174,7 +179,7 @@ namespace Robust.Shared.ContentPack
private (string[] refs, string name)? GetAssemblyReferenceData(Stream stream)
{
using var reader = ModLoader.MakePEReader(stream);
using var reader = ModLoader.MakePEReader(stream, leaveOpen: true);
var metaReader = reader.GetMetadataReader();
var name = metaReader.GetString(metaReader.GetAssemblyDefinition().Name);

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using Robust.Shared.Utility;
namespace Robust.Shared.ContentPack
{
@@ -63,5 +64,27 @@ namespace Robust.Shared.ContentPack
!OperatingSystem.IsWindows()
&& !OperatingSystem.IsMacOS();
internal static string SafeGetResourcePath(string baseDir, ResPath path)
{
var relSysPath = path.ToRelativeSystemPath();
if (relSysPath.Contains("\\..") || relSysPath.Contains("/.."))
{
// Hard cap on any exploit smuggling a .. in there.
// Since that could allow leaving sandbox.
throw new InvalidOperationException($"This branch should never be reached. Path: {path}");
}
var retPath = Path.GetFullPath(Path.Join(baseDir, relSysPath));
// better safe than sorry check
if (!retPath.StartsWith(baseDir))
{
// Allow path to match if it's just missing the directory separator at the end.
if (retPath != baseDir.TrimEnd(Path.DirectorySeparatorChar))
throw new InvalidOperationException($"This branch should never be reached. Path: {path}");
}
return retPath;
}
}
}

View File

@@ -41,13 +41,13 @@ namespace Robust.Shared.ContentPack
public IWritableDirProvider UserData { get; private set; } = default!;
/// <inheritdoc />
public virtual void Initialize(string? userData)
public virtual void Initialize(string? userData, bool hideRootDir)
{
Sawmill = _logManager.GetSawmill("res");
if (userData != null)
{
UserData = new WritableDirProvider(Directory.CreateDirectory(userData));
UserData = new WritableDirProvider(Directory.CreateDirectory(userData), hideRootDir);
}
else
{
@@ -379,6 +379,10 @@ namespace Robust.Shared.ContentPack
{
var rootDir = loader.GetPath(new ResPath(@"/"));
// TODO: GET RID OF THIS.
// This code shouldn't be passing OS disk paths through ResPath.
rootDir = rootDir.Replace(Path.DirectorySeparatorChar, '/');
yield return new ResPath(rootDir);
}
}

View File

@@ -17,6 +17,10 @@ WhitelistedNamespaces:
- Content
- OpenDreamShared
AllowedAssemblyPrefixes:
- OpenDream
- Content
# The type whitelist does NOT care about which assembly types come from.
# This is because types switch assembly all the time.
# Just look up stuff like StreamReader on https://apisof.net.
@@ -875,6 +879,7 @@ Types:
- "System.Text.StringBuilder Insert(int, object)"
- "System.Text.StringBuilder Insert(int, sbyte)"
- "System.Text.StringBuilder Insert(int, short)"
- "System.Text.StringBuilder Insert(int, string)"
- "System.Text.StringBuilder Insert(int, string, int)"
- "System.Text.StringBuilder Insert(int, System.Decimal)"
- "System.Text.StringBuilder Insert(int, System.ReadOnlySpan`1<char>)"

View File

@@ -10,17 +10,22 @@ namespace Robust.Shared.ContentPack
/// <inheritdoc />
internal sealed class WritableDirProvider : IWritableDirProvider
{
/// <inheritdoc />
private readonly bool _hideRootDir;
public string RootDir { get; }
string? IWritableDirProvider.RootDir => _hideRootDir ? null : RootDir;
/// <summary>
/// Constructs an instance of <see cref="WritableDirProvider"/>.
/// </summary>
/// <param name="rootDir">Root file system directory to allow writing.</param>
public WritableDirProvider(DirectoryInfo rootDir)
/// <param name="hideRootDir">If true, <see cref="IWritableDirProvider.RootDir"/> is reported as null.</param>
public WritableDirProvider(DirectoryInfo rootDir, bool hideRootDir)
{
// FullName does not have a trailing separator, and we MUST have a separator.
RootDir = rootDir.FullName + Path.DirectorySeparatorChar.ToString();
_hideRootDir = hideRootDir;
}
#region File Access
@@ -119,7 +124,7 @@ namespace Robust.Shared.ContentPack
throw new FileNotFoundException();
var dirInfo = new DirectoryInfo(GetFullPath(path));
return new WritableDirProvider(dirInfo);
return new WritableDirProvider(dirInfo, _hideRootDir);
}
/// <inheritdoc />
@@ -180,20 +185,7 @@ namespace Robust.Shared.ContentPack
path = path.Clean();
return GetFullPath(RootDir, path);
}
private static string GetFullPath(string root, ResPath path)
{
var relPath = path.ToRelativeSystemPath();
if (relPath.Contains("\\..") || relPath.Contains("/.."))
{
// Hard cap on any exploit smuggling a .. in there.
// Since that could allow leaving sandbox.
throw new InvalidOperationException($"This branch should never be reached. Path: {path}");
}
return Path.GetFullPath(Path.Combine(root, relPath));
return PathHelpers.SafeGetResourcePath(RootDir, path);
}
}
}

View File

@@ -273,11 +273,11 @@ namespace Robust.Shared.GameObjects
public IComponent GetComponent(Type componentType)
{
if (!_types.ContainsKey(componentType))
if (!_types.TryGetValue(componentType, out var value))
{
throw new InvalidOperationException($"{componentType} is not a registered component.");
}
return _typeFactory.CreateInstanceUnchecked<IComponent>(_types[componentType].Type);
return _typeFactory.CreateInstanceUnchecked<IComponent>(value.Type);
}
public IComponent GetComponent(CompIdx componentType)
@@ -287,11 +287,11 @@ namespace Robust.Shared.GameObjects
public T GetComponent<T>() where T : IComponent, new()
{
if (!_types.ContainsKey(typeof(T)))
if (!_types.TryGetValue(typeof(T), out var reg))
{
throw new InvalidOperationException($"{typeof(T)} is not a registered component.");
}
return _typeFactory.CreateInstanceUnchecked<T>(_types[typeof(T)].Type);
return _typeFactory.CreateInstanceUnchecked<T>(reg.Type);
}
public IComponent GetComponent(ComponentRegistration reg)

View File

@@ -150,6 +150,7 @@ namespace Robust.Shared.GameObjects
[ViewVariables, Access(typeof(EntityManager), Other = AccessPermissions.ReadExecute)]
public EntityLifeStage EntityLifeStage { get; internal set; }
[ViewVariables(VVAccess.ReadOnly)]
public MetaDataFlags Flags
{
get => _flags;

View File

@@ -80,4 +80,8 @@ public enum LayerRenderingStrategy
SnapToCardinals,
NoRotation,
UseSpriteStrategy
// TODO SPRITE
// Refactor this make the sprites strategy the actual default.
// That way layers have to opt in to having a custom strategy, instead of opt out.
// Also rename default to make it clear that its not actually the default, instead I guess its "WithRotation"?
}

View File

@@ -167,7 +167,7 @@ namespace Robust.Shared.GameObjects
if (!Initialized)
return;
_entMan.System<SharedTransformSystem>().RaiseMoveEvent((Owner, this, meta), _parent, _localPosition, oldRotation, MapUid);
_entMan.System<SharedTransformSystem>().RaiseMoveEvent((Owner, this, meta), _parent, _localPosition, oldRotation, MapUid, checkTraversal: false);
}
}

View File

@@ -11,7 +11,7 @@ public record struct Entity<T> : IFluentEntityUid, IAsType<EntityUid>
{
public EntityUid Owner;
public T Comp;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T comp)
{
@@ -48,9 +48,9 @@ public record struct Entity<T> : IFluentEntityUid, IAsType<EntityUid>
}
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T?> AsNullable() => new(Owner, Comp);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T?> AsNullable() => new(Owner, Comp);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -60,7 +60,7 @@ public record struct Entity<T1, T2> : IFluentEntityUid, IAsType<EntityUid>
public EntityUid Owner;
public T1 Comp1;
public T2 Comp2;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2)
{
@@ -119,9 +119,9 @@ public record struct Entity<T1, T2> : IFluentEntityUid, IAsType<EntityUid>
return new Entity<T1>(ent.Owner, ent.Comp1);
}
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?> AsNullable() => new(Owner, Comp1, Comp2);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?> AsNullable() => new(Owner, Comp1, Comp2);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -132,7 +132,7 @@ public record struct Entity<T1, T2, T3> : IFluentEntityUid, IAsType<EntityUid>
public T1 Comp1;
public T2 Comp2;
public T3 Comp3;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3)
{
@@ -226,9 +226,9 @@ public record struct Entity<T1, T2, T3> : IFluentEntityUid, IAsType<EntityUid>
#endregion
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?, T3?> AsNullable() => new(Owner, Comp1, Comp2, Comp3);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?, T3?> AsNullable() => new(Owner, Comp1, Comp2, Comp3);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -240,7 +240,7 @@ public record struct Entity<T1, T2, T3, T4> : IFluentEntityUid, IAsType<EntityUi
public T2 Comp2;
public T3 Comp3;
public T4 Comp4;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4)
{
@@ -357,9 +357,9 @@ public record struct Entity<T1, T2, T3, T4> : IFluentEntityUid, IAsType<EntityUi
#endregion
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?, T3?, T4?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?, T3?, T4?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -372,7 +372,7 @@ public record struct Entity<T1, T2, T3, T4, T5> : IFluentEntityUid, IAsType<Enti
public T3 Comp3;
public T4 Comp4;
public T5 Comp5;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5)
{
@@ -512,9 +512,9 @@ public record struct Entity<T1, T2, T3, T4, T5> : IFluentEntityUid, IAsType<Enti
#endregion
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?, T3?, T4?, T5?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?, T3?, T4?, T5?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -528,7 +528,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6> : IFluentEntityUid, IAsType<
public T4 Comp4;
public T5 Comp5;
public T6 Comp6;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5, T6 comp6)
{
@@ -691,9 +691,9 @@ public record struct Entity<T1, T2, T3, T4, T5, T6> : IFluentEntityUid, IAsType<
#endregion
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?, T3?, T4?, T5?, T6?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?, T3?, T4?, T5?, T6?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -708,7 +708,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7> : IFluentEntityUid, IAsT
public T5 Comp5;
public T6 Comp6;
public T7 Comp7;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5, T6 comp6, T7 comp7)
{
@@ -894,9 +894,9 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7> : IFluentEntityUid, IAsT
#endregion
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7);
public readonly EntityUid AsType() => Owner;
}
[NotYamlSerializable]
@@ -912,7 +912,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7, T8> : IFluentEntityUid,
public T6 Comp6;
public T7 Comp7;
public T8 Comp8;
EntityUid IFluentEntityUid.FluentOwner => Owner;
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5, T6 comp6, T7 comp7, T8 comp8)
{
@@ -1121,7 +1121,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7, T8> : IFluentEntityUid,
#endregion
public override int GetHashCode() => Owner.GetHashCode();
public Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?, T8?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7, Comp8);
public EntityUid AsType() => Owner;
public override readonly int GetHashCode() => Owner.GetHashCode();
public readonly Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?, T8?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7, Comp8);
public readonly EntityUid AsType() => Owner;
}

View File

@@ -52,6 +52,16 @@ public partial class EntityManager
return ents;
}
public EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, params EntProtoId[] protoNames)
{
var ents = new EntityUid[protoNames.Length];
for (var i = 0; i < protoNames.Length; i++)
{
ents[i] = SpawnAttachedTo(protoNames[i], coordinates);
}
return ents;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, List<string?> protoNames)
{
@@ -74,6 +84,14 @@ public partial class EntityManager
return ents;
}
public void SpawnEntitiesAttachedTo(EntityCoordinates coordinates, IEnumerable<EntProtoId> protoNames)
{
foreach (var protoName in protoNames)
{
SpawnAttachedTo(protoName, coordinates);
}
}
public virtual EntityUid SpawnAttachedTo(string? protoName, EntityCoordinates coordinates, ComponentRegistry? overrides = null, Angle rotation = default)
{
if (!coordinates.IsValid(this))

View File

@@ -29,6 +29,8 @@ namespace Robust.Shared.GameObjects
[Dependency] private readonly IReplayRecordingManager _replayMan = default!;
[Dependency] protected readonly ILocalizationManager Loc = default!;
protected IComponentFactory Factory => EntityManager.ComponentFactory;
public ISawmill Log { get; private set; } = default!;
protected virtual string SawmillName

View File

@@ -22,6 +22,8 @@ public partial interface IEntityManager
EntityUid[] SpawnEntities(MapCoordinates coordinates, params string?[] protoNames);
EntityUid[] SpawnEntities(MapCoordinates coordinates, string? prototype, int count);
EntityUid[] SpawnEntities(MapCoordinates coordinates, List<string?> protoNames);
void SpawnEntitiesAttachedTo(EntityCoordinates coordinates, IEnumerable<EntProtoId> protoNames);
EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, params EntProtoId[] protoNames);
EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, List<string?> protoNames);
EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, params string?[] protoNames);

View File

@@ -88,6 +88,7 @@ public sealed class SharedGridTraversalSystem : EntitySystem
public void CheckTraversal(EntityUid entity, TransformComponent xform, EntityUid map)
{
// TODO: This should probably check center of mass.
DebugTools.Assert(!HasComp<MapGridComponent>(entity));
DebugTools.Assert(!HasComp<MapComponent>(entity));

View File

@@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using Robust.Shared.Collections;
using Robust.Shared.GameStates;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
@@ -833,7 +834,7 @@ public abstract partial class SharedMapSystem
}
var offset = chunk.GridTileToChunkTile(gridIndices);
SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile);
SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile, out _);
}
public void SetTiles(EntityUid uid, MapGridComponent grid, List<(Vector2i GridIndices, Tile Tile)> tiles)
@@ -842,6 +843,11 @@ public abstract partial class SharedMapSystem
return;
var modified = new HashSet<MapChunk>(Math.Max(1, tiles.Count / grid.ChunkSize));
var tileChanges = new ValueList<TileChangedEntry>(tiles.Count);
// Suppress sending out events for each tile changed
// We're going to send them all out together at the end
MapManager.SuppressOnTileChanged = true;
foreach (var (gridIndices, tile) in tiles)
{
@@ -859,8 +865,11 @@ public abstract partial class SharedMapSystem
var offset = chunk.GridTileToChunkTile(gridIndices);
chunk.SuppressCollisionRegeneration = true;
if (SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile))
if (SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile, out var oldTile))
{
modified.Add(chunk);
tileChanges.Add(new TileChangedEntry(tile, oldTile, offset, gridIndices));
}
}
foreach (var chunk in modified)
@@ -869,6 +878,13 @@ public abstract partial class SharedMapSystem
}
RegenerateCollision(uid, grid, modified);
// Notify of all tile changes in one event
var ev = new TileChangedEvent((uid, grid), tileChanges.ToArray());
RaiseLocalEvent(uid, ref ev, true);
// Back to normal
MapManager.SuppressOnTileChanged = false;
}
public TilesEnumerator GetLocalTilesEnumerator(EntityUid uid, MapGridComponent grid, Box2 aabb,

View File

@@ -14,9 +14,9 @@ public abstract partial class SharedMapSystem
/// <param name="xIndex">The X tile index relative to the chunk.</param>
/// <param name="yIndex">The Y tile index relative to the chunk.</param>
/// <param name="tile">The new tile to insert.</param>
internal bool SetChunkTile(EntityUid uid, MapGridComponent grid, MapChunk chunk, ushort xIndex, ushort yIndex, Tile tile)
internal bool SetChunkTile(EntityUid uid, MapGridComponent grid, MapChunk chunk, ushort xIndex, ushort yIndex, Tile tile, out Tile oldTile)
{
if (!chunk.TrySetTile(xIndex, yIndex, tile, out var oldTile, out var shapeChanged))
if (!chunk.TrySetTile(xIndex, yIndex, tile, out oldTile, out var shapeChanged))
return false;
var tileIndices = new Vector2i(xIndex, yIndex);

View File

@@ -173,45 +173,61 @@ namespace Robust.Shared.GameObjects
}
/// <summary>
/// Arguments for when a single tile on a grid is changed locally or remotely.
/// Raised directed at the grid when tiles are changed locally or remotely.
/// </summary>
[ByRefEvent]
public readonly record struct TileChangedEvent
{
/// <summary>
/// Creates a new instance of this class.
/// </summary>
/// <inheritdoc cref="TileChangedEvent(Entity{MapGridComponent}, Tile, Tile, Vector2i, Vector2i)"/>
public TileChangedEvent(Entity<MapGridComponent> entity, TileRef newTile, Tile oldTile, Vector2i chunkIndex)
: this(entity, newTile.Tile, oldTile, chunkIndex, newTile.GridIndices) { }
/// <summary>
/// Creates a new instance of this event for a single changed tile.
/// </summary>
/// <param name="entity">The grid entity containing the changed tile(s)</param>
/// <param name="newTile">New tile that replaced the old one.</param>
/// <param name="oldTile">Old tile that was replaced.</param>
/// <param name="chunkIndex">The index of the grid-chunk that this tile belongs to.</param>
/// <param name="gridIndices">The positional indices of this tile on the grid.</param>
public TileChangedEvent(Entity<MapGridComponent> entity, Tile newTile, Tile oldTile, Vector2i chunkIndex, Vector2i gridIndices)
{
Entity = entity;
NewTile = newTile;
OldTile = oldTile;
ChunkIndex = chunkIndex;
Changes = [new TileChangedEntry(newTile, oldTile, chunkIndex, gridIndices)];
}
/// <summary>
/// Was the tile previously empty or is it now empty.
/// Creates a new instance of this event for multiple changed tiles.
/// </summary>
public bool EmptyChanged => OldTile.IsEmpty != NewTile.Tile.IsEmpty;
public TileChangedEvent(Entity<MapGridComponent> entity, TileChangedEntry[] changes)
{
Entity = entity;
Changes = changes;
}
/// <summary>
/// Entity of the grid with the tile-change. TileRef stores the GridId.
/// Entity of the grid with the tile-change. TileRef stores the GridId.
/// </summary>
public readonly Entity<MapGridComponent> Entity;
/// <summary>
/// New tile that replaced the old one.
/// An array of all the tiles that were changed.
/// </summary>
public readonly TileRef NewTile;
public readonly TileChangedEntry[] Changes;
}
/// <summary>
/// Data about a single tile that was changed as part of a <see cref="TileChangedEvent"/>.
/// </summary>
/// <param name="NewTile">New tile that replaced the old one.</param>
/// <param name="OldTile">Old tile that was replaced.</param>
/// <param name="ChunkIndex">The index of the grid-chunk that this tile belongs to.</param>
/// <param name="GridIndices">The positional indices of this tile on the grid.</param>
public readonly record struct TileChangedEntry(Tile NewTile, Tile OldTile, Vector2i ChunkIndex, Vector2i GridIndices)
{
/// <summary>
/// Old tile that was replaced.
/// Was the tile previously empty or is it now empty.
/// </summary>
public readonly Tile OldTile;
/// <summary>
/// The index of the grid-chunk that this tile belongs to.
/// </summary>
public readonly Vector2i ChunkIndex;
public bool EmptyChanged => OldTile.IsEmpty != NewTile.IsEmpty;
}
}

View File

@@ -651,19 +651,30 @@ public abstract partial class SharedTransformSystem
if (newMapId == ent.Comp1.MapID)
return;
EntityUid? newMap = newMapId == MapId.Nullspace ? null : _map.GetMap(newMapId);
var mapPaused = _map.IsPaused(newMapId);
EntityUid? newUid = newMapId == MapId.Nullspace ? null : _map.GetMap(newMapId);
bool? mapPaused = null;
ChangeMapIdRecursive(ent, newMap, newMapId, mapPaused);
// Client may be moving entities across maps due to things leaving or entering PVS range.
// In that case, we don't want to pause or unpause entities.
if (!_gameTiming.ApplyingState)
{
mapPaused = _map.IsPaused(newMapId);
_metaData.SetEntityPaused(ent.Owner, mapPaused.Value, ent.Comp2);
}
ChangeMapIdRecursive(ent, newUid, newMapId, mapPaused);
}
private void ChangeMapIdRecursive(
Entity<TransformComponent, MetaDataComponent> ent,
EntityUid? newMap,
MapId newMapId,
bool paused)
bool? paused)
{
_metaData.SetEntityPaused(ent.Owner, paused, ent.Comp2);
if (paused is { } p)
{
_metaData.SetEntityPaused(ent.Owner, p, ent.Comp2);
}
if ((ent.Comp2.Flags & MetaDataFlags.ExtraTransformEvents) != 0)
{
@@ -1258,7 +1269,13 @@ public abstract partial class SharedTransformSystem
if (!xform.Initialized)
return;
RaiseMoveEvent((uid, xform, meta), oldParent, oldPosition, oldRotation, xform.MapUid);
RaiseMoveEvent(
(uid, xform, meta),
oldParent,
oldPosition,
oldRotation,
xform.MapUid,
checkTraversal: !oldPosition.Equals(pos));
}
#endregion

View File

@@ -70,11 +70,14 @@ namespace Robust.Shared.GameObjects
private void MapManagerOnTileChanged(ref TileChangedEvent e)
{
if(e.NewTile.Tile != Tile.Empty)
return;
foreach (var change in e.Changes)
{
if(change.NewTile != Tile.Empty)
continue;
// TODO optimize this for when multiple tiles get empties simultaneously (e.g., explosions).
DeparentAllEntsOnTile(e.NewTile.GridUid, e.NewTile.GridIndices);
// TODO optimize this for when multiple tiles get empties simultaneously (e.g., explosions).
DeparentAllEntsOnTile(e.Entity, change.GridIndices);
}
}
/// <summary>
@@ -265,7 +268,8 @@ namespace Robust.Shared.GameObjects
EntityUid oldParent,
Vector2 oldPosition,
Angle oldRotation,
EntityUid? oldMap)
EntityUid? oldMap,
bool checkTraversal = true)
{
var pos = ent.Comp1._parent == EntityUid.Invalid
? default
@@ -295,7 +299,10 @@ namespace Robust.Shared.GameObjects
// Finally, handle grid traversal. This is handled separately to avoid out-of-order move events.
// I.e., if the traversal raises its own move event, this ensures that all the old move event handlers
// have finished running first. Ideally this shouldn't be required, but this is here just in case
_traversal.CheckTraverse(ent);
if (checkTraversal)
{
_traversal.CheckTraverse(ent);
}
}
}

View File

@@ -1,8 +1,8 @@
using System.Numerics;
using Robust.Shared.GameObjects;
using Robust.Shared.Maths;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Dynamics;
using Robust.Shared.Utility;
namespace Robust.Shared.Physics.Events;
@@ -20,9 +20,16 @@ public readonly struct StartCollideEvent
public readonly Fixture OurFixture;
public readonly Fixture OtherFixture;
public readonly Vector2 WorldPoint;
public StartCollideEvent(
internal readonly FixedArray2<Vector2> _worldPoints;
public readonly int PointCount;
public readonly Vector2 WorldNormal;
public Vector2[] WorldPoints => _worldPoints.AsSpan[..PointCount].ToArray();
internal StartCollideEvent(
EntityUid ourEntity,
EntityUid otherEntity,
string ourFixtureId,
@@ -31,7 +38,9 @@ public readonly struct StartCollideEvent
Fixture otherFixture,
PhysicsComponent ourBody,
PhysicsComponent otherBody,
Vector2 worldPoint)
FixedArray2<Vector2> worldPoints,
int pointCount,
Vector2 worldNormal)
{
OurEntity = ourEntity;
OtherEntity = otherEntity;
@@ -39,8 +48,10 @@ public readonly struct StartCollideEvent
OtherFixtureId = otherFixtureId;
OurFixture = ourFixture;
OtherFixture = otherFixture;
WorldPoint = worldPoint;
OtherBody = otherBody;
OurBody = ourBody;
_worldPoints = worldPoints;
PointCount = pointCount;
WorldNormal = worldNormal;
}
}

View File

@@ -294,6 +294,14 @@ public abstract partial class SharedPhysicsSystem
DebugTools.Assert(!fixB.Contacts.ContainsKey(fixA));
fixB.Contacts.Add(fixA, contact);
bodB.Contacts.AddLast(contact.BodyBNode);
// If it's a spawned static ent then need to wake any contacting entities.
// The issue is that static ents can never be awake and if it spawns on an asleep entity never gets a contact.
// Checking only bodyA should be okay because if bodyA is the other ent (i.e. dynamic / kinematic) then it should already be awake.
if (bodyA.BodyType == BodyType.Static && !bodyB.Awake)
{
WakeBody(uidB, body: bodyB);
}
}
/// <summary>
@@ -350,10 +358,10 @@ public abstract partial class SharedPhysicsSystem
if (contact.Manifold.PointCount > 0 && contact.FixtureA?.Hard == true && contact.FixtureB?.Hard == true)
{
if (bodyA.CanCollide)
if (bodyA.CanCollide && !TerminatingOrDeleted(aUid))
SetAwake((aUid, bodyA), true);
if (bodyB.CanCollide)
if (bodyB.CanCollide && !TerminatingOrDeleted(bUid))
SetAwake((bUid, bodyB), true);
}
@@ -543,7 +551,7 @@ public abstract partial class SharedPhysicsSystem
}
var status = ArrayPool<ContactStatus>.Shared.Rent(index);
var worldPoints = ArrayPool<Vector2>.Shared.Rent(index);
var worldPoints = ArrayPool<FixedArray4<Vector2>>.Shared.Rent(index);
// Update contacts all at once.
BuildManifolds(contacts, index, status, worldPoints);
@@ -578,9 +586,11 @@ public abstract partial class SharedPhysicsSystem
var uidA = contact.EntityA;
var uidB = contact.EntityB;
var worldPoint = worldPoints[i];
var points = new FixedArray2<Vector2>(worldPoint._00, worldPoint._01);
var worldNormal = worldPoint._02;
var ev1 = new StartCollideEvent(uidA, uidB, contact.FixtureAId, contact.FixtureBId, fixtureA, fixtureB, bodyA, bodyB, worldPoint);
var ev2 = new StartCollideEvent(uidB, uidA, contact.FixtureBId, contact.FixtureAId, fixtureB, fixtureA, bodyB, bodyA, worldPoint);
var ev1 = new StartCollideEvent(uidA, uidB, contact.FixtureAId, contact.FixtureBId, fixtureA, fixtureB, bodyA, bodyB, points, contact.Manifold.PointCount, worldNormal);
var ev2 = new StartCollideEvent(uidB, uidA, contact.FixtureBId, contact.FixtureAId, fixtureB, fixtureA, bodyB, bodyA, points, contact.Manifold.PointCount, worldNormal);
RaiseLocalEvent(uidA, ref ev1, true);
RaiseLocalEvent(uidB, ref ev2, true);
@@ -618,10 +628,10 @@ public abstract partial class SharedPhysicsSystem
ArrayPool<Contact>.Shared.Return(contacts);
ArrayPool<ContactStatus>.Shared.Return(status);
ArrayPool<Vector2>.Shared.Return(worldPoints);
ArrayPool<FixedArray4<Vector2>>.Shared.Return(worldPoints);
}
private void BuildManifolds(Contact[] contacts, int count, ContactStatus[] status, Vector2[] worldPoints)
private void BuildManifolds(Contact[] contacts, int count, ContactStatus[] status, FixedArray4<Vector2>[] worldPoints)
{
if (count == 0)
return;
@@ -664,7 +674,7 @@ public abstract partial class SharedPhysicsSystem
public Contact[] Contacts;
public ContactStatus[] Status;
public Vector2[] WorldPoints;
public FixedArray4<Vector2>[] WorldPoints;
public bool[] Wake;
public void Execute(int index)
@@ -673,7 +683,7 @@ public abstract partial class SharedPhysicsSystem
}
}
private void UpdateContact(Contact[] contacts, int index, ContactStatus[] status, bool[] wake, Vector2[] worldPoints)
private void UpdateContact(Contact[] contacts, int index, ContactStatus[] status, bool[] wake, FixedArray4<Vector2>[] worldPoints)
{
var contact = contacts[index];
@@ -698,7 +708,11 @@ public abstract partial class SharedPhysicsSystem
if (contactStatus == ContactStatus.StartTouching)
{
worldPoints[index] = Physics.Transform.Mul(bodyATransform, contacts[index].Manifold.LocalPoint);
var points = new FixedArray4<Vector2>();
contact.GetWorldManifold(bodyATransform, bodyBTransform, out var worldNormal, points.AsSpan);
// Use the 3rd Vector2 as the world normal, 4th is blank.
points._02 = worldNormal;
worldPoints[index] = points;
}
}

View File

@@ -9,7 +9,6 @@
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
[assembly: InternalsVisibleTo("OpenToolkit.GraphicsLibraryFramework")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Gives access to Castle(Moq)
[assembly: InternalsVisibleTo("Content.Benchmarks")]
[assembly: InternalsVisibleTo("Robust.Benchmarks")]
[assembly: InternalsVisibleTo("Robust.Client.WebView")]
[assembly: InternalsVisibleTo("Robust.Packaging")]

View File

@@ -306,6 +306,11 @@ public interface IPrototypeManager
/// </summary>
void RegisterIgnore(string name);
/// <summary>
/// Checks whether the given gind name has been marked as ignored via <see cref="RegisterIgnore"/>
/// </summary>
bool IsIgnored(string name);
/// <summary>
/// Loads several prototype kinds into the manager. Note that this will re-build a frozen dictionary and should be avoided if possible.
/// </summary>

View File

@@ -920,6 +920,8 @@ namespace Robust.Shared.Prototypes
return TryGetKindFrom(typeof(T), out kind);
}
public bool IsIgnored(string name) => _ignoredPrototypeTypes.Contains(name);
/// <inheritdoc />
public void RegisterIgnore(string name)
{
@@ -973,6 +975,17 @@ namespace Robust.Shared.Prototypes
var name = attribute.Type ?? CalculatePrototypeName(kind);
if (_ignoredPrototypeTypes.Contains(name))
{
// For whatever reason, we are registering a prototype despite it having been marked as ignored.
// This often happens when someone is moving a server or client prototype to shared. Maybe this should
// log an error, but I want to avoid breaking changes and maaaaybe there some weird instance where you
// want the client to know that a prototype kind exists, without having the client load information
// about the individual prototypes? So for now lets just log a warning instead of introducing breaking
// changes.
Sawmill.Warning($"Registering an ignored prototype {kind}");
}
if (_kindNames.TryGetValue(name, out var existing))
{
throw new InvalidImplementationException(kind,

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using JetBrains.Annotations;
using NetSerializer;
namespace Robust.Shared.Serialization;
/// <summary>
/// Custom serializer implementation for <see cref="BitArray"/>.
/// </summary>
/// <remarks>
/// <para>
/// This type is necessary as, since .NET 10, the internal layout of <see cref="BitArray"/> was changed.
/// The type now (internally) implements <see cref="ISerializable"/> for backwards compatibility with existing
/// <c>BinaryFormatter</c> code, but NetSerializer does not support <see cref="ISerializable"/>.
/// </para>
/// <para>
/// This code is designed to be backportable &amp; network compatible with the previous behavior on .NET 9.
/// </para>
/// </remarks>
internal sealed class NetBitArraySerializer : IStaticTypeSerializer
{
// For reference, the layout of BitArray before .NET 10 was:
// private int[] m_array;
// private int m_length;
// private int _version;
// NetSerializer serialized these in the following order (sorted by name):
// _version, m_array, m_length
public bool Handles(Type type)
{
return type == typeof(BitArray);
}
public IEnumerable<Type> GetSubtypes(Type type)
{
return [typeof(int[]), typeof(int)];
}
public MethodInfo GetStaticWriter(Type type)
{
return typeof(NetBitArraySerializer).GetMethod("Write", BindingFlags.Static | BindingFlags.NonPublic)!;
}
public MethodInfo GetStaticReader(Type type)
{
return typeof(NetBitArraySerializer).GetMethod("Read", BindingFlags.Static | BindingFlags.NonPublic)!;
}
[UsedImplicitly]
private static void Write(Serializer serializer, Stream stream, BitArray value)
{
var intCount = (31 + value.Length) >> 5;
var ints = new int[intCount];
value.CopyTo(ints, 0);
serializer.SerializeDirect(stream, 0); // _version
serializer.SerializeDirect(stream, ints); // m_array
serializer.SerializeDirect(stream, value.Length); // m_length
}
[UsedImplicitly]
private static void Read(Serializer serializer, Stream stream, out BitArray value)
{
serializer.DeserializeDirect<int>(stream, out _); // _version
serializer.DeserializeDirect<int[]>(stream, out var array); // m_array
serializer.DeserializeDirect<int>(stream, out var length); // m_length
value = new BitArray(array)
{
Length = length
};
}
}

View File

@@ -91,6 +91,7 @@ namespace Robust.Shared.Serialization
MappedStringSerializer.TypeSerializer,
new Vector2Serializer(),
new Matrix3x2Serializer(),
new NetBitArraySerializer()
}
};
_serializer = new Serializer(types, settings);

View File

@@ -3,6 +3,7 @@ using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.Manager;
using Robust.Shared.Serialization.Markdown.Validation;
using Robust.Shared.Serialization.Markdown.Value;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Generic;
using Robust.Shared.Serialization.TypeSerializers.Interfaces;
namespace Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype
@@ -30,10 +31,7 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Pro
public virtual ValidationNode Validate(ISerializationManager serializationManager, ValueDataNode node,
IDependencyCollection dependencies, ISerializationContext? context = null)
{
var protoMan = dependencies.Resolve<IPrototypeManager>();
return protoMan.TryGetKindFrom<TPrototype>(out _) && protoMan.HasIndex<TPrototype>(node.Value)
? new ValidatedValueNode(node)
: new ErrorNode(node, $"PrototypeID {node.Value} for type {typeof(TPrototype)} at {node.Start} not found");
return ProtoIdSerializer<TPrototype>.Validate(dependencies, node);
}
}
}

View File

@@ -19,8 +19,19 @@ public sealed class ProtoIdSerializer<T> : ITypeSerializer<ProtoId<T>, ValueData
{
public ValidationNode Validate(ISerializationManager serialization, ValueDataNode node, IDependencyCollection dependencies, ISerializationContext? context = null)
{
var prototypes = dependencies.Resolve<IPrototypeManager>();
if (prototypes.TryGetKindFrom<T>(out _) && prototypes.HasMapping<T>(node.Value))
return Validate(dependencies, node);
}
public static ValidationNode Validate(IDependencyCollection deps, ValueDataNode node)
{
var proto = deps.Resolve<IPrototypeManager>();
if (!proto.TryGetKindFrom<T>(out var kind))
return new ErrorNode(node, $"Unknown prototype kind: {typeof(T)}");
if (proto.IsIgnored(kind))
return new ErrorNode(node,$"Attempting to validate an ignored prototype: {typeof(T)}.\nDid you forget to remove the IPrototypeManager.RegisterIgnore(\"{kind}\") call when moving a prototype to Shared?");
if (proto.HasMapping<T>(node.Value))
return new ValidatedValueNode(node);
return new ErrorNode(node, $"No {typeof(T)} found with id {node.Value}");

View File

@@ -25,7 +25,7 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations
ITypeCopier<Rsi>,
ITypeCopier<Texture>
{
// Should probably be in SpriteComponent, but is needed for server to validate paths.
// Should probably be in SpriteSystem, but is needed for server to validate paths.
// So I guess it might as well go here?
public static readonly ResPath TextureRoot = new("/Textures");

View File

@@ -66,7 +66,7 @@ public readonly struct ResPath : IEquatable<ResPath>
public ResPath(string canonPath)
{
// Paths should never have non-standardised directory separators passed in, the caller should have already sanitised it.
DebugTools.Assert(!canonPath.Contains('\\'));
DebugTools.Assert(IsValidPath(canonPath));
CanonPath = canonPath;
}
@@ -77,6 +77,21 @@ public readonly struct ResPath : IEquatable<ResPath>
{
}
/// <summary>
/// Check whether the given string paths contains any non-standard directory separators.
/// </summary>
public static bool IsValidPath(string path) => !path.Contains('\\');
/// <summary>
/// Check whether a string is a valid path (<see cref="IsValidPath"/>) and corresponds to a simple file name.
/// </summary>
public static bool IsValidFilename([NotNullWhen(true)] string? filename)
=> !string.IsNullOrEmpty(filename)
&& IsValidPath(filename)
&& !filename.Contains('/')
&& filename != "."
&& filename != "..";
/// <summary>
/// Returns true if the path is equal to "."
/// </summary>
@@ -106,7 +121,7 @@ public readonly struct ResPath : IEquatable<ResPath>
}
var ind = CanonPath.Length > 1 && CanonPath[^1] == '/'
? CanonPath[..^1].LastIndexOf('/')
? CanonPath.LastIndexOf('/', CanonPath.Length - 2)
: CanonPath.LastIndexOf('/');
return ind switch
{
@@ -206,7 +221,7 @@ public readonly struct ResPath : IEquatable<ResPath>
// it's a filename
// Uses +1 to skip `/` found in or starts from beginning of string
// if we found nothing (ind == -1)
var ind = CanonPath[..^1].LastIndexOf('/') + 1;
var ind = CanonPath.LastIndexOf('/', CanonPath.Length - 2) + 1;
return CanonPath[^1] == '/'
? CanonPath[ind .. ^1] // Omit last `/`
: CanonPath[ind..];
@@ -242,7 +257,6 @@ public readonly struct ResPath : IEquatable<ResPath>
return CanonPath.GetHashCode();
}
public static bool operator ==(ResPath left, ResPath right)
{
return left.Equals(right);
@@ -283,7 +297,7 @@ public readonly struct ResPath : IEquatable<ResPath>
}
// Avoid double separators
if (left.CanonPath.EndsWith("/"))
if (left.CanonPath.EndsWith('/'))
{
return new ResPath(left.CanonPath + right.CanonPath);
}
@@ -475,7 +489,7 @@ public readonly struct ResPath : IEquatable<ResPath>
/// </summary>
public string ToRelativeSystemPath()
{
return ToRelativePath().ChangeSeparator(SystemSeparatorStr);
return ToRelativePath().ChangeSeparator(SystemSeparator);
}
/// <summary>
@@ -495,14 +509,19 @@ public readonly struct ResPath : IEquatable<ResPath>
/// </summary>
public string ChangeSeparator(string newSeparator)
{
if (newSeparator is "." or "\0")
{
throw new ArgumentException("New separator can't be `.` or `NULL`");
}
if (newSeparator.Length != 1)
throw new InvalidOperationException("new separator must be a single character.");
return ChangeSeparator(newSeparator[0]);
}
return newSeparator == "/"
? CanonPath
: CanonPath.Replace("/", newSeparator);
/// <inheritdoc cref="ChangeSeparator(string)"/>
public string ChangeSeparator(char newSeparator)
{
if (newSeparator is '.' or '\0')
throw new ArgumentException("New separator can't be `.` or `NULL`");
// String.Replace() already checks if newSeparator == '/'
return CanonPath.Replace('/', newSeparator);
}
}

View File

@@ -0,0 +1,132 @@
using System.Numerics;
using System.Threading.Tasks;
using NUnit.Framework;
using Robust.Client;
using Robust.Client.GameObjects;
using Robust.Shared.GameObjects;
using Robust.Shared.Maths;
namespace Robust.UnitTesting.Client.Sprite;
public sealed class SpriteBoundsTest : RobustIntegrationTest
{
private static readonly string Prototypes = @"
- type: entity
id: spriteBoundsTest1
components:
- type: Sprite
sprite: debugRotation.rsi
layers:
- state: direction1
- type: entity
id: spriteBoundsTest2
components:
- type: Sprite
sprite: debugRotation.rsi
layers:
- state: direction1
- state: direction1
rotation: 0.1
- type: entity
id: spriteBoundsTest3
components:
- type: Sprite
sprite: debugRotation.rsi
layers:
- state: direction1
- state: direction1
rotation: 0.1
visible: false
";
[Test]
public async Task TestSpriteBounds()
{
var client = StartClient(new ClientIntegrationOptions {ExtraPrototypes = Prototypes});
await client.WaitIdleAsync();
var baseClient = client.Resolve<IBaseClient>();
await client.WaitPost(() => baseClient.StartSinglePlayer());
await client.WaitIdleAsync();
var entMan = client.EntMan;
var sys = client.System<SpriteSystem>();
EntityUid uid1 = default;
EntityUid uid2 = default;
EntityUid uid3 = default;
await client.WaitPost(() =>
{
uid1 = entMan.Spawn("spriteBoundsTest1");
uid2 = entMan.Spawn("spriteBoundsTest2");
uid3 = entMan.Spawn("spriteBoundsTest3");
});
var ent1 = new Entity<SpriteComponent>(uid1, entMan.GetComponent<SpriteComponent>(uid1));
var ent2 = new Entity<SpriteComponent>(uid2, entMan.GetComponent<SpriteComponent>(uid2));
var ent3 = new Entity<SpriteComponent>(uid3, entMan.GetComponent<SpriteComponent>(uid3));
// None of the entities have empty bounding boxes
var box1 = sys.GetLocalBounds(ent1);
var box2 = sys.GetLocalBounds(ent2);
var box3 = sys.GetLocalBounds(ent3);
Assert.That(!box1.EqualsApprox(Box2.Empty));
Assert.That(!box2.EqualsApprox(Box2.Empty));
Assert.That(!box3.EqualsApprox(Box2.Empty));
// ent2 should have a larger bb than ent1 as it has a visible rotated layer
// ents 1 & 3 should have the same bounds, as the rotated layer is invisible in ent3
Assert.That(box1.EqualsApprox(box3));
Assert.That(!box1.EqualsApprox(box2));
Assert.That(box2.EqualsApprox(ent2.Comp.Layers[1].Bounds));
Assert.That(box2.Size.X, Is.GreaterThan(box1.Size.X));
Assert.That(box2.Size.Y, Is.GreaterThan(box1.Size.Y));
// Toggling layer visibility updates the bounds
sys.LayerSetVisible(ent2!, 1, false);
sys.LayerSetVisible(ent3!, 1, true);
var newBox2 = sys.GetLocalBounds(ent2);
var newBox3 = sys.GetLocalBounds(ent3);
Assert.That(!newBox2.EqualsApprox(Box2.Empty));
Assert.That(!newBox3.EqualsApprox(Box2.Empty));
Assert.That(box1.EqualsApprox(newBox2));
Assert.That(!box1.EqualsApprox(newBox3));
Assert.That(newBox3.EqualsApprox(ent3.Comp.Layers[1].Bounds));
Assert.That(newBox3.Size.X, Is.GreaterThan(box1.Size.X));
Assert.That(newBox3.Size.Y, Is.GreaterThan(box1.Size.Y));
// Changing the rotation, offset, scale, all trigger a bounds updatge
sys.LayerSetRotation(ent3!, 1, Angle.Zero);
var box = sys.GetLocalBounds(ent3);
Assert.That(box1.EqualsApprox(box));
// scale
sys.LayerSetScale(ent3!, 1, Vector2.One * 2);
box = sys.GetLocalBounds(ent3);
Assert.That(box1.EqualsApprox(box.Scale(0.5f)));
sys.LayerSetScale(ent3!, 1, Vector2.One);
box = sys.GetLocalBounds(ent3);
Assert.That(box1.EqualsApprox(box));
// offset
Assert.That(box.Center, Is.Approximately(Vector2.Zero));
sys.LayerSetOffset(ent3!, 1, Vector2.One);
box = sys.GetLocalBounds(ent3);
Assert.That(box.Size.X, Is.GreaterThan(box1.Size.X));
Assert.That(box.Size.Y, Is.GreaterThan(box1.Size.Y));
Assert.That(box.Center.X, Is.GreaterThan(0));
Assert.That(box.Center.Y, Is.GreaterThan(0));
await client.WaitPost(() =>
{
entMan.DeleteEntity(uid1);
entMan.DeleteEntity(uid2);
entMan.DeleteEntity(uid3);
});
}
}

View File

@@ -0,0 +1,118 @@
using System;
using System.Diagnostics.Contracts;
using System.Threading;
using System.Threading.Tasks;
using Robust.Shared.Configuration;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Robust.UnitTesting;
public interface IIntegrationInstance : IDisposable
{
/// <summary>
/// Whether the instance is still alive.
/// "Alive" indicates that it is able to receive and process commands.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
/// </exception>
bool IsAlive { get; }
Exception? UnhandledException { get; }
EntityManager EntMan { get; }
IPrototypeManager ProtoMan { get; }
IConfigurationManager CfgMan { get; }
ISharedPlayerManager PlayerMan { get; }
INetManager NetMan { get; }
IMapManager MapMan { get; }
IGameTiming Timing { get; }
ISawmill Log { get; }
/// <summary>
/// Resolve a dependency inside the instance.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
/// </exception>
[Pure] T Resolve<T>();
[Pure] T System<T>() where T : IEntitySystem;
TransformComponent Transform(EntityUid uid);
MetaDataComponent MetaData(EntityUid uid);
MetaDataComponent MetaData(NetEntity uid);
TransformComponent Transform(NetEntity uid);
Task ExecuteCommand(string cmd);
/// <summary>
/// Wait for the instance to go idle, either through finishing all commands or shutting down/crashing.
/// </summary>
/// <param name="throwOnUnhandled">
/// If true, throw an exception if the server dies on an unhandled exception.
/// </param>
/// <param name="cancellationToken"></param>
/// <exception cref="Exception">
/// Thrown if <paramref name="throwOnUnhandled"/> is true and the instance shuts down on an unhandled exception.
/// </exception>
Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default);
/// <summary>
/// Queue for the server to run n ticks.
/// </summary>
/// <param name="ticks">The amount of ticks to run.</param>
void RunTicks(int ticks);
/// <summary>
/// <see cref="RunTicks"/> followed by <see cref="WaitIdleAsync"/>
/// </summary>
Task WaitRunTicks(int ticks);
/// <summary>
/// Queue for a delegate to be ran inside the main loop of the instance.
/// </summary>
/// <remarks>
/// Do not run NUnit assertions inside <see cref="Post"/>. Use <see cref="Assert"/> instead.
/// </remarks>
void Post(Action post);
/// <inheritdoc cref="Post"/>
Task WaitPost(Action post);
/// <summary>
/// Queue for a delegate to be ran inside the main loop of the instance,
/// rethrowing any exceptions in <see cref="WaitIdleAsync"/>.
/// </summary>
/// <remarks>
/// Exceptions raised inside this callback will be rethrown by <see cref="WaitIdleAsync"/>.
/// This makes it ideal for NUnit assertions,
/// since rethrowing the NUnit assertion directly provides less noise.
/// </remarks>
void Assert(Action assertion);
/// <inheritdoc cref="Assert"/>
Task WaitAssertion(Action assertion);
/// <summary>
/// Post-test cleanup
/// </summary>
Task Cleanup();
}
public interface IClientIntegrationInstance : IIntegrationInstance
{
IClientNetManager CNetMan => (IClientNetManager) NetMan;
ICommonSession? Session { get; }
NetUserId? User { get; }
EntityUid? AttachedEntity { get; }
Task Connect(IServerIntegrationInstance target);
}
public interface IServerIntegrationInstance : IIntegrationInstance;

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Robust.Shared.Timing;
namespace Robust.UnitTesting.Pool;
public interface ITestPair
{
int Id { get; }
public Stopwatch Watch { get; }
public PairState State { get; }
public bool Initialized { get; }
void Kill();
List<string> TestHistory { get; }
PairSettings Settings { get; set; }
int ServerSeed { get; }
int ClientSeed { get; }
void ActivateContext(TextWriter testOut);
void ValidateSettings(PairSettings settings);
void SetupSeed();
void ClearModifiedCvars();
void Use();
Task Init(int id, BasePoolManager manager, PairSettings settings, TextWriter testOut);
Task RecycleInternal(PairSettings next, TextWriter testOut);
Task ApplySettings(PairSettings settings);
Task RunTicksSync(int ticks);
Task SyncTicks(int targetDelta = 1);
}
public enum PairState : byte
{
Ready = 0,
InUse = 1,
CleanDisposed = 2,
Dead = 3,
}

View File

@@ -0,0 +1,95 @@
using System;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// Settings for a server-client pair. These settings may change over a pair's lifetime.
/// The pool manager handles fetching pairs with a given setting, including applying new settings to re-used pairs.
/// </summary>
[Virtual]
public class PairSettings
{
/// <summary>
/// Set to true if the test will ruin the server/client pair.
/// </summary>
public virtual bool Destructive { get; init; }
/// <summary>
/// Set to true if the given server/client pair should be created fresh.
/// </summary>
public virtual bool Fresh { get; init; }
/// <summary>
/// Set to true if the given server/client pair should be connected from each other.
/// Defaults to disconnected as it makes dirty recycling slightly faster.
/// </summary>
public virtual bool Connected { get; init; }
/// <summary>
/// This will return a server-client pair that has not loaded test prototypes.
/// Try avoiding this whenever possible, as this will always create & destroy a new pair.
/// Use <see cref="RobustTestPair.IsTestPrototype(EntityPrototype)"/> if you need to
/// exclude test prototypes.
/// </summary>
public virtual bool NoLoadTestPrototypes { get; init; }
/// <summary>
/// Set this to true to disable the NetInterp CVar on the given server/client pair
/// </summary>
public virtual bool DisableInterpolate { get; init; }
/// <summary>
/// Set this to true to always clean up the server/client pair before giving it to another borrower
/// </summary>
public virtual bool Dirty { get; init; }
/// <summary>
/// Overrides the test name detection, and uses this in the test history instead
/// </summary>
public virtual string? TestName { get; set; }
/// <summary>
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
/// </summary>
public virtual int? ServerSeed { get; set; }
/// <summary>
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
/// </summary>
public virtual int? ClientSeed { get; set; }
#region Inferred Properties
/// <summary>
/// If the returned pair must not be reused
/// </summary>
public virtual bool MustNotBeReused => Destructive || NoLoadTestPrototypes;
/// <summary>
/// If the given pair must be brand new
/// </summary>
public virtual bool MustBeNew => Fresh || NoLoadTestPrototypes;
#endregion
/// <summary>
/// Tries to guess if we can skip recycling the server/client pair.
/// </summary>
/// <param name="nextSettings">The next set of settings the old pair will be set to</param>
/// <returns>If we can skip cleaning it up</returns>
public virtual bool CanFastRecycle(PairSettings nextSettings)
{
if (MustNotBeReused)
throw new InvalidOperationException("Attempting to recycle a non-reusable test.");
if (nextSettings.MustBeNew)
throw new InvalidOperationException("Attempting to recycle a test while requesting a fresh test.");
if (Dirty)
return false;
return Connected == nextSettings.Connected;
}
}

View File

@@ -0,0 +1,332 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Robust.Shared;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.UnitTesting.Pool;
public abstract class BasePoolManager
{
internal abstract void Return(ITestPair pair);
public abstract Assembly[] ClientAssemblies { get; }
public abstract Assembly[] ServerAssemblies { get; }
public readonly List<string> TestPrototypes = new();
// default cvar overrides to use when creating test pairs.
public readonly List<(string cvar, string value)> DefaultCvars =
[
(CVars.NetPVS.Name, "false"),
(CVars.ThreadParallelCount.Name, "1"),
(CVars.ReplayClientRecordingEnabled.Name, "false"),
(CVars.ReplayServerRecordingEnabled.Name, "false"),
(CVars.NetBufferSize.Name, "0")
];
}
[Virtual]
public class PoolManager<TPair> : BasePoolManager where TPair : class, ITestPair, new()
{
private int _nextPairId;
private readonly Lock _pairLock = new();
private bool _initialized;
/// <summary>
/// Set of all pairs, and whether they are currently in-use
/// </summary>
protected readonly Dictionary<TPair, bool> Pairs = new();
private bool _dead;
private Exception? _poolFailureReason;
private Assembly[] _clientAssemblies = [];
private Assembly[] _serverAssemblies = [];
public override Assembly[] ClientAssemblies => _clientAssemblies;
public override Assembly[] ServerAssemblies => _serverAssemblies;
/// <summary>
/// Initialize the pool manager. Override this to configure what assemblies should get loaded.
/// </summary>
public virtual void Startup(params Assembly[] extraAssemblies)
{
// By default, load no content assemblies, but make both server & client load the testing assembly.
Startup([], [], extraAssemblies);
}
protected void Startup(Assembly[] clientAssemblies, Assembly[] serverAssemblies, Assembly[] sharedAssemblies)
{
if (_initialized)
throw new InvalidOperationException("Already initialized");
DebugTools.AssertEqual(clientAssemblies.Intersect(sharedAssemblies).Count(), 0);
DebugTools.AssertEqual(serverAssemblies.Intersect(sharedAssemblies).Count(), 0);
DebugTools.AssertEqual(serverAssemblies.Intersect(clientAssemblies).Count(), 0);
foreach (var assembly in sharedAssemblies)
{
DiscoverTestPrototypes(assembly);
}
foreach (var assembly in clientAssemblies)
{
DiscoverTestPrototypes(assembly);
}
foreach (var assembly in serverAssemblies)
{
DiscoverTestPrototypes(assembly);
}
_initialized = true;
_clientAssemblies = clientAssemblies.Concat(sharedAssemblies).ToArray();
_serverAssemblies = serverAssemblies.Concat(sharedAssemblies).ToArray();
}
/// <summary>
/// This shuts down the pool, and disposes all the server/client pairs.
/// This is a one time operation to be used when the testing program is exiting.
/// </summary>
public void Shutdown()
{
List<TPair> localPairs;
lock (_pairLock)
{
if (_dead)
return;
_dead = true;
localPairs = Pairs.Keys.ToList();
}
foreach (var pair in localPairs)
{
pair.Kill();
}
_initialized = false;
TestPrototypes.Clear();
}
protected virtual string GetDefaultTestName(TestContext testContext)
{
return testContext.Test.FullName.Replace("Robust.UnitTesting.", "");
}
public string DeathReport()
{
lock (_pairLock)
{
var builder = new StringBuilder();
var pairs = Pairs.Keys.OrderBy(pair => pair.Id);
foreach (var pair in pairs)
{
var borrowed = Pairs[pair];
builder.AppendLine($"Pair {pair.Id}, Tests Run: {pair.TestHistory.Count}, Borrowed: {borrowed}");
for (var i = 0; i < pair.TestHistory.Count; i++)
{
builder.AppendLine($"#{i}: {pair.TestHistory[i]}");
}
}
return builder.ToString();
}
}
public virtual PairSettings DefaultSettings => new();
public async Task<TPair> GetPair(PairSettings? settings = null)
{
if (!_initialized)
throw new InvalidOperationException($"Pool manager has not been initialized");
settings ??= DefaultSettings;
// Trust issues with the AsyncLocal that backs this.
var testContext = TestContext.CurrentContext;
var testOut = TestContext.Out;
DieIfPoolFailure();
var currentTestName = settings.TestName ?? GetDefaultTestName(testContext);
var watch = new Stopwatch();
await testOut.WriteLineAsync($"{nameof(GetPair)}: Called by test {currentTestName}");
TPair? pair = null;
try
{
watch.Start();
if (settings.MustBeNew)
{
await testOut.WriteLineAsync(
$"{nameof(GetPair)}: Creating pair, because settings of pool settings");
pair = await CreateServerClientPair(settings, testOut);
}
else
{
await testOut.WriteLineAsync($"{nameof(GetPair)}: Looking in pool for a suitable pair");
pair = GrabOptimalPair(settings);
if (pair != null)
{
pair.ActivateContext(testOut);
await testOut.WriteLineAsync($"{nameof(GetPair)}: Suitable pair found");
if (pair.Settings.CanFastRecycle(settings))
{
await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleanup not needed, Skipping cleanup of pair");
await pair.ApplySettings(settings);
}
else
{
await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleaning existing pair");
await pair.RecycleInternal(settings, testOut);
}
await pair.RunTicksSync(5);
await pair.SyncTicks(targetDelta: 1);
}
else
{
await testOut.WriteLineAsync($"{nameof(GetPair)}: Creating a new pair, no suitable pair found in pool");
pair = await CreateServerClientPair(settings, testOut);
}
}
}
finally
{
if (pair != null && pair.TestHistory.Count > 0)
{
await testOut.WriteLineAsync($"{nameof(GetPair)}: Pair {pair.Id} Test History Start");
for (var i = 0; i < pair.TestHistory.Count; i++)
{
await testOut.WriteLineAsync($"- Pair {pair.Id} Test #{i}: {pair.TestHistory[i]}");
}
await testOut.WriteLineAsync($"{nameof(GetPair)}: Pair {pair.Id} Test History End");
}
}
await testOut.WriteLineAsync($"{nameof(GetPair)}: Retrieving pair {pair.Id} from pool took {watch.Elapsed.TotalMilliseconds} ms");
pair.ValidateSettings(settings);
pair.ClearModifiedCvars();
pair.Settings = settings;
pair.TestHistory.Add(currentTestName);
pair.SetupSeed();
await testOut.WriteLineAsync($"{nameof(GetPair)}: Returning pair {pair.Id} with client/server seeds: {pair.ClientSeed}/{pair.ServerSeed}");
pair.Watch.Restart();
return pair;
}
private TPair? GrabOptimalPair(PairSettings poolSettings)
{
lock (_pairLock)
{
TPair? fallback = null;
foreach (var pair in Pairs.Keys)
{
if (Pairs[pair])
continue;
if (!pair.Settings.CanFastRecycle(poolSettings))
{
fallback = pair;
continue;
}
pair.Use();
Pairs[pair] = true;
return pair;
}
if (fallback == null)
return null;
fallback.Use();
Pairs[fallback!] = true;
return fallback;
}
}
/// <summary>
/// Used by TestPair after checking the server/client pair, Don't use this.
/// </summary>
internal override void Return(ITestPair pair)
{
lock (_pairLock)
{
if (pair.State == PairState.Dead)
Pairs.Remove((TPair)pair);
else if (pair.State == PairState.Ready)
Pairs[(TPair) pair] = false;
else
throw new InvalidOperationException($"Attempted to return a pair in an invalid state. Pair: {pair.Id}. State: {pair.State}.");
}
}
private void DieIfPoolFailure()
{
if (_poolFailureReason != null)
{
// If the _poolFailureReason is not null, we can assume at least one test failed.
// So we say inconclusive so we don't add more failed tests to search through.
Assert.Inconclusive(@$"
In a different test, the pool manager had an exception when trying to create a server/client pair.
Instead of risking that the pool manager will fail at creating a server/client pairs for every single test,
we are just going to end this here to save a lot of time. This is the exception that started this:\n {_poolFailureReason}");
}
if (_dead)
{
// If Pairs is null, we ran out of time, we can't assume a test failed.
// So we are going to tell it all future tests are a failure.
Assert.Fail("The pool was shut down");
}
}
private async Task<TPair> CreateServerClientPair(PairSettings settings, TextWriter testOut)
{
try
{
var id = Interlocked.Increment(ref _nextPairId);
var pair = new TPair();
await pair.Init(id, this, settings, testOut);
pair.Use();
await pair.RunTicksSync(5);
await pair.SyncTicks(targetDelta: 1);
return pair;
}
catch (Exception ex)
{
_poolFailureReason = ex;
throw;
}
}
private void DiscoverTestPrototypes(Assembly assembly)
{
const BindingFlags flags = BindingFlags.Static
| BindingFlags.NonPublic
| BindingFlags.Public
| BindingFlags.DeclaredOnly;
foreach (var type in assembly.GetTypes())
{
foreach (var field in type.GetFields(flags))
{
if (!field.HasCustomAttribute<TestPrototypesAttribute>())
continue;
var val = field.GetValue(null);
if (val is not string str)
throw new Exception($"{nameof(TestPrototypesAttribute)} is only valid on non-null string fields");
TestPrototypes.Add(str);
}
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.IO;
using NUnit.Framework;
using Robust.Shared.Log;
using Robust.Shared.Timing;
using Serilog.Events;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// Log handler intended for pooled integration tests.
/// </summary>
/// <remarks>
/// <para>
/// This class logs to two places: an NUnit <see cref="TestContext"/> (so it nicely gets attributed to a test in your IDE),
/// and an in-memory ring buffer for diagnostic purposes. If test pooling breaks, the ring buffer can be used to see what the broken instance has gone through.
/// </para>
/// <para>
/// The active test context can be swapped out so pooled instances can correctly have their logs attributed.
/// </para>
/// </remarks>
public sealed class PoolTestLogHandler : ILogHandler
{
private readonly string? _prefix;
private RStopwatch _stopwatch;
public TextWriter? ActiveContext { get; private set; }
public LogLevel? FailureLevel { get; set; }
public PoolTestLogHandler(string? prefix)
{
_prefix = prefix != null ? $"{prefix}: " : "";
}
public bool ShuttingDown;
public void Log(string sawmillName, LogEvent message)
{
var level = message.Level.ToRobust();
if (ShuttingDown && (FailureLevel == null || level < FailureLevel))
return;
if (ActiveContext is not { } testContext)
{
// If this gets hit it means something is logging to this instance while it's "between" tests.
// This is a bug in either the game or the testing system, and must always be investigated.
throw new InvalidOperationException("Log to pool test log handler without active test context");
}
var name = LogMessage.LogLevelToName(level);
var seconds = _stopwatch.Elapsed.TotalSeconds;
var rendered = message.RenderMessage();
var line = $"{_prefix}{seconds:F3}s [{name}] {sawmillName}: {rendered}";
testContext.WriteLine(line);
if (FailureLevel == null || level < FailureLevel)
return;
testContext.Flush();
Assert.Fail($"{line} Exception: {message.Exception}");
}
public void ClearContext()
{
ActiveContext = null;
}
public void ActivateContext(TextWriter context)
{
_stopwatch.Restart();
ActiveContext = context;
}
}

View File

@@ -0,0 +1,23 @@
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// Simple data class that stored information about a map being used by a test.
/// </summary>
public sealed class TestMapData
{
public EntityUid MapUid { get; set; }
public Entity<MapGridComponent> Grid;
public MapId MapId;
public EntityCoordinates GridCoords { get; set; }
public MapCoordinates MapCoords { get; set; }
public TileRef Tile { get; set; }
// Client-side uids
public EntityUid CMapUid { get; set; }
public EntityUid CGridUid { get; set; }
public EntityCoordinates CGridCoords { get; set; }
}

View File

@@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
namespace Robust.UnitTesting.Pool;
// This partial file contains misc helper functions to make writing tests easier.
public partial class TestPair<TServer, TClient>
{
/// <summary>
/// Convert a client-side uid into a server-side uid
/// </summary>
public EntityUid ToServerUid(EntityUid uid) => ConvertUid(uid, Client, Server);
/// <summary>
/// Convert a server-side uid into a client-side uid
/// </summary>
public EntityUid ToClientUid(EntityUid uid) => ConvertUid(uid, Server, Client);
private static EntityUid ConvertUid(EntityUid uid, IIntegrationInstance source, IIntegrationInstance destination)
{
if (!uid.IsValid())
return EntityUid.Invalid;
if (!source.EntMan.TryGetComponent<MetaDataComponent>(uid, out var meta))
{
Assert.Fail($"Failed to resolve MetaData while converting the EntityUid for entity {uid}");
return EntityUid.Invalid;
}
if (!destination.EntMan.TryGetEntity(meta.NetEntity, out var otherUid))
{
Assert.Fail($"Failed to resolve net ID while converting the EntityUid entity {source.EntMan.ToPrettyString(uid)}");
return EntityUid.Invalid;
}
return otherUid.Value;
}
/// <summary>
/// Execute a command on the server and wait some number of ticks.
/// </summary>
public async Task WaitCommand(string cmd, int numTicks = 10)
{
await Server.ExecuteCommand(cmd);
await RunTicksSync(numTicks);
}
/// <summary>
/// Execute a command on the client and wait some number of ticks.
/// </summary>
public async Task WaitClientCommand(string cmd, int numTicks = 10)
{
await Client.ExecuteCommand(cmd);
await RunTicksSync(numTicks);
}
/// <summary>
/// Retrieve all entity prototypes that have some component.
/// </summary>
public List<(EntityPrototype, T)> GetPrototypesWithComponent<T>(
HashSet<string>? ignored = null,
bool ignoreAbstract = true,
bool ignoreTestPrototypes = true)
where T : IComponent, new()
{
if (!Server.Resolve<IComponentFactory>().TryGetRegistration<T>(out var reg)
&& !Client.Resolve<IComponentFactory>().TryGetRegistration<T>(out reg))
{
Assert.Fail($"Unknown component: {typeof(T).Name}");
return new();
}
var id = reg.Name;
var list = new List<(EntityPrototype, T)>();
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
{
if (ignored != null && ignored.Contains(proto.ID))
continue;
if (ignoreAbstract && proto.Abstract)
continue;
if (ignoreTestPrototypes && IsTestPrototype(proto))
continue;
if (proto.Components.TryGetComponent(id, out var cmp))
list.Add((proto, (T)cmp));
}
return list;
}
/// <summary>
/// Retrieve all entity prototypes that have some component.
/// </summary>
public List<EntityPrototype> GetPrototypesWithComponent(
Type type,
HashSet<string>? ignored = null,
bool ignoreAbstract = true,
bool ignoreTestPrototypes = true)
{
if (!Server.Resolve<IComponentFactory>().TryGetRegistration(type, out var reg)
&& !Client.Resolve<IComponentFactory>().TryGetRegistration(type, out reg))
{
Assert.Fail($"Unknown component: {type.Name}");
return new();
}
var id = reg.Name;
var list = new List<EntityPrototype>();
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
{
if (ignored != null && ignored.Contains(proto.ID))
continue;
if (ignoreAbstract && proto.Abstract)
continue;
if (ignoreTestPrototypes && IsTestPrototype(proto))
continue;
if (proto.Components.ContainsKey(id))
list.Add((proto));
}
return list;
}
public async Task Connect()
{
Assert.That(Client.NetMan.IsConnected, Is.False);
await Client.Connect(Server);
await ReallyBeIdle(10);
await Client.WaitRunTicks(1);
}
public async Task Disconnect(string reason = "")
{
await Client.WaitPost(() => Client.CNetMan.ClientDisconnect(reason));
await ReallyBeIdle(10);
}
public bool IsTestPrototype(EntityPrototype proto)
{
return _loadedEntityPrototypes.Contains(proto.ID);
}
public bool IsTestEntityPrototype(string id)
{
return _loadedEntityPrototypes.Contains(id);
}
public bool IsTestPrototype<TPrototype>(string id) where TPrototype : IPrototype
{
return IsTestPrototype(typeof(TPrototype), id);
}
public bool IsTestPrototype<TPrototype>(TPrototype proto) where TPrototype : IPrototype
{
return IsTestPrototype(typeof(TPrototype), proto.ID);
}
public bool IsTestPrototype(Type kind, string id)
{
return _loadedPrototypes.TryGetValue(kind, out var ids) && ids.Contains(id);
}
/// <summary>
/// Runs the server-client pair in sync
/// </summary>
/// <param name="ticks">How many ticks to run them for</param>
public async Task RunTicksSync(int ticks)
{
for (var i = 0; i < ticks; i++)
{
await Server.WaitRunTicks(1);
await Client.WaitRunTicks(1);
}
}
/// <summary>
/// Convert a time interval to some number of ticks.
/// </summary>
public int SecondsToTicks(float seconds)
{
return (int) Math.Ceiling(seconds / Server.Timing.TickPeriod.TotalSeconds);
}
/// <summary>
/// Run the server & client in sync for some amount of time
/// </summary>
public async Task RunSeconds(float seconds)
{
await RunTicksSync(SecondsToTicks(seconds));
}
/// <summary>
/// Runs the server-client pair in sync, but also ensures they are both idle each tick.
/// </summary>
/// <param name="runTicks">How many ticks to run</param>
public async Task ReallyBeIdle(int runTicks = 25)
{
for (var i = 0; i < runTicks; i++)
{
await Client.WaitRunTicks(1);
await Server.WaitRunTicks(1);
for (var idleCycles = 0; idleCycles < 4; idleCycles++)
{
await Client.WaitIdleAsync();
await Server.WaitIdleAsync();
}
}
}
/// <summary>
/// Run the server/clients until the ticks are synchronized.
/// By default the client will be one tick ahead of the server.
/// </summary>
public async Task SyncTicks(int targetDelta = 1)
{
var sTick = (int)Server.Timing.CurTick.Value;
var cTick = (int)Client.Timing.CurTick.Value;
var delta = cTick - sTick;
if (delta == targetDelta)
return;
if (delta > targetDelta)
await Server.WaitRunTicks(delta - targetDelta);
else
await Client.WaitRunTicks(targetDelta - delta);
sTick = (int)Server.Timing.CurTick.Value;
cTick = (int)Client.Timing.CurTick.Value;
delta = cTick - sTick;
Assert.That(delta, Is.EqualTo(targetDelta));
}
/// <summary>
/// Creates a map with a single grid consisting of one tile.
/// </summary>
[MemberNotNull(nameof(TestMap))]
public async Task<TestMapData> CreateTestMap(bool initialized, ushort tileTypeId)
{
TestMap = new TestMapData();
await Server.WaitIdleAsync();
var sys = Server.System<SharedMapSystem>();
await Server.WaitPost(() =>
{
TestMap.MapUid = sys.CreateMap(out TestMap.MapId, runMapInit: initialized);
TestMap.Grid = Server.MapMan.CreateGridEntity(TestMap.MapId);
TestMap.GridCoords = new EntityCoordinates(TestMap.Grid, 0, 0);
TestMap.MapCoords = new MapCoordinates(0, 0, TestMap.MapId);
sys.SetTile(TestMap.Grid.Owner, TestMap.Grid.Comp, TestMap.GridCoords, new Tile(tileTypeId));
TestMap.Tile = sys.GetAllTiles(TestMap.Grid.Owner, TestMap.Grid.Comp).First();
});
if (!Settings.Connected)
return TestMap;
await RunTicksSync(10);
TestMap.CMapUid = ToClientUid(TestMap.MapUid);
TestMap.CGridUid = ToClientUid(TestMap.Grid);
TestMap.CGridCoords = new EntityCoordinates(TestMap.CGridUid, 0, 0);
return TestMap;
}
/// <inheritdoc cref="CreateTestMap(bool, ushort)"/>
[MemberNotNull(nameof(TestMap))]
public async Task<TestMapData> CreateTestMap(bool initialized, string tileName)
{
var defMan = Server.Resolve<ITileDefinitionManager>();
if (!defMan.TryGetDefinition(tileName, out var def))
Assert.Fail($"Unknown tile: {tileName}");
return await CreateTestMap(initialized, def?.TileId ?? 1);
}
}

View File

@@ -0,0 +1,208 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Robust.Client;
using Robust.Shared;
using Robust.Shared.Exceptions;
using Robust.Shared.Network;
using Robust.Shared.Player;
namespace Robust.UnitTesting.Pool;
// This partial file contains logic related to recycling & disposing test pairs.
public partial class TestPair<TServer, TClient>
{
private async Task OnDirtyDispose()
{
var usageTime = Watch.Elapsed;
Watch.Restart();
await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Test gave back pair {Id} in {usageTime.TotalMilliseconds} ms");
Kill();
var disposeTime = Watch.Elapsed;
await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Disposed pair {Id} in {disposeTime.TotalMilliseconds} ms");
// Test pairs should only dirty dispose if they are failing. If they are not failing, this probably happened
// because someone forgot to clean-return the pair.
Assert.Warn("Test was dirty-disposed.");
}
/// <summary>
/// This method gets called before any test pair gets returned to the pool.
/// </summary>
protected virtual async Task Cleanup()
{
if (TestMap != null)
{
await Server.WaitPost(() => Server.EntMan.DeleteEntity(TestMap.MapUid));
TestMap = null;
}
}
private async Task OnCleanDispose()
{
await Server.WaitIdleAsync();
await Client.WaitIdleAsync();
await Cleanup();
await Server.Cleanup();
await Client.Cleanup();
await RevertModifiedCvars();
var usageTime = Watch.Elapsed;
Watch.Restart();
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Test borrowed pair {Id} for {usageTime.TotalMilliseconds} ms");
// Let any last minute failures the test cause happen.
await ReallyBeIdle();
if (!Settings.Destructive)
{
if (Client.IsAlive == false)
throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the client in pair {Id}:", Client.UnhandledException);
if (Server.IsAlive == false)
throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the server in pair {Id}:", Server.UnhandledException);
}
if (Settings.MustNotBeReused)
{
Kill();
await ReallyBeIdle();
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Clean disposed in {Watch.Elapsed.TotalMilliseconds} ms");
return;
}
var sRuntimeLog = Server.Resolve<IRuntimeLog>();
if (sRuntimeLog.ExceptionCount > 0)
throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions");
var cRuntimeLog = Client.Resolve<IRuntimeLog>();
if (cRuntimeLog.ExceptionCount > 0)
throw new Exception($"{nameof(CleanReturnAsync)}: Client logged exceptions");
var returnTime = Watch.Elapsed;
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool");
}
public async ValueTask CleanReturnAsync()
{
if (State != PairState.InUse)
throw new Exception($"{nameof(CleanReturnAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started");
State = PairState.CleanDisposed;
await OnCleanDispose();
State = PairState.Ready;
Manager.Return(this);
ClearContext();
}
public async ValueTask DisposeAsync()
{
switch (State)
{
case PairState.Dead:
case PairState.Ready:
break;
case PairState.InUse:
await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Dirty return of pair {Id} started");
await OnDirtyDispose();
Manager.Return(this);
ClearContext();
break;
default:
throw new Exception($"{nameof(DisposeAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
}
}
/// <summary>
/// This method gets called when a previously used test pair is being retrieved from the pool.
/// Note that in some instances this method may get skipped (See <see cref="PairSettings.CanFastRecycle"/>).
/// </summary>
public async Task RecycleInternal(PairSettings settings, TextWriter testOut)
{
Watch.Restart();
await testOut.WriteLineAsync($"Recycling...");
await RunTicksSync(1);
// Disconnect the client if they are connected.
if (Client.CNetMan.IsConnected)
{
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Disconnecting client.");
await Client.WaitPost(() => Client.CNetMan.ClientDisconnect("Test pooling cleanup disconnect"));
await RunTicksSync(1);
}
await Recycle(settings, testOut);
ClearModifiedCvars();
// (possibly) reconnect the client
if (settings.Connected)
{
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Connecting client");
await Client.Connect(Server);
}
Settings = default!;
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Idling");
await ReallyBeIdle();
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Done recycling");
}
/// <summary>
/// This method gets called when a previously used test pair is being retrieved from the pool.
/// If the next settings are compatible with the previous settings, this step may get skipped (See <see cref="PairSettings.CanFastRecycle"/>).
/// In general, this method should also call <see cref="ApplySettings"/>.
/// </summary>
protected virtual async Task Recycle(PairSettings next, TextWriter testOut)
{
//Apply Cvars
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Setting CVar ");
await ApplySettings(next);
await RunTicksSync(1);
// flush server entities.
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Flushing server entities");
await Server.WaitPost(() => Server.EntMan.FlushEntities());
await RunTicksSync(1);
}
/// <summary>
/// Apply settings to the test pair. This method is always called when a pair is fetched from the pool. There should
/// be no need to apply settings that require a pair to be recycled, as in those cases the
/// <see cref="PairSettings.CanFastRecycle"/> should have caused <see cref="Recycle"/> to be invoked, which should
/// already have applied those settings.
/// </summary>
public async Task ApplySettings(PairSettings next)
{
await ApplySettings(Client, next);
await ApplySettings(Server, next);
}
/// <inheritdoc cref="ApplySettings(PairSettings)"/>
[MustCallBase]
protected internal virtual async Task ApplySettings(IIntegrationInstance instance, PairSettings next)
{
if (instance.CfgMan.IsCVarRegistered(CVars.NetInterp.Name))
await instance.WaitPost(() => instance.CfgMan.SetCVar(CVars.NetInterp, !next.DisableInterpolate));
}
/// <summary>
/// Invoked after a test pair has been recycled to validate that the settings have been properly applied.
/// </summary>
[MustCallBase]
public virtual void ValidateSettings(PairSettings settings)
{
var netMan = Client.Resolve<INetManager>();
Assert.That(netMan.IsConnected, Is.EqualTo(settings.Connected));
if (!settings.Connected)
return;
var baseClient = Client.Resolve<IBaseClient>();
var cPlayer = Client.Resolve<ISharedPlayerManager>();
var sPlayer = Server.Resolve<ISharedPlayerManager>();
Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame));
Assert.That(sPlayer.Sessions.Length, Is.EqualTo(1));
var session = sPlayer.Sessions.Single();
Assert.That(cPlayer.LocalSession?.UserId, Is.EqualTo(session.UserId));
}
}

View File

@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// This object wraps a pooled server+client pair.
/// </summary>
public abstract partial class TestPair<TServer, TClient> : ITestPair, IAsyncDisposable
where TServer : IServerIntegrationInstance
where TClient : IClientIntegrationInstance
{
public int Id { get; internal set; }
protected BasePoolManager Manager = default!;
public PairState State { get; private set; } = PairState.Ready;
public bool Initialized { get; private set; }
protected TextWriter TestOut = default!;
public Stopwatch Watch { get; } = new();
public List<string> TestHistory { get; } = new();
public PairSettings Settings { get; set; } = default!;
public readonly PoolTestLogHandler ServerLogHandler = new("SERVER");
public readonly PoolTestLogHandler ClientLogHandler = new("CLIENT");
public TestMapData? TestMap;
private int _nextServerSeed;
private int _nextClientSeed;
public int ServerSeed { get; set; }
public int ClientSeed { get; set; }
public TServer Server { get; private set; } = default!;
public TClient Client { get; private set; } = default!;
public ICommonSession? Player => Server.PlayerMan.SessionsDict.GetValueOrDefault(Client.User ?? default);
private Dictionary<Type, HashSet<string>> _loadedPrototypes = new();
private HashSet<string> _loadedEntityPrototypes = new();
protected readonly Dictionary<string, object> ModifiedClientCvars = new();
protected readonly Dictionary<string, object> ModifiedServerCvars = new();
public async Task LoadPrototypes(List<string> prototypes)
{
await LoadPrototypes(Server, prototypes);
await LoadPrototypes(Client, prototypes);
}
public async Task Init(
int id,
BasePoolManager manager,
PairSettings settings,
TextWriter testOut)
{
if (Initialized)
throw new InvalidOperationException("Already initialized");
Id = id;
Manager = manager;
Settings = settings;
Initialized = true;
ClientLogHandler.ActivateContext(testOut);
ServerLogHandler.ActivateContext(testOut);
Client = await GenerateClient();
Server = await GenerateServer();
ActivateContext(testOut);
await ApplySettings(settings);
Client.CfgMan.OnCVarValueChanged += OnClientCvarChanged;
Server.CfgMan.OnCVarValueChanged += OnServerCvarChanged;
if (!settings.NoLoadTestPrototypes)
await LoadPrototypes(Manager.TestPrototypes);
var cRand = Client.Resolve<IRobustRandom>();
var sRand = Server.Resolve<IRobustRandom>();
_nextClientSeed = cRand.Next();
_nextServerSeed = sRand.Next();
await Initialize();
// Always initially connect clients.
// This is done in case the server does randomization when client first connects
// This is to try and prevent issues where if the first test that connects the client is consistently some test
// that uses a fixed seed, it would effectively prevent the initial configuration from being randomized.
await Connect();
if (!Settings.Connected)
await Disconnect("Initial disconnect");
}
protected virtual Task Initialize()
{
return Task.CompletedTask;
}
protected abstract Task<TClient> GenerateClient();
protected abstract Task<TServer> GenerateServer();
public void Kill()
{
State = PairState.Dead;
ServerLogHandler.ShuttingDown = true;
ClientLogHandler.ShuttingDown = true;
Server.Dispose();
Client.Dispose();
}
private void ClearContext()
{
TestOut = default!;
ServerLogHandler.ClearContext();
ClientLogHandler.ClearContext();
}
public void ActivateContext(TextWriter testOut)
{
TestOut = testOut;
ServerLogHandler.ActivateContext(testOut);
ClientLogHandler.ActivateContext(testOut);
}
public void Use()
{
if (State != PairState.Ready)
throw new InvalidOperationException($"Pair is not ready to use. State: {State}");
State = PairState.InUse;
}
public void SetupSeed()
{
var sRand = Server.Resolve<IRobustRandom>();
if (Settings.ServerSeed is { } severSeed)
{
ServerSeed = severSeed;
sRand.SetSeed(ServerSeed);
}
else
{
ServerSeed = _nextServerSeed;
sRand.SetSeed(ServerSeed);
_nextServerSeed = sRand.Next();
}
var cRand = Client.Resolve<IRobustRandom>();
if (Settings.ClientSeed is { } clientSeed)
{
ClientSeed = clientSeed;
cRand.SetSeed(ClientSeed);
}
else
{
ClientSeed = _nextClientSeed;
cRand.SetSeed(ClientSeed);
_nextClientSeed = cRand.Next();
}
}
private async Task LoadPrototypes(IIntegrationInstance instance, List<string> prototypes)
{
var changed = new Dictionary<Type, HashSet<string>>();
foreach (var file in prototypes)
{
instance.ProtoMan.LoadString(file, changed: changed);
}
await instance.WaitPost(() => instance.ProtoMan.ReloadPrototypes(changed));
foreach (var (kind, ids) in changed)
{
_loadedPrototypes.GetOrNew(kind).UnionWith(ids);
}
if (_loadedPrototypes.TryGetValue(typeof(EntityPrototype), out var entIds))
_loadedEntityPrototypes.UnionWith(entIds);
}
public void Deconstruct(out TServer server, out TClient client)
{
server = Server;
client = Client;
}
private void OnServerCvarChanged(CVarChangeInfo args)
{
ModifiedServerCvars.TryAdd(args.Name, args.OldValue);
}
private void OnClientCvarChanged(CVarChangeInfo args)
{
ModifiedClientCvars.TryAdd(args.Name, args.OldValue);
}
public void ClearModifiedCvars()
{
ModifiedClientCvars.Clear();
ModifiedServerCvars.Clear();
}
/// <summary>
/// Reverts any cvars that were modified during a test back to their original values.
/// </summary>
public virtual async Task RevertModifiedCvars()
{
await Server.WaitPost(() =>
{
foreach (var (name, value) in ModifiedServerCvars)
{
if (Server.CfgMan.GetCVar(name).Equals(value))
continue;
Server.Log.Info($"Resetting cvar {name} to {value}");
Server.CfgMan.SetCVar(name, value);
}
});
await Client.WaitPost(() =>
{
foreach (var (name, value) in ModifiedClientCvars)
{
if (Client.CfgMan.GetCVar(name).Equals(value))
continue;
var flags = Client.CfgMan.GetCVarFlags(name);
if (flags.HasFlag(CVar.REPLICATED) && flags.HasFlag(CVar.SERVER))
continue;
Client.Log.Info($"Resetting cvar {name} to {value}");
Client.CfgMan.SetCVar(name, value);
}
});
ClearModifiedCvars();
}
}

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