diff --git a/Directory.Packages.props b/Directory.Packages.props index 724621432..a08ae7d9d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -74,7 +74,7 @@ - + diff --git a/MSBuild/Robust.Engine.Version.props b/MSBuild/Robust.Engine.Version.props index 4f36c6fa0..0371fd488 100644 --- a/MSBuild/Robust.Engine.Version.props +++ b/MSBuild/Robust.Engine.Version.props @@ -1,4 +1,4 @@ - - - 286.0.0 - + + + 288.0.1 + diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 07d9a1e2c..33eb101eb 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -26,7 +26,7 @@ Don't change the format without looking at the script! ### Internal -* Pool sprite post-shader render targets in Clyde. ([#6657](https://github.com/space-wizards/RobustToolbox/pull/6657)) +*None yet* END TEMPLATE--> @@ -54,6 +54,80 @@ END TEMPLATE--> *None yet* +## 288.0.1 + +### Bugfixes + +* Fix server and client getstate not being aligned for ComponentNetworkGenerator. +* Fix components removed on entity deserializer not flagging the entity as dirty. + + +## 288.0.0 + +### Breaking changes + +* `ReplayData` no longer exposes the `States`/`Messages` lists; use `Count`, `GetState(index)` and `GetMessages(index)` instead. Replay history is now provided lazily through the new `IReplayDataProvider` interface, and `IReplayLoadManager.GenerateCheckpointsAsync` is no longer part of the public API. +* GridFixtureSystem now updates the grid origin for split grids to re-centre them. +* Batch font outline drawing with new API methods. +* SharedMapSystem enumerators can now use struct foreach loops instead of .MoveNext calls and obsoleted the other ones. + +### New features + +* Added IAudioManager.ConvertAudioDeviceNameForDisplay helper method for decoding OpenAL device names into a more human-readable format. +* The replay client now streams replay history from disk instead of keeping the entire deserialized replay resident in RAM, both while loading (history is streamed block-by-block through checkpoint generation) and during playback (data blocks are lazily re-read through a small LRU window, configurable via the `replay.loaded_block_window` cvar). Measured on an 861 MB, 1h38m replay this halves load time and cuts peak memory from ~22 GB to ~12 GB. +* Added WithCompOrNull helper methods to EntitySystems. +* Fix deletion rectangle rotation +* Added CVar to mute on unfocus. + +### Bugfixes + +* Failed runtime prototype uploads are now dropped. +* Fix spawn tiles window UIBox2 errors. +* Release all keybinds when window loses focus. +* Fix chunt pausing not aligning with the attached root pausing. + +### Internal + +* ISimulation no longer has SpawnEntity methods, resolve IEntityManager and call the spawn methods directly instead. +* Run GenerateClient and GenerateServer in parallel. + + +## 287.0.0 + +### Breaking changes + +* OccluderComponent now supports convex polygons and no longer uses a bounding box. These use the same limitations as convex hulls for physics (no more than 8 points). +* Update Yamldotnet to 18.1.0 +* EntityPrototype components are now interned and shared. Any components that have the same datafield data are now shared when stored on PrototypeManager, saving significant amounts of memory. +* RSIStates now store AtlasTexture and not Texture, speeding up RSI rendering by directly passing it through. +* Reverted BUI state queueing. + +### New features + +* Added a field to `ComponentNetworkGenerator` to exclude components from replays. +* Added support for before and after subscriptions for the new `[SubscribeLocalEvent]` and related attributes. + +### Bugfixes + +* Make DefaultWindow call base.FrameUpdate to support animations. +* Fix Discord RPC playtime resetting on join. +* Fix some WordWrap bugs. + +### Other + +* Change `OccluderComponent` access to `ReadExecute`. +* Components that are being removed are no longer serialized. +* Added test workflows for the template repos. + +### Internal + +* Rewrite ReflectionManager for performance reasons. +* Made many optimizations to GameStates, componentregistryserializer, IoC dependencies, and collection serializers. +* Remove redundant MsgEntity properties. +* Cached texture UVs for rendering atlas textures. +* Pool sprite post-shader render targets in Clyde. + + ## 286.0.0 ### Breaking changes diff --git a/Resources/Locale/en-US/input.ftl b/Resources/Locale/en-US/input.ftl index 4b8e1f72b..999438d06 100644 --- a/Resources/Locale/en-US/input.ftl +++ b/Resources/Locale/en-US/input.ftl @@ -78,4 +78,15 @@ input-key-RSystem-mac = Right ⌘ input-key-LSystem-linux = Left Meta input-key-RSystem-linux = Right Meta +input-key-Help = Help +input-key-Stop = Stop +input-key-Again = Again +input-key-Prop = Props +input-key-Undo = Undo +input-key-Cut = Cut +input-key-Copy = Copy +input-key-Open = Open +input-key-Paste = Paste +input-key-Find = Find + input-key-unknown = diff --git a/Resources/Locale/en-US/replays.ftl b/Resources/Locale/en-US/replays.ftl index f7949e30c..b8efa68fc 100644 --- a/Resources/Locale/en-US/replays.ftl +++ b/Resources/Locale/en-US/replays.ftl @@ -33,6 +33,8 @@ cmd-replay-error-no-replay = Not currently playing a replay. cmd-replay-error-already-loaded = A replay is already loaded. cmd-replay-error-run-level = You cannot load a replay while connected to a server. +cmd-replay-toggleui-desc = Toggles the replay control UI. + # Recording commands cmd-replay-recording-start-desc = Starts a replay recording, optionally with some time limit. diff --git a/Robust.Analyzers.Tests/EntitySystemSubscriptionConversionAnalyzerTest.cs b/Robust.Analyzers.Tests/EntitySystemSubscriptionConversionAnalyzerTest.cs new file mode 100644 index 000000000..3c5fdb4c3 --- /dev/null +++ b/Robust.Analyzers.Tests/EntitySystemSubscriptionConversionAnalyzerTest.cs @@ -0,0 +1,274 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using VerifyCS = + Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier; + +namespace Robust.Analyzers.Tests; + +[TestOf(typeof(EntitySystemSubscriptionConversionAnalyzer))] +public sealed class EntitySystemSubscriptionConversionAnalyzerTest +{ + private static Task Verifier(string code, params DiagnosticResult[] expected) + { + var test = new CSharpAnalyzerTest() + { + TestState = + { + Sources = { code } + }, + }; + + 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 Robust.Shared.GameObjects; + using System; + + namespace Robust.Shared.GameObjects + { + public interface IComponent; + public abstract class Component : IComponent; + + public readonly struct EntityUid; + + public delegate void ComponentEventRefHandler(EntityUid uid, TComp component, ref TEvent args) + where TComp : IComponent + where TEvent : notnull; + + public delegate void ComponentEventHandler(EntityUid uid, TComp component, TEvent args) + where TComp : IComponent + where TEvent : notnull; + + public interface IEntitySystem; + public abstract class EntitySystem : IEntitySystem + { + public virtual void Initialize() { } + public void SubscribeLocalEvent( + ComponentEventRefHandler handler, + Type[]? before = null, + Type[]? after = null) + where TComp : IComponent + where TEvent : notnull + { } + + public void SubscribeLocalEvent( + ComponentEventHandler handler, + Type[]? before = null, + Type[]? after = null) + where TComp : IComponent + where TEvent : notnull + { } + } + } + + namespace Robust.Shared.Analyzers + { + public sealed class SubscribeLocalEventAttribute : Attribute; + } + + public readonly struct TestEvent; + public readonly struct TestEvent2; + public readonly struct TestEvent3; + public sealed partial class TestComponent : IComponent; + """; + + [Test] + [Description("Tests that a SubscribeLocalEvent invocation in an EntitySystem Intialize method is flagged as elligible for conversion.")] + public async Task FlagSubscribeLocalEvent() + { + const string code = """ + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, + // /0/Test0.cs(9,9): info RA0058: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(9, 9, 9, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a subscription using an anonymous delegate is not flagged as elligible for conversion.")] + public async Task IgnoreAnonymousDelegate() + { + const string code = """ + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent((u, c, ref _) => OnTest(u, c)); + } + + private void OnTest(EntityUid uid, TestComponent comp) { } + } + """; + + await Verifier(code, []); + } + + [Test] + [Description("Tests that a subscription in a method containing preprocessor directives is not flagged as elligible for conversion.")] + public async Task IgnoreWithPreprocessorDirectives() + { + const string code = """ + + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + #if DEBUG + SubscribeLocalEvent(OnTest); + #else + SubscribeLocalEvent(OnTest2); + #endif + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) { } + private void OnTest2(EntityUid uid, TestComponent comp, ref TestEvent args) { } + } + """; + + await Verifier(code, []); + } + + [Test] + [Description("Tests that a subscription using a generic type parameter is not flagged as elligible for conversion.")] + public async Task IgnoreWithGenericComponent() + { + const string code = """ + + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + where TComp : Component + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); + } + + private void OnTest(EntityUid uid, TComp comp, ref TestEvent args) { } + } + """; + + await Verifier(code, []); + } + + [Test] + [Description("Tests that subscriptions using generic methods as event handlers are not flagged as elligible for conversion.")] + public async Task IgnoreWithGenericHandler() + { + const string code = """ + + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); + SubscribeLocalEvent(OnTest); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref T args) where T : TestEventArgs { } + } + + public class TestEventArgs; + public sealed class TestEventClassA : TestEventArgs; + public sealed class TestEventClassB : TestEventArgs; + """; + + await Verifier(code, []); + } + + [Test] + [Description("Tests that subscriptions using event handlers with abstract event types are not flagged as elligible for conversion.")] + public async Task IgnoreWithAbstractHandler() + { + const string code = """ + + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); + SubscribeLocalEvent(OnTest); + } + + private void OnTest(EntityUid uid, TestComponent comp, TestEventArgs args) { } + } + + public abstract class TestEventArgs; + public sealed class TestEventClassA : TestEventArgs; + public sealed class TestEventClassB : TestEventArgs; + """; + + await Verifier(code, []); + } + + [Test] + [Description("Tests that subscriptions within if statement blocks are not flagged as elligible for conversion.")] + public async Task IgnoreWithIfStatement() + { + const string code = """ + + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + if (true) + SubscribeLocalEvent(OnTest); + else + SubscribeLocalEvent(OnTest2); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) { } + private void OnTest2(EntityUid uid, TestComponent comp, ref TestEvent args) { } + } + """; + + await Verifier(code, []); + } +} diff --git a/Robust.Analyzers.Tests/EntitySystemSubscriptionConversionFixerTest.cs b/Robust.Analyzers.Tests/EntitySystemSubscriptionConversionFixerTest.cs new file mode 100644 index 000000000..2b1c237d1 --- /dev/null +++ b/Robust.Analyzers.Tests/EntitySystemSubscriptionConversionFixerTest.cs @@ -0,0 +1,743 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using VerifyCS = + Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier; + +namespace Robust.Analyzers.Tests; + +public sealed class EntitySystemSubscriptionConversionFixerTest +{ + private static Task Verifier(string code, string fixedCode, params DiagnosticResult[] expected) + { + var test = new CSharpCodeFixTest() + { + TestState = + { + Sources = { code }, + }, + FixedState = + { + Sources = { fixedCode }, + } + }; + + test.TestState.Sources.Add(("TestTypeDefs.cs", TestTypeDefs)); + test.FixedState.Sources.Add(("TestTypeDefs.cs", TestTypeDefs)); + + test.TestState.ExpectedDiagnostics.AddRange(expected); + + return test.RunAsync(); + } + + private static Task Verifier(string[] code, string[] fixedCode, params DiagnosticResult[] expected) + { + var test = new CSharpCodeFixTest(); + + foreach (var file in code) + { + test.TestState.Sources.Add(file); + } + foreach (var file in fixedCode) + { + test.FixedState.Sources.Add(file); + } + + test.TestState.Sources.Add(("TestTypeDefs.cs", TestTypeDefs)); + test.FixedState.Sources.Add(("TestTypeDefs.cs", TestTypeDefs)); + + test.TestState.ExpectedDiagnostics.AddRange(expected); + + return test.RunAsync(); + } + + private const string TestTypeDefs = """ + using Robust.Shared.GameObjects; + using System; + + namespace Robust.Shared.GameObjects + { + public interface IComponent; + public abstract class Component : IComponent; + + public readonly struct EntityUid; + + public delegate void ComponentEventRefHandler(EntityUid uid, TComp component, ref TEvent args) + where TComp : IComponent + where TEvent : notnull; + public delegate void EntityEventHandler(T ev); + public delegate void EntitySessionEventHandler(T msg, string foo); + + public interface IEntitySystem; + public abstract class EntitySystem : IEntitySystem + { + public virtual void Initialize() { } + public void SubscribeLocalEvent( + ComponentEventRefHandler handler, + Type[]? before = null, Type[]? after = null) + where TComp : IComponent + where TEvent : notnull + { } + protected void SubscribeNetworkEvent( + EntityEventHandler handler, + Type[]? before = null, Type[]? after = null) + where T : notnull + { } + protected void SubscribeAllEvent( + EntitySessionEventHandler handler, + Type[]? before = null, Type[]? after = null) + where T : notnull + { } + } + } + + namespace Robust.Shared.Analyzers + { + public sealed class SubscribeLocalEventAttribute(Type[]? before = null, Type[]? after = null) : Attribute; + public sealed class SubscribeNetworkEventAttribute(Type[]? before = null, Type[]? after = null) : Attribute; + public sealed class EventSubscriptionAttribute(Type[]? before = null, Type[]? after = null) : Attribute; + } + + public readonly struct TestEvent; + public readonly struct TestEvent2; + public sealed partial class TestComponent : IComponent; + public sealed class TestNetworkEvent; + + public sealed class SomeOtherSystemA : EntitySystem; + public sealed class SomeOtherSystemB : EntitySystem; + """; + + [Test] + [Description("Tests that a SubscribeLocalEvent invocation is correctly converted to an attribute.")] + public async Task ConvertLocalEvent() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0058: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that multiple SubscribeLocalEvent invocations are correctly converted to attributes.")] + public async Task ConvertLocalEvent_Multiple() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + SubscribeLocalEvent(OnTest2); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + + private void OnTest2(EntityUid uid, TestComponent comp, ref TestEvent2 args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + + [SubscribeLocalEvent] + private void OnTest2(EntityUid uid, TestComponent comp, ref TestEvent2 args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute"), + // /0/Test0.cs(11,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(11, 9, 11, 64).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that multiple SubscribeLocalEvent invocations are correctly converted to attributes when there is a gap between them.")] + public async Task ConvertLocalEvent_MultipleWithGap() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + + SubscribeLocalEvent(OnTest2); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + + private void OnTest2(EntityUid uid, TestComponent comp, ref TestEvent2 args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + + [SubscribeLocalEvent] + private void OnTest2(EntityUid uid, TestComponent comp, ref TestEvent2 args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute"), + // /0/Test0.cs(12,9): info RA0057:Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(12, 9, 12, 64).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a SubscribeLocalEvent invocation with before and after parameters is correctly converted to an attribute.")] + public async Task ConvertLocalEvent_WithBeforeAfter() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest, before: [typeof(SomeOtherSystemA)], after: new[] { typeof(SomeOtherSystemB) }); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent(before: [typeof(SomeOtherSystemA)], after: [typeof(SomeOtherSystemB)])] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0058: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 141).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a SubscribeLocalEvent invocation a before parameter is correctly converted to an attribute.")] + public async Task ConvertLocalEvent_WithBefore() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest, before: new[] { typeof(SomeOtherSystemA) }); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent(before: [typeof(SomeOtherSystemA)])] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0058: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 106).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a SubscribeLocalEvent invocation an after parameter is correctly converted to an attribute.")] + public async Task ConvertLocalEvent_WithAfter() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest, after: [typeof(SomeOtherSystemA), typeof(SomeOtherSystemB)]); + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent(after: [typeof(SomeOtherSystemA), typeof(SomeOtherSystemB)])] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0058: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 123).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a class that isn't marked partial is given the partial modifier when converted.")] + public async Task ConvertLocalEvent_AddPartial() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a class is given the using directive for the SubscribeLocalEventAttribute namespace when converted.")] + public async Task ConvertLocalEvent_AddUsingDirective() + { + const string code = """ + using Robust.Shared.GameObjects; + + public sealed class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(9,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(9, 9, 9, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that the conversion isn't confused by other methods with the same name as the event handler.")] + public async Task ConvertLocalEvent_HandlerOverload() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + } + + private void OnTest(string foo) { } + + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + private void OnTest(string foo) { } + + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } + + [Test] + [Description("Tests that a SubscribeNetworkEvent invocation is correctly converted to an attribute.")] + public async Task ConvertNetworkEvent() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeNetworkEvent(OnTest); // Comment here + } + + private void OnTest(TestNetworkEvent args) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [SubscribeNetworkEvent] + private void OnTest(TestNetworkEvent args) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0058: Event subscription using SubscribeNetworkEvent can be converted to use SubscribeNetworkEventAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 56).WithArguments("SubscribeNetworkEvent", "SubscribeNetworkEventAttribute") + ); + } + + [Test] + [Description("Tests that a SubscribeAllEvent invocation is correctly converted to an attribute.")] + public async Task ConvertAllEvent() + { + const string code = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeAllEvent(OnTest); // Comment here + } + + private void OnTest(TestNetworkEvent args, string foo) + { + // Do something + } + } + """; + + const string fixedCode = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + + [EventSubscription] + private void OnTest(TestNetworkEvent args, string foo) + { + // Do something + } + } + """; + + await Verifier(code, fixedCode, + // /0/Test0.cs(10,9): info RA0058: Event subscription using SubscribeAllEvent can be converted to use EventSubscriptionAttribute + VerifyCS.Diagnostic().WithSpan(10, 9, 10, 52).WithArguments("SubscribeAllEvent", "EventSubscriptionAttribute") + ); + } + + [Test] + [Description("Tests that the conversion works correctly when the Initialize and event handler methods are declared in separate files (partial classes).")] + public async Task ConvertLocalEvent_WithPartials() + { + const string code1 = """ + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnTest); // Comment here + } + } + """; + + const string code2 = """ + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + const string fixed1 = """ + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + public override void Initialize() + { + base.Initialize(); + } + } + """; + + const string fixed2 = """ + using Robust.Shared.Analyzers; + using Robust.Shared.GameObjects; + + public sealed partial class InitalizeBasedSystem : EntitySystem + { + [SubscribeLocalEvent] + private void OnTest(EntityUid uid, TestComponent comp, ref TestEvent args) + { + // Do something + } + } + """; + + await Verifier([code1, code2], [fixed1, fixed2], + // /0/Test0.cs(9,9): info RA0057: Event subscription using SubscribeLocalEvent can be converted to use SubscribeLocalEventAttribute + VerifyCS.Diagnostic().WithSpan(9, 9, 9, 62).WithArguments("SubscribeLocalEvent", "SubscribeLocalEventAttribute") + ); + } +} diff --git a/Robust.Analyzers.Tests/HasDependenciesGeneratorTest.cs b/Robust.Analyzers.Tests/HasDependenciesGeneratorTest.cs index f10b06308..2c68e19ce 100644 --- a/Robust.Analyzers.Tests/HasDependenciesGeneratorTest.cs +++ b/Robust.Analyzers.Tests/HasDependenciesGeneratorTest.cs @@ -37,18 +37,9 @@ public sealed partial class Foobar public partial class Foobar : global::Robust.Shared.IoC.IHasDependencies { [global::Robust.Shared.Analyzers.RobustAutoGenerated] - global::System.Type[] global::Robust.Shared.IoC.IHasDependencies.GetDependencyTypes() + void global::Robust.Shared.IoC.IHasDependencies.Inject(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - return new global::System.Type[] - { - typeof(global::string) - }; - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - void global::Robust.Shared.IoC.IHasDependencies.Inject(global::System.ReadOnlySpan instances) - { - Foo = (global::string)instances[0]; + Foo = dependencies.ResolveInject(typeof(Foobar)); } } @@ -86,32 +77,16 @@ public sealed partial class Bar : Foo public partial class Foo : global::Robust.Shared.IoC.IHasDependencies { [global::Robust.Shared.Analyzers.RobustAutoGenerated] - global::System.Type[] global::Robust.Shared.IoC.IHasDependencies.GetDependencyTypes() - { - return GetDependencyTypesImpl(); - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - void global::Robust.Shared.IoC.IHasDependencies.Inject(global::System.ReadOnlySpan instances) + void global::Robust.Shared.IoC.IHasDependencies.Inject(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - InjectImpl(instances); + InjectImpl(dependencies); } [global::Robust.Shared.Analyzers.RobustAutoGenerated] [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected virtual global::System.Type[] GetDependencyTypesImpl() + protected virtual void InjectImpl(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - return new global::System.Type[] - { - typeof(global::string) - }; - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected virtual void InjectImpl(global::System.ReadOnlySpan instances) - { - _x = (global::string)instances[0]; + _x = dependencies.ResolveInject(typeof(Foo)); } } @@ -128,25 +103,11 @@ public partial class Bar { [global::Robust.Shared.Analyzers.RobustAutoGenerated] [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected override global::System.Type[] GetDependencyTypesImpl() - { - var baseTypes = base.GetDependencyTypesImpl(); - var types = new global::System.Type[baseTypes.Length + 1]; - - types[0] = typeof(global::string); - - global::System.Array.Copy(baseTypes, 0, types, 1, baseTypes.Length); - - return types; - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected override void InjectImpl(global::System.ReadOnlySpan instances) + protected override void InjectImpl(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - _heck = (global::string)instances[0]; + _heck = dependencies.ResolveInject(typeof(Bar)); - base.InjectImpl(instances.Slice(1)); + base.InjectImpl(dependencies); } } @@ -184,32 +145,16 @@ public sealed partial class Bar : Foo public partial class Foo : global::Robust.Shared.IoC.IHasDependencies { [global::Robust.Shared.Analyzers.RobustAutoGenerated] - global::System.Type[] global::Robust.Shared.IoC.IHasDependencies.GetDependencyTypes() + void global::Robust.Shared.IoC.IHasDependencies.Inject(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - return GetDependencyTypesImpl(); - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - void global::Robust.Shared.IoC.IHasDependencies.Inject(global::System.ReadOnlySpan instances) - { - InjectImpl(instances); + InjectImpl(dependencies); } [global::Robust.Shared.Analyzers.RobustAutoGenerated] [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected virtual global::System.Type[] GetDependencyTypesImpl() + protected virtual void InjectImpl(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - return new global::System.Type[] - { - typeof(global::string) - }; - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected virtual void InjectImpl(global::System.ReadOnlySpan instances) - { - _x = (global::string)instances[0]; + _x = dependencies.ResolveInject(typeof(Foo)); } } @@ -226,25 +171,11 @@ public partial class Bar { [global::Robust.Shared.Analyzers.RobustAutoGenerated] [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected override global::System.Type[] GetDependencyTypesImpl() - { - var baseTypes = base.GetDependencyTypesImpl(); - var types = new global::System.Type[baseTypes.Length + 1]; - - types[0] = typeof(global::string); - - global::System.Array.Copy(baseTypes, 0, types, 1, baseTypes.Length); - - return types; - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - protected override void InjectImpl(global::System.ReadOnlySpan instances) + protected override void InjectImpl(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - _heck = (global::string)instances[0]; + _heck = dependencies.ResolveInject(typeof(Bar)); - base.InjectImpl(instances.Slice(1)); + base.InjectImpl(dependencies); } } @@ -315,18 +246,9 @@ public partial class Real public partial class Foobar : global::Robust.Shared.IoC.IHasDependencies { [global::Robust.Shared.Analyzers.RobustAutoGenerated] - global::System.Type[] global::Robust.Shared.IoC.IHasDependencies.GetDependencyTypes() - { - return new global::System.Type[] - { - typeof(global::string) - }; - } - - [global::Robust.Shared.Analyzers.RobustAutoGenerated] - void global::Robust.Shared.IoC.IHasDependencies.Inject(global::System.ReadOnlySpan instances) + void global::Robust.Shared.IoC.IHasDependencies.Inject(global::Robust.Shared.IoC.IDependencyCollection dependencies) { - Foo = (global::string)instances[0]; + Foo = dependencies.ResolveInject(typeof(Real.Foobar)); } } } diff --git a/Robust.Analyzers/EntitySystemSubscriptionConversionAnalyzer.cs b/Robust.Analyzers/EntitySystemSubscriptionConversionAnalyzer.cs new file mode 100644 index 000000000..358caf99d --- /dev/null +++ b/Robust.Analyzers/EntitySystemSubscriptionConversionAnalyzer.cs @@ -0,0 +1,167 @@ +#nullable enable +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using Robust.Roslyn.Shared; + +namespace Robust.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EntitySystemSubscriptionConversionAnalyzer : DiagnosticAnalyzer +{ + private const string EntitySystemTypeName = "Robust.Shared.GameObjects.IEntitySystem"; + private const string SubscribeLocalEventAttributeTypeName = "Robust.Shared.Analyzers.SubscribeLocalEventAttribute"; + private const string SubscribeLocalEventMethodName = "SubscribeLocalEvent"; + private const string SubscribeNetworkEventMethodName = "SubscribeNetworkEvent"; + private const string SubscribeAllEventMethodName = "SubscribeAllEvent"; + private const string SubscribeAllEventAttributeName = "EventSubscription"; + private static readonly string[] SubscribeMethods = + [ + SubscribeLocalEventMethodName, + SubscribeNetworkEventMethodName, + SubscribeAllEventMethodName, + ]; + + /// + /// The key used to access the needed replacement attribute name in the diagnostic's Properties dictionary. + /// + public const string AttributeNameKey = "attribute"; + + public static readonly DiagnosticDescriptor EntitySystemSubscriptionConversionPossible = new( + Diagnostics.IdEntitySystemSubscriptionConversionPossible, + "Convert to attribute-based subscription", + "Event subscription using {0} can be converted to use {1}", + "Usage", + DiagnosticSeverity.Info, + true + ); + + public override ImmutableArray SupportedDiagnostics => + [ + EntitySystemSubscriptionConversionPossible, + ]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(ctx => + { + // If the subscription attributes aren't available in this compilation, we can't do anything. + if (ctx.Compilation.GetTypeByMetadataName(SubscribeLocalEventAttributeTypeName) is null) + return; + + if (ctx.Compilation.GetTypeByMetadataName(EntitySystemTypeName) is not { } entitySystemType) + return; + + ctx.RegisterSymbolStartAction(symbolContext => + { + // We only care about classes + if (symbolContext.Symbol is not INamedTypeSymbol typeSymbol || typeSymbol.TypeKind != TypeKind.Class) + return; + + // Filter out anything that isn't an EntitySystem + if (!typeSymbol.AllInterfaces.Contains(entitySystemType)) + return; + + // Check each method definition in the class + symbolContext.RegisterOperationAction(AnalyzeMethod, OperationKind.MethodBody); + }, SymbolKind.NamedType); + }); + } + + private static void AnalyzeMethod(OperationAnalysisContext context) + { + if (context.Operation is not IMethodBodyOperation method) + return; + + if (method.BlockBody is null) + return; + + // If the class contains any sort of conditional directives, + // we consider it too complicated for automatic conversion. + var classSyntax = method.Syntax.Ancestors().OfType().First(); + if (classSyntax.ContainsDirective(SyntaxKind.IfDirectiveTrivia | SyntaxKind.ElseDirectiveTrivia | SyntaxKind.ElifDirectiveTrivia | SyntaxKind.EndIfDirectiveTrivia)) + return; + + // Examine each operation within the Initialize method body + foreach (var initOperation in method.BlockBody.ChildOperations) + { + // We only care about method invocations + if (initOperation is not IExpressionStatementOperation expression + || expression.Operation is not IInvocationOperation invocation) + continue; + + // Check if the invoked method is one of the SubscribeWhateverEvent methods + if (SubscribeMethods.Contains(invocation.TargetMethod.Name)) + { + // If any of the type arguments of the invocation is a type parameter (rather than a distinct Type), + // the attribute can't handle it, so we skip it. + // For example, RaiseLocalEvent(), where TTreeComp is a type arg to the containing class. + if (invocation.TargetMethod.TypeArguments.OfType().Any()) + continue; + + // Ignore anything that isn't a direct method reference, i.e. an anonymous delegate. + if (invocation.Arguments.SingleOrDefault(arg => arg.Parameter?.Name == "handler") is not { } handlerArg + || handlerArg.Value.Syntax is not IdentifierNameSyntax) + continue; + + // Get the symbol for the event handler method. + // We use OriginalDefinition to get the generic form if it's a generic method. + // So we get MyEventHandler instead of MyEventHandler. + if (((handlerArg.Value as IDelegateCreationOperation)?.Target as IMethodReferenceOperation)?.Method.OriginalDefinition is not { } handlerMethod) + continue; + + // If the target method is generic, we can't subscribe using the attribute. + if (handlerMethod.IsGenericMethod) + continue; + + // If the target method's event type is abstract, we can't subscribe using the attribute, + // since the subscription needs the exact type. + // This assumes that the event is the last parameter in the handler's signature, + // which seems like a reasonable assumption at the time of writing. + var handlerEventType = handlerMethod.Parameters.Last().Type; + if (handlerEventType.IsAbstract) + continue; + + // If the handler is a virtual or abstract method, we can't use the attribute + // since we would have to add it to the base class. + if (handlerMethod.IsVirtual || handlerMethod.IsAbstract) + continue; + + // Find the name of the attribute we need to use to replace the invocation and + // add it to the diagnostic so the code fixer can easily get it. + var attributeName = ToAttributeName(invocation.TargetMethod.Name); + var props = new Dictionary + { + { AttributeNameKey, attributeName } + }; + + // Flag this subscription as elligible for conversion + context.ReportDiagnostic(Diagnostic.Create( + EntitySystemSubscriptionConversionPossible, + invocation.Syntax.GetLocation(), + props.ToImmutableDictionary(), + invocation.TargetMethod.Name, + $"{attributeName}Attribute" + )); + } + } + } + + /// + /// Returns the name of the appropriate attribute to replace the given subscription method. + /// + public static string ToAttributeName(string methodName) + { + return methodName switch + { + SubscribeAllEventMethodName => SubscribeAllEventAttributeName, + _ => methodName + }; + } +} diff --git a/Robust.Analyzers/EntitySystemSubscriptionConversionFixer.cs b/Robust.Analyzers/EntitySystemSubscriptionConversionFixer.cs new file mode 100644 index 000000000..28b9f02e0 --- /dev/null +++ b/Robust.Analyzers/EntitySystemSubscriptionConversionFixer.cs @@ -0,0 +1,237 @@ +#nullable enable +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Simplification; +using static Robust.Roslyn.Shared.Diagnostics; + +namespace Robust.Analyzers; + +[ExportCodeFixProvider(LanguageNames.CSharp)] +public sealed class EntitySystemSubscriptionConversionFixer : CodeFixProvider +{ + private const string AttributeNamespace = "Robust.Shared.Analyzers"; + + public override ImmutableArray FixableDiagnosticIds => + [ + IdEntitySystemSubscriptionConversionPossible + ]; + + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + foreach (var diagnostic in context.Diagnostics) + { + switch (diagnostic.Id) + { + case IdEntitySystemSubscriptionConversionPossible: + return RegisterSubscriptionConversion(context, diagnostic); + } + } + + return Task.CompletedTask; + } + + private static async Task RegisterSubscriptionConversion(CodeFixContext context, Diagnostic diagnostic) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + + var span = diagnostic.Location.SourceSpan; + var invocationSyntax = root?.FindToken(span.Start).Parent?.AncestorsAndSelf().OfType().First(); + var classSyntax = invocationSyntax?.AncestorsAndSelf().OfType().First(); + + if (invocationSyntax is null || classSyntax is null) + return; + + // Get the name of the Attribute we need to add to the event handler method. + if (diagnostic.Properties[EntitySystemSubscriptionConversionAnalyzer.AttributeNameKey] is not string attributeName) + return; + + context.RegisterCodeFix(CodeAction.Create( + "Convert subscription to attribute", + c => ConvertSubscription(context.Document, invocationSyntax, classSyntax, attributeName, c), + "Convert subscription to attribute" + ), diagnostic); + } + + private static async Task ConvertSubscription( + Document document, + InvocationExpressionSyntax invocationSyntax, + ClassDeclarationSyntax classSyntax, + string attributeName, + CancellationToken c) + { + // Get the identifier of the event handler method. + if (invocationSyntax.ArgumentList.Arguments[0].Expression is not IdentifierNameSyntax handlerMethodIdentifer) + throw new InvalidOperationException($"Exception determining event handler method identifier for {invocationSyntax}"); + + var model = await document.GetSemanticModelAsync(c); + if (model.GetSymbolInfo(handlerMethodIdentifer, c).Symbol is not IMethodSymbol handlerMethodSymbol) + throw new InvalidOperationException($"Failed to find event handler method {handlerMethodIdentifer}"); + + if (model.GetDeclaredSymbol(classSyntax) is not { } classSymbol) + throw new InvalidOperationException($"Failed to find symbol for class {classSyntax.Identifier}"); + + if (model?.GetOperation(invocationSyntax) is not IInvocationOperation invocationOperation) + throw new InvalidOperationException($"Failed to find invocation operation"); + + var beforeTypes = GetTypesList(invocationOperation, "before"); + var afterTypes = GetTypesList(invocationOperation, "after"); + + // Create a SolutionEditor to edit multiple documents without worrying about immutability. + // The Initialize method might be in a different document than the handler, thanks to partial classes. + var editor = new SolutionEditor(document.Project.Solution); + + // Get an editor for the document containing the Initialize method. + var initializeEditor = await editor.GetDocumentEditorAsync(document.Id, c); + // Make our changes to the document containing the Initialize method. + ModifyInitialize(initializeEditor, invocationSyntax); + + // Find the ID for the document containing the event handler method. + var handlerDocId = editor.OriginalSolution.GetDocumentId(handlerMethodSymbol.DeclaringSyntaxReferences.First().SyntaxTree); + + // Get an editor for the document containing the event handler method. + // If the event handler is in the same document as the Initialize method, just reuse the same editor. + var handlerEditor = (handlerDocId == document.Id) ? initializeEditor : await editor.GetDocumentEditorAsync(handlerDocId, c); + // Make our changes to the document containing the event handler method. + ModifyHandler(handlerEditor, handlerMethodSymbol, attributeName, beforeTypes, afterTypes); + + // Make sure the class is marked as partial. + EnsureClassPartial(initializeEditor, classSymbol, classSyntax); + + // Return the modified solution. + return editor.GetChangedSolution(); + } + + /// + /// Edits the document containing the Intialize method. + /// Removes the SubscribeWhateverEvent method invocation. + /// + /// An editor for the document containing the Initialize method. + /// The SyntaxNode for the invocation of the Initialize method. + private static void ModifyInitialize( + DocumentEditor editor, + InvocationExpressionSyntax invocationSyntax) + { + // Remove the SubscribeWhateverEvent invocation from the Initialize method. + editor.RemoveNode(invocationSyntax.Parent!, SyntaxRemoveOptions.KeepUnbalancedDirectives); + } + + /// + /// Edits the document containing the event handler method. + /// Adds the SubscribeWhateverEventAttribute to the method. + /// + /// An editor for the document containing the event handler method. + /// The symbol for the event handler method. + /// The name of the Attribute to be added. + private static void ModifyHandler( + DocumentEditor editor, + IMethodSymbol handlerMethodSymbol, + string attributeName, + IEnumerable? beforeTypes, + IEnumerable? afterTypes + ) + { + // Get the syntax node for the event handler method. + var handlerMethodSyntax = handlerMethodSymbol.DeclaringSyntaxReferences.First().GetSyntax() as MethodDeclarationSyntax; + + // Generate an annotation containing the full name of the attribute we're adding. + // The magic string "SymbolId" makes this a SymbolAnnotation for Simplifier.AddImportsAnnotation to use. + var symbolAnnotation = new SyntaxAnnotation("SymbolId", $"{AttributeNamespace}.{attributeName}Attribute"); + + // Create the identifier for the attribute, annotating it with the full class name and AddImportsAnnotation. + // When Roslyn applies this code fix, AddImportsAnnotation tells it to add any missing using directives, + // but it needs the full name of the class to be able to do so. + var identifier = editor.Generator.IdentifierName(attributeName).WithAdditionalAnnotations(symbolAnnotation, Simplifier.AddImportsAnnotation); + + // Generate the SubscribeWhateverEvent attribute. + var attr = editor.Generator.Attribute(identifier); + + // Generate attribute argument syntax nodes for the before and after arguments. + var before = GenerateTypesArgument(beforeTypes, "before"); + var after = GenerateTypesArgument(afterTypes, "after"); + + // Remove either or both if they are null (meaning they weren't in the original invocation). + var args = new[]{before, after}.Where(arg => arg is not null); + + // If either or both are non-null, add them as arguments to the attribute. + // If both are null, we don't add anything otherwise we get empty parentheses on the attribute. + if (args.Any()) + attr = editor.Generator.AddAttributeArguments(attr, args!); + + // Add the attribute to the event handler method. + editor.AddAttribute(handlerMethodSyntax!, attr); + } + + /// + /// Marks the class as partial if it's not already. + /// + private static void EnsureClassPartial( + DocumentEditor editor, + INamedTypeSymbol classSymbol, + ClassDeclarationSyntax classSyntax) + { + // Use the current modifiers as a base. + var oldModifiers = DeclarationModifiers.From(classSymbol); + // Add the partial modifier if it's not already there. + editor.SetModifiers(classSyntax, oldModifiers.WithPartial(true)); + } + + /// + /// Extracts an enumerable containing the nodes passed to the named + /// parameter of an invocation. + /// + /// The method invocation the argument is being passed to. + /// The name of the parameter ("before" or "after") + /// + /// Thrown if the passed value is not a valid type of expression. + /// The passed value must be either a collection expression or an array literal. + /// + private static IEnumerable? GetTypesList(IInvocationOperation invocationOperation, string parameter) + { + // Get the operation representing the argument we're looking for. + var arg = invocationOperation.Arguments.Where(arg => arg.Parameter?.Name == parameter).SingleOrDefault(); + // If the argument is omitted, the operation will be a DefaultValueOperation. + if (arg.Value is IDefaultValueOperation or null) + return null; + // The way of getting the set of elements varies depending on the syntax that was used. + var expression = (arg.Syntax as ArgumentSyntax)?.Expression; + return expression switch + { + // SubscribeLocalEvent(MyMethod, before: [typeof(MyOtherSystem)]) + CollectionExpressionSyntax collection => collection.Elements.OfType().Select(e => e.Expression), + // SubscribeLocalEvent(MyMethod, before: new Type[] { typeof(MyOtherSystem) }) + ArrayCreationExpressionSyntax arrayCreation => arrayCreation.Initializer?.Expressions ?? [], + // SubscribeLocalEvent(MyMethod, before: new[] { typeof(MyOtherSystem) }) + ImplicitArrayCreationExpressionSyntax implicitArrayCreation => implicitArrayCreation.Initializer.Expressions, + _ => throw new InvalidOperationException("Invalid types list") + }; + } + + /// + /// Returns a syntax node representing an attribute argument passing a collection expression of typeof expressions. + /// + /// The typeof expressions to populate the collection. + /// The name of the method parameter this argument is being passed to ("before" or "after"). + private static AttributeArgumentSyntax? GenerateTypesArgument(IEnumerable? types, string name) + { + if (types is null) + return null; + // Explicitly naming the parameters is much nicer for readability, especially with optional parameters. + var nameColon = SyntaxFactory.NameColon(name); + // Throw our list of typeof expressions into a collection expression. + var syntaxList = SyntaxFactory.SeparatedList(types.Select(SyntaxFactory.ExpressionElement)); + var collection = SyntaxFactory.CollectionExpression(syntaxList); + // Return the complete argument to be added to the attribute. + return SyntaxFactory.AttributeArgument(null, nameColon, collection); + } +} diff --git a/Robust.Analyzers/Generators/HasDependenciesGenerator.cs b/Robust.Analyzers/Generators/HasDependenciesGenerator.cs index 971c3261f..274bdb5bc 100644 --- a/Robust.Analyzers/Generators/HasDependenciesGenerator.cs +++ b/Robust.Analyzers/Generators/HasDependenciesGenerator.cs @@ -131,16 +131,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { // Explicit impl only sb.AppendLineIndented("[global::Robust.Shared.Analyzers.RobustAutoGenerated]"); - sb.AppendLineIndented($"global::System.Type[] global::{IHasDependenciesName}.GetDependencyTypes()"); + sb.AppendLineIndented($"void global::{IHasDependenciesName}.Inject(global::Robust.Shared.IoC.IDependencyCollection dependencies)"); sb.AppendOpeningBrace(); // { - WriteGetDependencies(ref sb, fields, false); - sb.AppendClosingBrace(); // } - sb.AppendLine(); - - sb.AppendLineIndented("[global::Robust.Shared.Analyzers.RobustAutoGenerated]"); - sb.AppendLineIndented($"void global::{IHasDependenciesName}.Inject(global::System.ReadOnlySpan instances)"); - sb.AppendOpeningBrace(); // { - WriteInject(ref sb, fields, false); + WriteInject(ref sb, fields, false, typeInfo.DisplayName); sb.AppendClosingBrace(); // } } else @@ -149,16 +142,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { // Explicit impl -> protected virtual methods sb.AppendLineIndented("[global::Robust.Shared.Analyzers.RobustAutoGenerated]"); - sb.AppendLineIndented($"global::System.Type[] global::{IHasDependenciesName}.GetDependencyTypes()"); - sb.AppendOpeningBrace(); // { - sb.AppendLineIndented("return GetDependencyTypesImpl();"); - sb.AppendClosingBrace(); // } - sb.AppendLine(); - - sb.AppendLineIndented("[global::Robust.Shared.Analyzers.RobustAutoGenerated]"); - sb.AppendLineIndented($"void global::{IHasDependenciesName}.Inject(global::System.ReadOnlySpan instances)"); + sb.AppendLineIndented($"void global::{IHasDependenciesName}.Inject(global::Robust.Shared.IoC.IDependencyCollection dependencies)"); sb.AppendOpeningBrace(); // { - sb.AppendLineIndented("InjectImpl(instances);"); + sb.AppendLineIndented("InjectImpl(dependencies);"); sb.AppendClosingBrace(); // } sb.AppendLine(); } @@ -166,17 +152,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Protected virtual/override methods sb.AppendLineIndented("[global::Robust.Shared.Analyzers.RobustAutoGenerated]"); sb.AppendLineIndented("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); - sb.AppendLineIndented($"protected {(hasParent ? "override" : "virtual")} global::System.Type[] GetDependencyTypesImpl()"); + sb.AppendLineIndented($"protected {(hasParent ? "override" : "virtual")} void InjectImpl(global::Robust.Shared.IoC.IDependencyCollection dependencies)"); sb.AppendOpeningBrace(); // { - WriteGetDependencies(ref sb, fields, hasParent); - sb.AppendClosingBrace(); // } - sb.AppendLine(); - - sb.AppendLineIndented("[global::Robust.Shared.Analyzers.RobustAutoGenerated]"); - sb.AppendLineIndented("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); - sb.AppendLineIndented($"protected {(hasParent ? "override" : "virtual")} void InjectImpl(global::System.ReadOnlySpan instances)"); - sb.AppendOpeningBrace(); // { - WriteInject(ref sb, fields, hasParent); + WriteInject(ref sb, fields, hasParent, typeInfo.DisplayName); sb.AppendClosingBrace(); // } } @@ -188,61 +166,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } - private static void WriteGetDependencies(ref IndentWriter sb, EquatableArray fields, bool isOverride) - { - if (isOverride) - { - sb.AppendLineIndented("var baseTypes = base.GetDependencyTypesImpl();"); - sb.AppendLineIndented($"var types = new global::System.Type[baseTypes.Length + {fields.Length}];"); - - sb.AppendLine(); - - for (var i = 0; i < fields.Length; i++) - { - var field = fields[i]; - sb.AppendLineIndented($"types[{i}] = typeof(global::{field.TypeName});"); - } - - sb.AppendLine(); - - sb.AppendLineIndented($"global::System.Array.Copy(baseTypes, 0, types, {fields.Length}, baseTypes.Length);"); - - sb.AppendLine(); - - sb.AppendLineIndented("return types;"); - } - else - { - sb.AppendLineIndented("return new global::System.Type[]"); - sb.AppendOpeningBrace(); - - for (var i = 0; i < fields.Length; i++) - { - var field = fields[i]; - sb.AppendIndents(); - sb.Append($"typeof(global::{field.TypeName})"); - if (i != fields.Length - 1) - sb.Append(","); - sb.AppendLine(); - } - - sb.PopDepth(); - sb.AppendLineIndented("};"); - } - } - - private static void WriteInject(ref IndentWriter sb, EquatableArray fields, bool isOverride) + private static void WriteInject(ref IndentWriter sb, EquatableArray fields, bool isOverride, string typeName) { for (var i = 0; i < fields.Length; i++) { var field = fields[i]; - sb.AppendLineIndented($"{field.Name} = (global::{field.TypeName})instances[{i}];"); + sb.AppendLineIndented($"{field.Name} = dependencies.ResolveInject(typeof({typeName}));"); } if (isOverride) { sb.AppendLine(); - sb.AppendLineIndented($"base.InjectImpl(instances.Slice({fields.Length}));"); + sb.AppendLineIndented("base.InjectImpl(dependencies);"); } } diff --git a/Robust.Benchmarks/Graphics/AtlasTextureUvBenchmark.cs b/Robust.Benchmarks/Graphics/AtlasTextureUvBenchmark.cs new file mode 100644 index 000000000..194d5654b --- /dev/null +++ b/Robust.Benchmarks/Graphics/AtlasTextureUvBenchmark.cs @@ -0,0 +1,94 @@ +using System; +using BenchmarkDotNet.Attributes; +using Robust.Client.Graphics; +using Robust.Shared.Graphics; +using Robust.Shared.Maths; +using ClydeRenderer = Robust.Client.Graphics.Clyde.Clyde; + +namespace Robust.Benchmarks.Graphics; + +[MemoryDiagnoser] +public class AtlasTextureUvBenchmark +{ + private AtlasTexture _atlas = default!; + private Texture _texture = default!; + private UIBox2? _subRegion; + + [GlobalSetup] + public void Setup() + { + var sourceTexture = new ClydeRenderer.ClydeTexture((ClydeHandle) 42, (256, 256), false, null!); + GC.SuppressFinalize(sourceTexture); + _atlas = new AtlasTexture(sourceTexture, UIBox2.FromDimensions(48, 80, 16, 16)); + _texture = _atlas; + _subRegion = null; + } + + [Benchmark(Baseline = true)] + public DrawCall LegacyAtlasPath() + { + var sourceTexture = ExtractTexture(_texture, in _subRegion, out var region); + return new DrawCall((long) sourceTexture.TextureId, CalculateUvs(sourceTexture, region)); + } + + [Benchmark] + public DrawCall TextureCallerToAtlasOverload() + { + return DrawCached(_texture, in _subRegion); + } + + [Benchmark] + public DrawCall StaticallyTypedAtlasCaller() + { + return DrawCached(_atlas); + } + + private static DrawCall DrawCached(Texture texture, in UIBox2? subRegion) + { + if (subRegion == null && texture is AtlasTexture atlas) + return DrawCached(atlas); + + var fallbackTexture = ExtractTexture(texture, in subRegion, out var region); + return new DrawCall((long) fallbackTexture.TextureId, CalculateUvs(fallbackTexture, region)); + } + + private static DrawCall DrawCached(AtlasTexture texture) + { + return new DrawCall((long) texture.ClydeTexture!.TextureId, texture.NormalizedSubRegion); + } + + private static ClydeRenderer.ClydeTexture ExtractTexture(Texture texture, in UIBox2? subRegion, out UIBox2 region) + { + if (texture is AtlasTexture atlas) + { + texture = atlas.SourceTexture; + if (subRegion.HasValue) + { + var offset = atlas.SubRegion.TopLeft; + region = new UIBox2(subRegion.Value.TopLeft + offset, subRegion.Value.BottomRight + offset); + } + else + { + region = atlas.SubRegion; + } + } + else + { + region = subRegion ?? new UIBox2(0, 0, texture.Width, texture.Height); + } + + return (ClydeRenderer.ClydeTexture) texture; + } + + private static Box2 CalculateUvs(Texture texture, UIBox2 region) + { + var (width, height) = texture.Size; + return new Box2( + region.Left / width, + (height - region.Bottom) / height, + region.Right / width, + (height - region.Top) / height); + } + + public readonly record struct DrawCall(long TextureId, Box2 TexCoords); +} diff --git a/Robust.Benchmarks/Robust.Benchmarks.csproj b/Robust.Benchmarks/Robust.Benchmarks.csproj index f47c475bc..129d5a238 100644 --- a/Robust.Benchmarks/Robust.Benchmarks.csproj +++ b/Robust.Benchmarks/Robust.Benchmarks.csproj @@ -9,6 +9,7 @@ false + diff --git a/Robust.Client.IntegrationTests/GameStates/GameStateProcessor_Tests.cs b/Robust.Client.IntegrationTests/GameStates/GameStateProcessor_Tests.cs index 3c86ca8b2..b679304fe 100644 --- a/Robust.Client.IntegrationTests/GameStates/GameStateProcessor_Tests.cs +++ b/Robust.Client.IntegrationTests/GameStates/GameStateProcessor_Tests.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using Robust.Client.GameStates; using Robust.Client.Timing; +using Robust.Shared.GameObjects; using Robust.Shared.GameStates; using Robust.Shared.Log; using Robust.Shared.Timing; @@ -163,6 +164,55 @@ public void ExtrapolateAdvanceWithFutureState() Assert.That(curState, Is.Not.Null); } + [Test] + public void PvsDetachMergesMessagesWithSameTick() + { + var (_, processor) = SetupEmptyProcessor(); + var tick = new GameTick(5); + + processor.AddLeavePvsMessage(new() { Ent(1), Ent(2) }, tick); + processor.AddLeavePvsMessage(new() { Ent(3) }, tick); + + var result = processor.GetEntitiesToDetach(tick, 10); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Tick, Is.EqualTo(tick)); + Assert.That(result[0].Entities, Is.EqualTo(new[] { Ent(1), Ent(2), Ent(3) })); + } + + [Test] + public void PvsDetachProcessesOldestTickFirst() + { + var (_, processor) = SetupEmptyProcessor(); + + processor.AddLeavePvsMessage(new() { Ent(10) }, new GameTick(10)); + processor.AddLeavePvsMessage(new() { Ent(5) }, new GameTick(5)); + + var result = processor.GetEntitiesToDetach(new GameTick(10), 10); + + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].Tick, Is.EqualTo(new GameTick(5))); + Assert.That(result[1].Tick, Is.EqualTo(new GameTick(10))); + } + + [Test] + public void PvsDetachPartialBudgetKeepsRemainingEntities() + { + var (_, processor) = SetupEmptyProcessor(); + var tick = new GameTick(1); + processor.AddLeavePvsMessage(new() { Ent(1), Ent(2), Ent(3) }, tick); + + var result = processor.GetEntitiesToDetach(tick, 2); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Entities, Is.EqualTo(new[] { Ent(2), Ent(3) })); + + result = processor.GetEntitiesToDetach(tick, 10); + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Entities, Is.EqualTo(new[] { Ent(1) })); + } + + /// /// Creates a new empty GameState with the given to and from properties. /// @@ -171,6 +221,25 @@ private static GameState GameStateFactory(uint from, uint to) return new(new GameTick(@from), new GameTick(to), 0, default, default, default); } + private static NetEntity Ent(int id) => new(id); + + private static (IClientGameTiming timing, GameStateProcessor processor) SetupEmptyProcessor() + { + var timingMock = new Mock(); + timingMock.SetupProperty(p => p.CurTick); + timingMock.SetupProperty(p => p.LastProcessedTick); + timingMock.SetupProperty(p => p.LastRealTick); + timingMock.SetupProperty(p => p.TickTimingAdjustment); + + var timing = timingMock.Object; + var managerMock = new Mock(); + var logMock = new Mock(); + var processor = new GameStateProcessor(managerMock.Object, timing, logMock.Object); + + return (timing, processor); + } + + /// /// Creates a new GameTiming and GameStateProcessor, fills the processor with enough states, and calculate the first tick. /// CurTick = 1, states 1 - 3 are in the buffer. diff --git a/Robust.Client.Tests/Graphics/AtlasTextureTest.cs b/Robust.Client.Tests/Graphics/AtlasTextureTest.cs new file mode 100644 index 000000000..dd35848a8 --- /dev/null +++ b/Robust.Client.Tests/Graphics/AtlasTextureTest.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using Robust.Client.Graphics; +using Robust.Shared.Maths; + +namespace Robust.Client.Tests.Graphics; + +[TestFixture] +public sealed class AtlasTextureTest +{ + [Test] + public void AllowsNonClydeSourceTextures() + { + var source = new TestTexture((64, 64)); + var atlas = new AtlasTexture(source, UIBox2.FromDimensions(8, 16, 32, 32)); + + Assert.That(atlas.SourceTexture, Is.SameAs(source)); + Assert.That(atlas.ClydeTexture, Is.Null); + } + + private sealed class TestTexture(Vector2i size) : Texture(size) + { + public override Color GetPixel(int x, int y) + { + return Color.Black; + } + } +} diff --git a/Robust.Client.Tests/UserInterface/WordWrapTest.cs b/Robust.Client.Tests/UserInterface/WordWrapTest.cs new file mode 100644 index 000000000..ed3657558 --- /dev/null +++ b/Robust.Client.Tests/UserInterface/WordWrapTest.cs @@ -0,0 +1,89 @@ +using NUnit.Framework; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using System.Text; + +namespace Robust.Client.Tests.UserInterface; + +[Parallelizable(ParallelScope.All)] +public sealed class WordWrapTest +{ + private List GenerateBreaks(string s, int maxWidth) + { + var breaksOut = new List(); + var wrapper = new WordWrap(maxSizeX: maxWidth); + + // For simplicity, assume every character has the same width, except for some special ones + var charMetrics = new CharMetrics (bearingX: 0, bearingY: 0, advance: 10, width: 10, height: 10); + var wideMetrics = new CharMetrics (bearingX: 0, bearingY: 0, advance: 25, width: 25, height: 10); + var narrowMetrics = new CharMetrics (bearingX: 0, bearingY: 0, advance: 6, width: 6, height: 10); + + foreach (var r in s.EnumerateRunes()) + { + wrapper.NextRune(r, out var breakLine, out var breakNewLine, out var skip); + if (breakLine != null) + { + breaksOut.Add(breakLine.Value); + } + if (breakNewLine != null) + { + breaksOut.Add(breakNewLine.Value); + } + if (skip) + { + continue; + } + + var metrics = charMetrics; + if (r == new Rune('W')) + { + metrics = wideMetrics; + } + else if (r == new Rune('|')) + { + metrics = narrowMetrics; + } + + wrapper.NextMetrics(metrics, out breakLine, out var abort); + + if (breakLine != null) + { + breaksOut.Add(breakLine.Value); + } + if (abort) + { + return breaksOut; + } + } + + return breaksOut; + } + + [Test] + // Basic wrapping. First two words fit on one line, need a break to fit the third + //Breaks at: v + [TestCase("1 3 123", 50, new int[]{4})] + // Basic wrapping, over more lines: + //Breaks at: v v + [TestCase("1 3 123 5 1234", 50, new int[]{4, 10})] + // Word doesn't fit on one line, need to break mid-word + //Breaks at: v + [TestCase("12345123", 50, new int[]{5})] + // Word doesn't fit on *two* lines, needs two breaks mid-word + //Breaks at: v v + [TestCase("1234512345123", 50, new int[]{5, 10})] + // Same, but with some words at the start + //Breaks at: v v v + [TestCase("1 3 12345123451", 50, new int[]{4, 9, 14})] + // Can fit first two words on one line, need a break for the third word and needs splitting mid-word + //Breaks at: v v + [TestCase("1 3 12345123", 50, new int[]{4, 9})] + // Check for a debug assert in WordWrap. Second word needs an extra split on the last character + //Breaks at: v v + [TestCase("123 1|34W ", 50, new int[]{4, 8})] + public void TestSimpleWrapping(string s, int maxWidth, int[] expectedBreaks) + { + var breaks = GenerateBreaks(s, maxWidth); + Assert.That(breaks, Is.EqualTo(expectedBreaks)); + } +} diff --git a/Robust.Client/Audio/AudioManager.Public.cs b/Robust.Client/Audio/AudioManager.Public.cs index 21901d80b..39bba49d8 100644 --- a/Robust.Client/Audio/AudioManager.Public.cs +++ b/Robust.Client/Audio/AudioManager.Public.cs @@ -5,6 +5,7 @@ using OpenTK.Audio.OpenAL; using Robust.Client.Audio.Sources; using Robust.Client.Graphics; +using Robust.Shared; using Robust.Shared.Audio; using Robust.Shared.Audio.AudioLoading; using Robust.Shared.Audio.Sources; @@ -45,6 +46,11 @@ public void InitializePostWindowing() public void Shutdown() { + _clyde.OnWindowFocused -= OnWindowFocused; + _cfg.UnsubValueChanged(CVars.AudioMasterVolume, SetMasterGain); + _cfg.UnsubValueChanged(CVars.AudioMuteUnfocused, OnMuteUnfocusedChanged); + _cfg.UnsubValueChanged(CVars.AudioDevice, OnAudioDeviceChanged); + DisposeAllAudio(); if (_openALContext != ALContext.Null) @@ -84,6 +90,17 @@ public void SetRotation(Angle angle) AL.Listener(ALListenerfv.Orientation, ref at, ref up); } + public void FrameUpdate(float frameTime) + { + if (MathF.Abs(FadeGain - _masterFadeTargetGain) < 0.001f) + return; + + _masterFadeElapsed = MathF.Min(_masterFadeElapsed + frameTime, MasterFadeDuration); + var t = MasterFadeDuration <= 0f ? 1f : _masterFadeElapsed / MasterFadeDuration; + FadeGain = MathHelper.Lerp(_masterFadeStartGain, _masterFadeTargetGain, t); + ApplyMasterGain(); + } + void IAudioInternal.Remove(AudioStream stream) { if (stream.ClydeHandle == null) @@ -235,15 +252,26 @@ public void SetMasterGain(float newGain) if (newGain < 0f) { OpenALSawmill.Error("Tried to set master gain below 0, clamping to 0"); - AL.Listener(ALListenerf.Gain, 0f); - return; + newGain = 0f; } + BaseGain = newGain; + ApplyMasterGain(); + } + + public float BaseGain { get; private set; } + + public float FadeGain { get; private set; } = 1f; + + private void ApplyMasterGain() + { + var effectiveGain = BaseGain * FadeGain; + #region Platform hack for MacOS // HACK/BUG: Apple's OpenAL implementation has a bug where values of 0f for listener gain don't actually // HACK/BUG: prevent sound playback. Workaround is to cap the minimum gain at a value just above 0. - if (OperatingSystem.IsMacOS() && newGain == 0f) + if (OperatingSystem.IsMacOS() && effectiveGain == 0f) { OpenALSawmill.Verbose("Not setting gain to 0 because Apple can't write an OpenAL implementation"); AL.Listener(ALListenerf.Gain, float.Epsilon); @@ -251,7 +279,7 @@ public void SetMasterGain(float newGain) } #endregion Platform hack for MacOS - AL.Listener(ALListenerf.Gain, newGain); + AL.Listener(ALListenerf.Gain, effectiveGain); } public void SetAttenuation(Attenuation attenuation) diff --git a/Robust.Client/Audio/AudioManager.cs b/Robust.Client/Audio/AudioManager.cs index f70f3b8d3..96aa9eed5 100644 --- a/Robust.Client/Audio/AudioManager.cs +++ b/Robust.Client/Audio/AudioManager.cs @@ -6,11 +6,13 @@ using System.Threading; using OpenTK.Audio.OpenAL; using Robust.Client.Audio.Sources; +using Robust.Client.Graphics; using Robust.Client.ResourceManagement; using Robust.Shared; using Robust.Shared.Audio; using Robust.Shared.Configuration; using Robust.Shared.Log; +using Robust.Shared.Maths; using Robust.Shared.Utility; namespace Robust.Client.Audio; @@ -21,6 +23,7 @@ internal sealed partial class AudioManager : IAudioInternal [Shared.IoC.Dependency] private ILogManager _logMan = default!; [Shared.IoC.Dependency] private IReloadManager _reload = default!; [Shared.IoC.Dependency] private IResourceCache _cache = default!; + [Shared.IoC.Dependency] private IClydeInternal _clyde = default!; private Thread? _gameThread; @@ -39,6 +42,12 @@ internal sealed partial class AudioManager : IAudioInternal private readonly HashSet _alContextExtensions = new(); private Attenuation _attenuation; private bool _audioInitialized; + private bool _focused = true; + private bool _muteUnfocused; + private const float MasterFadeDuration = 0.25f; + private float _masterFadeElapsed = MasterFadeDuration; + private float _masterFadeStartGain = 1f; + private float _masterFadeTargetGain = 1f; public bool HasAlDeviceExtension(string extension) => _alcDeviceExtensions.Contains(extension); public bool HasAlContextExtension(string extension) => _alContextExtensions.Contains(extension); @@ -168,7 +177,9 @@ private void InitializeAudio() IsEfxSupported = HasAlDeviceExtension("ALC_EXT_EFX"); _cfg.OnValueChanged(CVars.AudioMasterVolume, SetMasterGain, true); + _cfg.OnValueChanged(CVars.AudioMuteUnfocused, OnMuteUnfocusedChanged, true); _cfg.OnValueChanged(CVars.AudioDevice, OnAudioDeviceChanged); + _clyde.OnWindowFocused += OnWindowFocused; _reload.Register("/Audio", "*.ogg"); _reload.Register("/Audio", "*.wav"); @@ -177,6 +188,37 @@ private void InitializeAudio() _audioInitialized = true; } + private void OnMuteUnfocusedChanged(bool muteUnfocused) + { + _muteUnfocused = muteUnfocused; + SetMasterFadeTarget(GetMasterFadeTarget()); + } + + private void OnWindowFocused(WindowFocusedEventArgs args) + { + if (args.Window != _clyde.MainWindow) + return; + + _focused = args.Focused; + SetMasterFadeTarget(GetMasterFadeTarget()); + } + + private float GetMasterFadeTarget() + { + return _muteUnfocused && !_focused ? 0f : 1f; + } + + private void SetMasterFadeTarget(float fadeGain) + { + if (MathF.Abs(_masterFadeTargetGain - fadeGain) < 0.001f) + return; + + _masterFadeStartGain = FadeGain; + _masterFadeTargetGain = fadeGain; + _masterFadeElapsed = 0f; + ApplyMasterGain(); + } + private void OnAudioDeviceChanged(string deviceSpecifier) { if (!_audioInitialized) diff --git a/Robust.Client/Audio/HeadlessAudioManager.cs b/Robust.Client/Audio/HeadlessAudioManager.cs index b38449adc..a4a1d593c 100644 --- a/Robust.Client/Audio/HeadlessAudioManager.cs +++ b/Robust.Client/Audio/HeadlessAudioManager.cs @@ -33,6 +33,11 @@ public void FlushALDisposeQueues() { } + /// + public void FrameUpdate(float frameTime) + { + } + /// public IAudioSource CreateAudioSource(AudioStream stream) { @@ -73,8 +78,13 @@ public void SetRotation(Angle angle) /// public void SetMasterGain(float newGain) { + BaseGain = Math.Max(newGain, 0f); } + public float BaseGain { get; private set; } + + public float FadeGain { get; private set; } = 1f; + /// public void SetAttenuation(Attenuation attenuation) { diff --git a/Robust.Client/Audio/IAudioInternal.cs b/Robust.Client/Audio/IAudioInternal.cs index 9b594451f..4c83aa339 100644 --- a/Robust.Client/Audio/IAudioInternal.cs +++ b/Robust.Client/Audio/IAudioInternal.cs @@ -21,6 +21,11 @@ internal interface IAudioInternal : IAudioManager /// void FlushALDisposeQueues(); + /// + /// Updates audio-manager frame state. + /// + void FrameUpdate(float frameTime); + /// /// Returns a buffered audio source. /// diff --git a/Robust.Client/Audio/IAudioManager.cs b/Robust.Client/Audio/IAudioManager.cs index 9396346b0..6f8a6e1a0 100644 --- a/Robust.Client/Audio/IAudioManager.cs +++ b/Robust.Client/Audio/IAudioManager.cs @@ -1,7 +1,10 @@ +using Robust.Shared.Audio.Sources; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; -using Robust.Shared.Audio.Sources; +using System.Text; +using Robust.Shared; namespace Robust.Client.Audio; @@ -11,6 +14,11 @@ namespace Robust.Client.Audio; [NotContentImplementable] public interface IAudioManager { + /// + /// Provides list of audio devices available on the system. Those device names can be used to change device used by the game. + /// + /// + /// IReadOnlyList GetAudioDevices(); string? GetDefaultAudioDevice(); @@ -23,5 +31,27 @@ public interface IAudioManager AudioStream LoadAudioRaw(ReadOnlySpan samples, int channels, int sampleRate, string? name = null); + float BaseGain { get; } + + float FadeGain { get; } + void SetMasterGain(float gain); + + /// + /// Helper method for decoding device names into unicode, provided by OpenAL (by method) for display. + /// Make sure to use converted names only for display and not for setting device, as it will break audio. + /// + /// + /// OpenAL provides device names in some system encoding, as it seems, + /// but it does not provide info, which encoding it used to dotnet gets UTF-8 string. + /// + static string ConvertAudioDeviceNameForDisplay(string deviceName) + { + if (CultureInfo.InstalledUICulture.TextInfo.ANSICodePage == 65001) + return deviceName; + + var enc = Encoding.GetEncoding(CultureInfo.InstalledUICulture.TextInfo.ANSICodePage); + var rawBytes = enc.GetBytes(deviceName); + return Encoding.UTF8.GetString(rawBytes); + } } diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index 7a2ca8e14..1fece6465 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -1,9 +1,7 @@ -using System; using Robust.Client.Audio; using Robust.Client.Audio.Midi; using Robust.Client.Configuration; using Robust.Client.Console; -using Robust.Client.Debugging; using Robust.Client.GameObjects; using Robust.Client.GameStates; using Robust.Client.Graphics; @@ -29,7 +27,6 @@ using Robust.Client.Upload; using Robust.Client.UserInterface; using Robust.Client.UserInterface.RichText; -using Robust.Client.UserInterface.Themes; using Robust.Client.UserInterface.XAML.Proxy; using Robust.Client.Utility; using Robust.Client.ViewVariables; @@ -43,7 +40,6 @@ using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Network.Transfer; -using Robust.Shared.Physics; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Reflection; @@ -53,6 +49,8 @@ using Robust.Shared.Upload; using Robust.Shared.Utility; using Robust.Shared.ViewVariables; +using System; +using System.Text; namespace Robust.Client { @@ -60,6 +58,8 @@ internal static class ClientIoC { public static void RegisterIoC(GameController.DisplayMode mode, IDependencyCollection deps) { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + SharedIoC.RegisterIoC(deps); deps.Register(); diff --git a/Robust.Client/Debugging/Overlays/TileDebugOverlay.cs b/Robust.Client/Debugging/Overlays/TileDebugOverlay.cs index e6c8377c2..97157efd8 100644 --- a/Robust.Client/Debugging/Overlays/TileDebugOverlay.cs +++ b/Robust.Client/Debugging/Overlays/TileDebugOverlay.cs @@ -81,8 +81,7 @@ protected virtual void DrawScreen(in OverlayDrawArgs args, Entity Sys.SetBaseRsi((Owner, this), value); } - [DataField("sprite", readOnly: true)] private string? rsi; - [DataField("layers", readOnly: true)] private List layerDatums = new(); + [DataField("sprite", readOnly: true)] internal string? rsi; + [DataField("layers", readOnly: true)] internal List layerDatums = new(); [DataField(readOnly: true)] private string? state; [DataField(readOnly: true)] private string? texture; @@ -274,16 +274,11 @@ void ISerializationHooks.AfterDeserialization() { // Please somebody burn this to the ground. There is so much spaghetti. // Why has no one answered my prayers. + // I answered half of your prayer someone please answer the rest IoCManager.InjectDependencies(this); - if (!string.IsNullOrWhiteSpace(rsi)) - { - var rsiPath = TextureRoot / rsi; - if (resourceCache.TryGetResource(rsiPath, out RSIResource? resource)) - _baseRsi = resource.RSI; - else - Logger.ErrorS(LogCategory, "Unable to load RSI '{0}'.", rsiPath); - } + + resourceCache.AddToDeserialize(this); if (layerDatums.Count == 0) { @@ -304,19 +299,6 @@ void ISerializationHooks.AfterDeserialization() } } - if (layerDatums.Count != 0) - { - LayerMap.Clear(); - Layers.Clear(); - foreach (var datum in layerDatums) - { - var layer = new Layer((Owner, this), Layers.Count); - Layers.Add(layer); - LayerSetData(layer, datum); - } - - } - BoundsDirty = true; LocalMatrix = Matrix3Helpers.CreateTransform(in offset, in rotation, in scale); } diff --git a/Robust.Client/GameObjects/EntitySystems/ClientOccluderSystem.cs b/Robust.Client/GameObjects/EntitySystems/ClientOccluderSystem.cs index 2be27f9fd..7c7f5ea0c 100644 --- a/Robust.Client/GameObjects/EntitySystems/ClientOccluderSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/ClientOccluderSystem.cs @@ -1,83 +1,91 @@ using JetBrains.Annotations; -using Robust.Shared.GameObjects; -using Robust.Shared.Map.Components; -using Robust.Shared.Map.Enumerators; using Robust.Shared.Maths; -using Robust.Shared.Utility; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Physics; using System; using System.Collections.Generic; -using Robust.Shared.IoC; -using static Robust.Shared.GameObjects.OccluderComponent; +using System.Numerics; namespace Robust.Client.GameObjects; -// NOTE: this class handles both snap grid updates of occluders, as well as occluder tree updates (via its parent). -// This seems like it's doing somewhat double work because it already has an update queue for occluders but... -// See the thing is the snap grid stuff was coded earlier -// and technically it only cares about changes in the entity's SNAP GRID position. -// Whereas the tree stuff is precise. -// Also I just realized this and I cba to refactor this again. [UsedImplicitly] -internal sealed partial class ClientOccluderSystem : OccluderSystem +public sealed partial class ClientOccluderSystem : OccluderSystem { + private const float SharedOccluderEdgeTolerance = 0.001f; + private const float SharedOccluderNeighbourQueryPadding = SharedOccluderEdgeTolerance * 4f; + private readonly HashSet _dirtyEntities = new(); - [Dependency] private SharedMapSystem _mapSystem = default!; + private readonly HashSet<(EntityUid TreeUid, Box2 Bounds)> _dirtyBounds = new(); + private readonly Vector4[] _edgeBuffer = new Vector4[PhysicsConstants.MaxPolygonVertices]; + private readonly Vector4[] _otherEdgeBuffer = new Vector4[PhysicsConstants.MaxPolygonVertices]; + + [Dependency] private EntityQuery _occluderQuery; + [Dependency] private EntityQuery _treeQuery; + [Dependency] private EntityQuery _xformQuery; - /// public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnAnchorChanged); SubscribeLocalEvent(OnShutdown); } - public override void SetEnabled(EntityUid uid, bool enabled, OccluderComponent? comp = null, MetaDataComponent? meta = null) + public override void SetPolygon(EntityUid uid, Vector2[]? polygon, OccluderComponent? comp = null) { - if (!Resolve(uid, ref comp, false) || enabled == comp.Enabled) + if (!Resolve(uid, ref comp, false)) return; - base.SetEnabled(uid, enabled, comp, meta); - - var xform = Transform(uid); - QueueTreeUpdate(uid, comp, xform); - QueueOccludedDirectionUpdate(uid, comp, xform); + base.SetPolygon(uid, polygon, comp); + QueueSharedEdgeUpdate(uid, comp); } - private void OnShutdown(EntityUid uid, OccluderComponent comp, ComponentShutdown args) + public override void SetEnabled(EntityUid uid, bool enabled, OccluderComponent? comp = null, MetaDataComponent? meta = null) { - if (!Terminating(uid)) - QueueOccludedDirectionUpdate(uid, comp); + if (!Resolve(uid, ref comp, false) || enabled == comp.Enabled) + return; + + base.SetEnabled(uid, enabled, comp, meta); + QueueSharedEdgeUpdate(uid, comp); } protected override void OnCompStartup(EntityUid uid, OccluderComponent comp, ComponentStartup args) { base.OnCompStartup(uid, comp, args); - AnchorStateChanged(uid, comp, Transform(uid)); + QueueSharedEdgeUpdate(uid, comp); } - public void AnchorStateChanged(EntityUid uid, OccluderComponent comp, TransformComponent xform) + protected override void OnCompRemoved(EntityUid uid, OccluderComponent comp, ComponentRemove args) { - QueueOccludedDirectionUpdate(uid, comp, xform); + if (!Terminating(uid)) + QueueSharedEdgeUpdate(uid, comp); + + base.OnCompRemoved(uid, comp, args); } public override void FrameUpdate(float frameTime) { base.FrameUpdate(frameTime); + foreach (var (treeUid, bounds) in _dirtyBounds) + { + DirtyOccludersInTree(treeUid, bounds); + } + + _dirtyBounds.Clear(); + if (_dirtyEntities.Count == 0) return; - var query = GetEntityQuery(); - var xforms = GetEntityQuery(); - var grids = GetEntityQuery(); - try { - foreach (var entity in _dirtyEntities) + foreach (var uid in _dirtyEntities) { - if (query.TryGetComponent(entity, out var occluder)) - UpdateOccluder(entity, occluder, query, xforms, grids); + if (_occluderQuery.TryGetComponent(uid, out var occluder) + && _xformQuery.TryGetComponent(uid, out var xform)) + { + UpdateCachedSharedEdges(uid, occluder, xform); + } } } finally @@ -86,165 +94,249 @@ public override void FrameUpdate(float frameTime) } } - private void OnAnchorChanged(EntityUid uid, OccluderComponent comp, ref AnchorStateChangedEvent args) + protected override void OnComponentMove(EntityUid uid, OccluderComponent comp, ref MoveEvent args) { - AnchorStateChanged(uid, comp, args.Transform); + QueueSharedEdgeUpdate(uid, comp, args.Component); } - private void QueueOccludedDirectionUpdate(EntityUid sender, OccluderComponent occluder, TransformComponent? xform = null) + private void OnShutdown(EntityUid uid, OccluderComponent comp, ComponentShutdown args) { - if (!Resolve(sender, ref xform)) - return; + if (!Terminating(uid)) + QueueSharedEdgeUpdate(uid, comp); + } - occluder.Occluding = OccluderDir.None; - var query = GetEntityQuery(); - Vector2i pos; - EntityUid gridId; - MapGridComponent? grid; + protected override void OnOccluderAfterAutoHandleState(EntityUid uid, OccluderComponent comp, ref AfterAutoHandleStateEvent args) + { + QueueSharedEdgeUpdate(uid, comp); + } - if (occluder.Enabled && xform.Anchored && TryComp(xform.GridUid, out grid)) - { - gridId = xform.GridUid.Value; - pos = _mapSystem.TileIndicesFor(gridId, grid, xform.Coordinates); - _dirtyEntities.Add(sender); - } - else if (occluder.LastPosition != null) - { - (gridId, pos) = occluder.LastPosition.Value; - occluder.LastPosition = null; - if (!TryComp(gridId, out grid)) - return; - } - else - { + private void QueueSharedEdgeUpdate(EntityUid uid, OccluderComponent occluder, TransformComponent? xform = null) + { + occluder.OccludingEdges = 0; + _dirtyEntities.Add(uid); + + if (occluder.LastTreeBounds is { } lastBounds) + _dirtyBounds.Add((lastBounds.TreeUid, lastBounds.Bounds.Enlarged(SharedOccluderNeighbourQueryPadding))); + + if (!Resolve(uid, ref xform, false) + || !TryGetTreeTransform(occluder, xform, out var treeUid, out _, out var treeBounds)) return; - } - DirtyNeighbours(_mapSystem.GetAnchoredEntitiesEnumerator(gridId, grid, pos + new Vector2i(0, 1)), query); - DirtyNeighbours(_mapSystem.GetAnchoredEntitiesEnumerator(gridId, grid, pos + new Vector2i(0, -1)), query); - DirtyNeighbours(_mapSystem.GetAnchoredEntitiesEnumerator(gridId, grid, pos + new Vector2i(1, 0)), query); - DirtyNeighbours(_mapSystem.GetAnchoredEntitiesEnumerator(gridId, grid, pos + new Vector2i(-1, 0)), query); + _dirtyBounds.Add((treeUid, treeBounds.Enlarged(SharedOccluderNeighbourQueryPadding))); } - private void DirtyNeighbours(AnchoredEntitiesEnumerator enumerator, EntityQuery occluderQuery) + private void DirtyOccludersInTree( + EntityUid treeUid, + Box2 treeBounds) { - while (enumerator.MoveNext(out var entity)) + // We need to handle shared edges as there's some cases where we don't want them to show, e.g. between walls. + if (!_treeQuery.TryGetComponent(treeUid, out var treeComp)) + return; + + treeComp.Tree.QueryAabb((in ComponentTreeEntry entry) => { - if (occluderQuery.TryGetComponent(entity.Value, out var occluder)) + var occluder = entry.Component; + if (!occluder.Enabled) + return true; + + occluder.OccludingEdges = 0; + _dirtyEntities.Add(entry.Uid); + return true; + }, treeBounds); + } + + private void UpdateCachedSharedEdges( + EntityUid uid, + OccluderComponent occluder, + TransformComponent xform) + { + occluder.OccludingEdges = 0; + occluder.LastTreeBounds = null; + + if (!TryGetTreeTransform(occluder, xform, out var treeUid, out var treeTransform, out var treeBounds)) + return; + + occluder.LastTreeBounds = (treeUid, treeBounds); + + var polygon = occluder.Polygon; + var edgeCount = BuildOccluderEdges(polygon, treeTransform, _edgeBuffer); + if (edgeCount == 0) + return; + + if (!_treeQuery.TryGetComponent(treeUid, out var treeComp)) + return; + + var queryBounds = treeBounds.Enlarged(SharedOccluderNeighbourQueryPadding); + var state = (Uid: uid, TreeUid: treeUid, Edges: _edgeBuffer, EdgeCount: edgeCount, Occluder: occluder, System: this); + treeComp.Tree.QueryAabb( + ref state, + static (ref ( + EntityUid Uid, + EntityUid TreeUid, + Vector4[] Edges, + int EdgeCount, + OccluderComponent Occluder, + ClientOccluderSystem System) state, + in ComponentTreeEntry entry) => { - _dirtyEntities.Add(entity.Value); - occluder.Occluding = OccluderDir.None; - } - } + if (entry.Uid == state.Uid) + return true; + + var other = entry.Component; + if (!other.Enabled || other.Polygon.Length < 3) + return true; + + var otherTransform = state.System.GetTreeTransform(entry.Transform, state.TreeUid); + var otherEdges = state.System._otherEdgeBuffer; + var otherEdgeCount = BuildOccluderEdges(other.Polygon, otherTransform, otherEdges); + if (otherEdgeCount == 0) + return true; + + state.Occluder.OccludingEdges |= CalculateSharedEdgeMask( + state.Edges.AsSpan(0, state.EdgeCount), + otherEdges.AsSpan(0, otherEdgeCount)); + return true; + }, + queryBounds); } - private void UpdateOccluder(EntityUid uid, + private bool TryGetTreeTransform( OccluderComponent occluder, - EntityQuery occluders, - EntityQuery xforms, - EntityQuery grids) + TransformComponent xform, + out EntityUid treeUid, + out Matrix3x2 treeTransform, + out Box2 treeBounds) { - // Content may want to override the default behavior for occlusion. - // Apparently OD needs this? - { - var ev = new OccluderDirectionsEvent(uid, occluder); - RaiseLocalEvent(uid, ref ev, true); + treeUid = default; + treeTransform = default; + treeBounds = default; + + var polygon = occluder.Polygon; + if (!occluder.Enabled || polygon.Length < 3 || xform.MapUid == null) + return false; + + treeUid = xform.GridUid ?? xform.MapUid.Value; + treeTransform = GetTreeTransform(xform, treeUid); + treeBounds = treeTransform.TransformBox(occluder.LocalBounds); + return true; + } - if (ev.Handled) - return; + private Matrix3x2 GetTreeTransform(TransformComponent xform, EntityUid treeUid) + { + var (position, rotation) = XformSystem.GetRelativePositionRotation(xform, treeUid); + return Matrix3Helpers.CreateTransform(position, rotation); + } + + private static byte CalculateSharedEdgeMask(ReadOnlySpan edges, ReadOnlySpan otherEdges) + { + Span edgeKeys = stackalloc OccluderEdgeKey[PhysicsConstants.MaxPolygonVertices]; + Span otherEdgeKeys = stackalloc OccluderEdgeKey[PhysicsConstants.MaxPolygonVertices]; + + for (var i = 0; i < edges.Length; i++) + { + edgeKeys[i] = OccluderEdgeKey.From(edges[i]); } - if (!occluder.Enabled) + for (var i = 0; i < otherEdges.Length; i++) { - DebugTools.Assert(occluder.Occluding == OccluderDir.None); - DebugTools.Assert(occluder.LastPosition == null); - return; + otherEdgeKeys[i] = OccluderEdgeKey.From(otherEdges[i]); } - var xform = xforms.GetComponent(uid); - if (!xform.Anchored || !grids.TryGetComponent(xform.GridUid, out var grid)) + byte mask = 0; + for (var i = 0; i < edges.Length; i++) { - DebugTools.Assert(occluder.Occluding == OccluderDir.None); - DebugTools.Assert(occluder.LastPosition == null); - return; + for (var j = 0; j < otherEdges.Length; j++) + { + if (!EdgeKeysMatch(edgeKeys[i], otherEdgeKeys[j])) + continue; + + mask = (byte) (mask | 1 << i); + break; + } } - var tile = _mapSystem.TileIndicesFor(xform.GridUid.Value, grid, xform.Coordinates); + return mask; + } + + private static int BuildOccluderEdges(ReadOnlySpan polygon, Matrix3x2 worldTransform, Span edges) + { + if (polygon.Length < 3) + return 0; - // TODO: Sub to parent changes instead or something. - // DebugTools.Assert(occluder.LastPosition == null - // || occluder.LastPosition.Value.Grid == xform.GridUid && occluder.LastPosition.Value.Tile == tile); - occluder.LastPosition = (xform.GridUid.Value, tile); + var clockwise = SignedArea(polygon) < 0f; + var first = default(Vector2); + var previous = default(Vector2); - // dir starts at the relative effective south direction; - var dir = xform.LocalRotation.GetCardinalDir(); - CheckDir(dir, OccluderDir.South, tile, occluder, xform.GridUid.Value, grid, occluders, xforms); + for (var i = 0; i < polygon.Length; i++) + { + var sourceIndex = clockwise ? i : polygon.Length - 1 - i; + var current = Vector2.Transform(polygon[sourceIndex], worldTransform); - dir = dir.GetClockwise90Degrees(); - CheckDir(dir, OccluderDir.West, tile, occluder, xform.GridUid.Value, grid, occluders, xforms); + if (i == 0) + { + first = current; + } + else + { + edges[i - 1] = EdgeToVector4(previous, current); + } - dir = dir.GetClockwise90Degrees(); - CheckDir(dir, OccluderDir.North, tile, occluder, xform.GridUid.Value, grid, occluders, xforms); + previous = current; + } - dir = dir.GetClockwise90Degrees(); - CheckDir(dir, OccluderDir.East, tile, occluder, xform.GridUid.Value, grid, occluders, xforms); + edges[polygon.Length - 1] = EdgeToVector4(previous, first); + return polygon.Length; } - private void CheckDir( - Direction dir, - OccluderDir occDir, - Vector2i tile, - OccluderComponent occluder, - EntityUid gridUid, - MapGridComponent grid, - EntityQuery query, - EntityQuery xforms) + private static float SignedArea(ReadOnlySpan vertices) { - if ((occluder.Occluding & occDir) != 0) - return; - - foreach (var neighbor in _mapSystem.GetAnchoredEntities(gridUid, grid, tile.Offset(dir))) + var area = 0f; + for (var i = 0; i < vertices.Length; i++) { - if (!query.TryGetComponent(neighbor, out var otherOccluder) || !otherOccluder.Enabled) - continue; + var j = (i + 1) % vertices.Length; + area += vertices[i].X * vertices[j].Y; + area -= vertices[i].Y * vertices[j].X; + } - occluder.Occluding |= occDir; + return area * 0.5f; + } - // while we are here, also set the occluder flag for the other entity; - var otherXform = xforms.GetComponent(neighbor); - DebugTools.Assert(otherXform.Anchored); - var rot = -otherXform.LocalRotation; - var otherOcDir = FromDirection(rot.RotateDir(dir.GetOpposite())); - otherOccluder.Occluding |= otherOcDir; - } + private static Vector4 EdgeToVector4(Vector2 a, Vector2 b) + { + return new Vector4(a.X, a.Y, b.X, b.Y); } - public static OccluderDir FromDirection(Direction dir) + private static bool EdgeKeysMatch(OccluderEdgeKey a, OccluderEdgeKey b) { - return dir switch - { - Direction.South => OccluderDir.South, - Direction.North => OccluderDir.North, - Direction.East => OccluderDir.East, - Direction.West => OccluderDir.West, - _ => throw new ArgumentException($"Invalid dir: {dir}.") - }; + return Math.Abs(a.AX - b.AX) <= 1 + && Math.Abs(a.AY - b.AY) <= 1 + && Math.Abs(a.BX - b.BX) <= 1 + && Math.Abs(a.BY - b.BY) <= 1; } - /// - /// Raised by occluders when trying to get occlusion directions. - /// - [ByRefEvent] - public struct OccluderDirectionsEvent + private readonly record struct OccluderEdgeKey(long AX, long AY, long BX, long BY) { - public bool Handled = false; - public readonly EntityUid Sender = default!; - public readonly OccluderComponent Occluder = default!; + public static OccluderEdgeKey From(Vector4 edge) + { + return From(new Vector2(edge.X, edge.Y), new Vector2(edge.Z, edge.W)); + } + + private static OccluderEdgeKey From(Vector2 a, Vector2 b) + { + var ax = Quantize(a.X); + var ay = Quantize(a.Y); + var bx = Quantize(b.X); + var by = Quantize(b.Y); + + if (ax > bx || ax == bx && ay > by) + return new OccluderEdgeKey(bx, by, ax, ay); + + return new OccluderEdgeKey(ax, ay, bx, by); + } - public OccluderDirectionsEvent(EntityUid sender, OccluderComponent occluder) + private static long Quantize(float value) { - Sender = sender; - Occluder = occluder; + return (long) MathF.Round(value / SharedOccluderEdgeTolerance); } } } diff --git a/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs b/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs index bf1eb83b4..692d34ef6 100644 --- a/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/ContainerSystem.cs @@ -113,7 +113,12 @@ private void HandleComponentState(EntityUid uid, ContainerManagerComponent compo { if (!component.Containers.TryGetValue(id, out var container)) { - var type = _serializer.FindSerializedType(typeof(BaseContainer), data.ContainerType); + var type = data.ContainerType switch + { + nameof(Container) => typeof(Container), + nameof(ContainerSlot) => typeof(ContainerSlot), + _ => null, + }; container = _dynFactory.CreateInstanceUnchecked(type!, inject: false); container.Init(this, id, (uid, component)); component.Containers.Add(id, container); diff --git a/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Component.cs b/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Component.cs index ed01d7206..7826a7de4 100644 --- a/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Component.cs +++ b/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Component.cs @@ -60,6 +60,8 @@ public void CopySprite(Entity source, Entity return; target.Comp._baseRsi = source.Comp._baseRsi; + target.Comp.rsi = source.Comp.rsi; + target.Comp.layerDatums = new List(source.Comp.layerDatums); target.Comp._bounds = source.Comp._bounds; target.Comp._visible = source.Comp._visible; target.Comp.color = source.Comp.color; @@ -76,17 +78,26 @@ in target target.Comp.NoRotation = source.Comp.NoRotation; target.Comp.DirectionOverride = source.Comp.DirectionOverride; target.Comp.EnableDirectionOverride = source.Comp.EnableDirectionOverride; - target.Comp.Layers = new List(source.Comp.Layers.Count); - foreach (var otherLayer in source.Comp.Layers) + if (source.Comp.Layers.Count == 0 && source.Comp.layerDatums.Count != 0) { - var layer = new SpriteComponent.Layer(otherLayer, target.Comp); - layer.Index = target.Comp.Layers.Count; - layer.Owner = target!; - target.Comp.Layers.Add(layer); + LoadPrototypeData(target!); + QueueUpdateIsInert(target!); + } + else + { + target.Comp.Layers = new List(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.LayerMap = source.Comp.LayerMap.ShallowClone(); } target.Comp.IsInert = source.Comp.IsInert; - target.Comp.LayerMap = source.Comp.LayerMap.ShallowClone(); target.Comp.PostShaders = new List(source.Comp.PostShaders.Count); foreach (var postShader in source.Comp.PostShaders) { diff --git a/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Render.cs b/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Render.cs index 86c536aa7..c3ca7a240 100644 --- a/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Render.cs +++ b/Robust.Client/GameObjects/EntitySystems/SpriteSystem.Render.cs @@ -141,7 +141,17 @@ private void RenderLayer(Layer layer, DrawingHandleWorld drawingHandle, ref Matr dir = overrideDirection.Value.Convert(state.RsiDirections); dir = dir.OffsetRsiDir(layer.DirOffset); - var texture = state?.GetFrame(dir, layer.AnimationFrame) ?? layer.Texture ?? GetFallbackTexture(); + AtlasTexture? atlasTexture = null; + Texture texture; + if (state != null) + { + atlasTexture = state.GetAtlasFrame(dir, layer.AnimationFrame); + texture = atlasTexture; + } + else + { + texture = layer.Texture ?? GetFallbackTexture(); + } // TODO SPRITE // Refactor shader-param-layers to a separate layer type after layers are split into types & collections. @@ -174,7 +184,10 @@ private void RenderLayer(Layer layer, DrawingHandleWorld drawingHandle, ref Matr layerColor = new(new Vector4(-1) - layerColor.RGBA); } - drawingHandle.DrawTextureRectRegion(texture, quad, layerColor); + if (atlasTexture != null) + drawingHandle.DrawTextureRect(atlasTexture, quad, layerColor); + else + drawingHandle.DrawTextureRectRegion(texture, quad, layerColor); if (layer.Shader != null) drawingHandle.UseShader(null); diff --git a/Robust.Client/GameObjects/EntitySystems/SpriteSystem.cs b/Robust.Client/GameObjects/EntitySystems/SpriteSystem.cs index 036303ddb..56cf0308a 100644 --- a/Robust.Client/GameObjects/EntitySystems/SpriteSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/SpriteSystem.cs @@ -29,7 +29,7 @@ public sealed partial class SpriteSystem : EntitySystem [Dependency] private IConfigurationManager _cfg = default!; [Dependency] private IEyeManager _eye = default!; [Dependency] private IGameTiming _timing = default!; - [Dependency] private IResourceCache _resourceCache = default!; + [Dependency] private IResourceCacheInternal _resourceCache = default!; [Dependency] private IPrototypeManager _prototypes = default!; // Note that any new system dependencies have to be added to RobustUnitTest.BaseSetup() @@ -59,7 +59,6 @@ public override void Initialize() UpdatesAfter.Add(typeof(SpriteTreeSystem)); SubscribeLocalEvent(OnPrototypesReloaded); - SubscribeLocalEvent(OnInit); Subs.CVar(_cfg, CVars.RenderSpriteDirectionBias, OnBiasChanged, true); _query = GetEntityQuery(); @@ -70,6 +69,38 @@ public bool IsVisible(Layer layer) return layer.Visible && layer.CopyToShaderParameters == null; } + [SubscribeLocalEvent] + private void OnAdd(EntityUid uid, SpriteComponent component, ComponentAdd args) + { + LoadPrototypeData((uid, component)); + } + + private void LoadPrototypeData(Entity sprite) + { + LoadBaseRsi(sprite, sprite); + LoadLayers(sprite); + } + + private void LoadBaseRsi(EntityUid uid, SpriteComponent component) + { + _resourceCache.LoadBaseRsi(uid, component); + } + + private void LoadLayers(Entity sprite) + { + if (sprite.Comp.layerDatums.Count == 0) + return; + + sprite.Comp.LayerMap.Clear(); + sprite.Comp.Layers.Clear(); + foreach (var datum in sprite.Comp.layerDatums) + { + var layer = new Layer(sprite, sprite.Comp.Layers.Count); + sprite.Comp.Layers.Add(layer); + LayerSetData(layer, datum); + } + } + [SubscribeLocalEvent] private void OnInit(EntityUid uid, SpriteComponent component, ComponentInit args) { try diff --git a/Robust.Client/GameStates/GameStateProcessor.cs b/Robust.Client/GameStates/GameStateProcessor.cs index bf5a97d2d..f65c47a7b 100644 --- a/Robust.Client/GameStates/GameStateProcessor.cs +++ b/Robust.Client/GameStates/GameStateProcessor.cs @@ -20,7 +20,7 @@ internal sealed class GameStateProcessor : IGameStateProcessor private readonly List _stateBuffer = new(); - private readonly Dictionary> _pvsDetachMessages = new(); + private readonly List<(GameTick Tick, List Entities)> _pvsDetachMessages = new(); public GameState? LastFullState { get; private set; } public bool WaitingForFull => LastFullStateRequested.HasValue; public (GameTick Tick, DateTime Time)? LastFullStateRequested { get; private set; } = (GameTick.Zero, DateTime.MaxValue); @@ -55,7 +55,11 @@ public int MaxBufferSize { get => _maxBufferSize; // We place a lower bound on the maximum size to avoid spamming servers with full game state requests. - set => _maxBufferSize = Math.Max(value, MinimumMaxBufferSize); + set + { + _maxBufferSize = Math.Max(value, MinimumMaxBufferSize); + _stateBuffer.EnsureCapacity(value); + } } /// @@ -131,7 +135,7 @@ public bool AddNewState(GameState state) public void TryAdd(GameState state) { - if (_stateBuffer.Count <= MaxBufferSize) + if (_stateBuffer.Count < MaxBufferSize) { _stateBuffer.Add(state); return; @@ -199,6 +203,7 @@ public void UpdateFullRep(GameState state, bool cloneDelta = false) { // Full state. _lastStateFullRep.Clear(); + _lastStateFullRep.EnsureCapacity(state.EntityStates.Span.Length); } else { @@ -210,29 +215,37 @@ public void UpdateFullRep(GameState state, bool cloneDelta = false) foreach (var entityState in state.EntityStates.Span) { - if (!_lastStateFullRep.TryGetValue(entityState.NetEntity, out var compData)) + ref var compDataRef = ref CollectionsMarshal.GetValueRefOrAddDefault( + _lastStateFullRep, + entityState.NetEntity, + out var compDataExists); + + if (!compDataExists) { - compData = new(); - _lastStateFullRep.Add(entityState.NetEntity, compData); + var componentCount = entityState.NetComponents?.Count ?? entityState.ComponentChanges.Span.Length; + compDataRef = new(componentCount); } + var compData = compDataRef!; foreach (var change in entityState.ComponentChanges.Span) { var compState = change.State; + ref var old = ref CollectionsMarshal.GetValueRefOrAddDefault(compData, change.NetID, out var oldExists); if (compState is not IComponentDeltaState delta) { - compData[change.NetID] = compState; + old = compState; continue; } - if (!compData.TryGetValue(change.NetID, out var old)) + if (!oldExists) { // Either the server needs to ensure that the initial state it sends to a client is a full // state, or the client needs to be able to construct an implicit full state (i.e., get-state // code needs to be in shared code). // // Without this, the client won't be able to reset predicted changes made to this component. + compData.Remove(change.NetID); DebugTools.Assert("Received delta state without having received or constructed an implicit full state"); continue; } @@ -246,7 +259,7 @@ public void UpdateFullRep(GameState state, bool cloneDelta = false) } var newFull = delta.CreateNewFullState(old!); - compData[change.NetID] = newFull; + old = newFull; DebugTools.Assert(newFull is not IComponentDeltaState, "constructed state is not a full state"); } @@ -260,7 +273,6 @@ public void UpdateFullRep(GameState state, bool cloneDelta = false) } } } - private bool TryGetFullState([NotNullWhen(true)] out GameState? curState, out GameState? nextState) { nextState = null; @@ -308,7 +320,38 @@ internal void AddLeavePvsMessage(List entities, GameTick tick) { // Late message may still need to be processed, DebugTools.Assert(entities.Count > 0); - _pvsDetachMessages.TryAdd(tick, entities); + + // Typically detaches are sorted by tick. + var count = _pvsDetachMessages.Count; + if (count == 0) + { + _pvsDetachMessages.Add((tick, entities)); + return; + } + + var lastTick = _pvsDetachMessages[count - 1].Tick; + if (tick == lastTick) + { + _pvsDetachMessages[count - 1].Entities.AddRange(entities); + return; + } + + // Normal path of new tick so just add to the end. + if (tick > lastTick) + { + _pvsDetachMessages.Add((tick, entities)); + return; + } + + // This is the slow path if the message is out of order + var index = FindDetachMessageIndex(tick); + if (index >= 0) + { + _pvsDetachMessages[index].Entities.AddRange(entities); + return; + } + + _pvsDetachMessages.Insert(~index, (tick, entities)); } public void ClearDetachQueue() => _pvsDetachMessages.Clear(); @@ -316,15 +359,24 @@ internal void AddLeavePvsMessage(List entities, GameTick tick) public List<(GameTick Tick, List Entities)> GetEntitiesToDetach(GameTick toTick, int budget) { var result = new List<(GameTick Tick, List Entities)>(); - foreach (var (tick, entities) in _pvsDetachMessages) + + if (budget <= 0) + return result; + + var removeCount = 0; + for (var i = 0; i < _pvsDetachMessages.Count; i++) { + if (budget <= 0) + break; + + var (tick, entities) = _pvsDetachMessages[i]; if (tick > toTick) - continue; + break; if (budget >= entities.Count) { budget -= entities.Count; - _pvsDetachMessages.Remove(tick); + removeCount++; result.Add((tick, entities)); continue; } @@ -334,9 +386,38 @@ internal void AddLeavePvsMessage(List entities, GameTick tick) entities.RemoveRange(index, budget); break; } + + if (removeCount > 0) + _pvsDetachMessages.RemoveRange(0, removeCount); + return result; } + private int FindDetachMessageIndex(GameTick tick) + { + // Bad binary search if we need to scrape ticks. + var low = 0; + var high = _pvsDetachMessages.Count - 1; + while (low <= high) + { + var mid = low + ((high - low) / 2); + var midTick = _pvsDetachMessages[mid].Tick; + if (midTick < tick) + { + low = mid + 1; + } + else if (midTick > tick) + { + high = mid - 1; + } + else + { + return mid; + } + } + + return ~low; + } private bool TryGetDeltaState(out GameState? curState, out GameState? nextState) { curState = null; @@ -405,6 +486,7 @@ public void MergeImplicitData(Dictionary @@ -28,11 +37,23 @@ public AtlasTexture(Texture texture, UIBox2 subRegion) : base((Vector2i) subRegi /// public Texture SourceTexture { get; } + /// + /// The Clyde texture backing this atlas texture. + /// + // Headless Clyde uses dummy textures. They are never drawn through the regular renderer, + // but atlas creation must still work for resources loaded by headless tests. + internal ClydeTextureImpl? ClydeTexture { get; } + /// /// Our sub region within our source, in pixel coordinates. /// public UIBox2 SubRegion { get; } + /// + /// Our sub region within the source texture, normalized for rendering. + /// + internal Box2 NormalizedSubRegion { get; } + public override Color GetPixel(int x, int y) { DebugTools.Assert(x < SubRegion.Right); diff --git a/Robust.Client/Graphics/Clyde/Clyde.Events.cs b/Robust.Client/Graphics/Clyde/Clyde.Events.cs index f5afe1bf1..029d12c5f 100644 --- a/Robust.Client/Graphics/Clyde/Clyde.Events.cs +++ b/Robust.Client/Graphics/Clyde/Clyde.Events.cs @@ -75,6 +75,9 @@ private void DispatchSingleEvent(DEventBase ev) OnWindowScaleChanged?.Invoke(args); break; case DEventWindowFocus(var args): + if (!args.Focused && args.Window == _mainWindow?.Handle) + _inputManager.ReleaseAllKeys(); + OnWindowFocused?.Invoke(args); break; case DEventWindowResized(var reg, var args): diff --git a/Robust.Client/Graphics/Clyde/Clyde.LightRendering.cs b/Robust.Client/Graphics/Clyde/Clyde.LightRendering.cs index 7ff0881e8..8506f7863 100644 --- a/Robust.Client/Graphics/Clyde/Clyde.LightRendering.cs +++ b/Robust.Client/Graphics/Clyde/Clyde.LightRendering.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Diagnostics.Contracts; using System.Numerics; +using System.Runtime.InteropServices; using OpenToolkit.Graphics.OpenGL4; using Robust.Client.GameObjects; using Robust.Client.ResourceManagement; @@ -13,9 +14,10 @@ using Robust.Shared.Maths; using TKStencilOp = OpenToolkit.Graphics.OpenGL4.StencilOp; using Robust.Shared.Physics; +using Robust.Shared.Physics.Shapes; +using Robust.Shared.Physics.Systems; using Robust.Shared.Enums; using Robust.Shared.Graphics; -using static Robust.Shared.GameObjects.OccluderComponent; using Robust.Shared.Utility; using TextureWrapMode = Robust.Shared.Graphics.TextureWrapMode; @@ -31,6 +33,9 @@ internal partial class Clyde // Horizontal width, in pixels, of the shadow maps used to render regular lights. private const int ShadowMapSize = 512; + private const float SharedOccluderEdgeTolerance = 0.001f; + private const float SharedOccluderEdgeToleranceSquared = SharedOccluderEdgeTolerance * SharedOccluderEdgeTolerance; + private const float SharedOccluderNeighbourQueryPadding = 1f + SharedOccluderEdgeTolerance; // Horizontal width, in pixels, of the shadow maps used to render FOV. // I figured this was more accuracy sensitive than lights so resolution is significantly higher. private const int FovMapSize = 2048; @@ -92,11 +97,25 @@ internal partial class Clyde private ClydeTexture FovTexture => _fovRenderTarget.Texture; private ClydeTexture ShadowTexture => _shadowRenderTarget.Texture; - private (PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot)[] _lightsToRenderList = default!; + private LightRenderData[] _lightsToRenderList = default!; private LightCapacityComparer _lightCap = new(); private ShadowCapacityComparer _shadowCap = new ShadowCapacityComparer(); + // Cached shared occluder edges from ClientOccluderSystem because we have very specific occluder rules. + private readonly HashSet _occluderSharedBoundaryEdges = new(); + private readonly List _occluderBoundarySegments = new(); + private readonly HashSet _occluderVisibleBoundaryVertices = new(); + private readonly HashSet _occluderConvexBoundaryVertices = new(); + private readonly Dictionary _occluderBoundaryVertexDirections = new(); + private readonly Dictionary> _occluderSharedVertexEdges = new(); + private readonly HashSet _occluderUniqueSharedEdges = new(); + private readonly List _occluderStaleSharedVertices = new(); + private readonly List _occluderRenderEntries = new(); + private readonly List _occluderRenderVertices = new(); + private readonly List _occluderRenderEdges = new(); + private readonly List _occluderRenderSharedEdges = new(); + private float _maxLightRadius; private unsafe void InitLighting() @@ -252,7 +271,6 @@ private void DrawFov(Viewport viewport, IEye eye) /// The width of the current framebuffer. /// The maximum distance of this light. /// Y index of the row to render the depth at in the framebuffer. - /// private void DrawOcclusionDepth(Vector2 lightPos, int width, float maxDist, int viewportY) { // The light is now the center of the universe. @@ -355,9 +373,7 @@ private void DrawLightsAndFov(Viewport viewport, Box2Rotated worldBounds, Box2 w (count, expandedBounds) = GetLightsToRender(mapId, worldBounds, worldAABB); } - eye.GetViewMatrixNoOffset(out var eyeTransform, eye.Scale); - - UpdateOcclusionGeometry(mapId, expandedBounds, eyeTransform); + UpdateOcclusionGeometry(mapId, expandedBounds, eye.Position.Position); DrawFov(viewport, eye); @@ -380,11 +396,16 @@ private void DrawLightsAndFov(Viewport viewport, Box2Rotated worldBounds, Box2 w { for (var i = 0; i < count; i++) { - var (light, lightPos, _, _) = _lightsToRenderList[i]; + ref var lightData = ref _lightsToRenderList[i]; + var light = lightData.Light; - if (!light.CastShadows) continue; + if (lightData.ShadowMapIndex < 0) continue; - DrawOcclusionDepth(lightPos, ShadowMapSize, light.Radius, i); + DrawOcclusionDepth( + lightData.Position, + ShadowMapSize, + light.Radius, + lightData.ShadowMapIndex); } } @@ -459,7 +480,10 @@ private void DrawLightsAndFov(Viewport viewport, Box2Rotated worldBounds, Box2 w { for (var i = 0; i < count; i++) { - var (component, lightPos, _, rot) = _lightsToRenderList[i]; + ref var lightData = ref _lightsToRenderList[i]; + var component = lightData.Light; + var lightPos = lightData.Position; + var rot = lightData.Rotation; Texture? mask = null; var rotation = Angle.Zero; @@ -515,7 +539,7 @@ private void DrawLightsAndFov(Viewport viewport, Box2Rotated worldBounds, Box2 w lightShader.SetUniformMaybe("lightCenter", lightPos); lightShader.SetUniformMaybe("lightIndex", - component.CastShadows ? (i + 0.5f) / ShadowTexture.Height : -1); + lightData.ShadowMapIndex >= 0 ? (lightData.ShadowMapIndex + 0.5f) / ShadowTexture.Height : -1); var offset = new Vector2(component.Radius, component.Radius); @@ -564,6 +588,7 @@ private void DrawLightsAndFov(Viewport viewport, Box2Rotated worldBounds, Box2 w private static bool LightQuery(ref ( Clyde clyde, + MapId map, int count, int shadowCastingCount, EntityQuery xforms, @@ -578,6 +603,9 @@ private static bool LightQuery(ref ( return false; var (light, transform) = value; + if (light is not PointLightComponent pointLight) + return true; + var (lightPos, rot) = state.clyde._transformSystem.GetWorldPositionRotation(transform, state.xforms); lightPos += rot.RotateVec(light.Offset); var circle = new Circle(lightPos, light.Radius); @@ -587,35 +615,66 @@ private static bool LightQuery(ref ( if (!circle.Intersects(state.worldAABB)) return true; - // If the light is a shadow casting light, keep a separate track of that if (light.CastShadows) + { + // Shadow-casting lights embedded inside an occluder cannot work consistently. + // As such we just disable them! If you want light inside an occluder use non-shadow casting lights! + if (state.clyde.IsLightEmbeddedInOccluder(state.map, lightPos, state.xforms)) + return true; + + // If the light is a shadow casting light, keep a separate track of that. shadowCount++; + } var distanceSquared = (state.worldAABB.Center - lightPos).LengthSquared(); - state.clyde._lightsToRenderList[count++] = ((PointLightComponent)light, lightPos, distanceSquared, rot); + state.clyde._lightsToRenderList[count++] = new LightRenderData( + pointLight, + lightPos, + distanceSquared, + rot); return true; } - private sealed class LightCapacityComparer : IComparer<(PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot)> + private struct LightRenderData + { + public PointLightComponent Light; + public Vector2 Position; + public float DistanceSquared; + public Angle Rotation; + public bool CastShadows; + public int ShadowMapIndex; + + public LightRenderData( + PointLightComponent light, + Vector2 position, + float distanceSquared, + Angle rotation) + { + Light = light; + Position = position; + DistanceSquared = distanceSquared; + Rotation = rotation; + CastShadows = light.CastShadows; + ShadowMapIndex = -1; + } + } + + private sealed class LightCapacityComparer : IComparer { - public int Compare( - (PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot) x, - (PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot) y) + public int Compare(LightRenderData x, LightRenderData y) { - if (x.light.CastShadows && !y.light.CastShadows) return 1; - if (!x.light.CastShadows && y.light.CastShadows) return -1; + if (x.CastShadows && !y.CastShadows) return 1; + if (!x.CastShadows && y.CastShadows) return -1; return 0; } } - private sealed class ShadowCapacityComparer : IComparer<(PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot)> + private sealed class ShadowCapacityComparer : IComparer { - public int Compare( - (PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot) x, - (PointLightComponent light, Vector2 pos, float distanceSquared, Angle rot) y) + public int Compare(LightRenderData x, LightRenderData y) { - return x.distanceSquared.CompareTo(y.distanceSquared); + return x.DistanceSquared.CompareTo(y.DistanceSquared); } } @@ -626,7 +685,7 @@ public int Compare( { // Use worldbounds for this one as we only care if the light intersects our actual bounds var xforms = _entityManager.GetEntityQuery(); - var state = (this, count: 0, shadowCastingCount: 0, xforms, worldAABB); + var state = (this, map, count: 0, shadowCastingCount: 0, xforms, worldAABB); var lightAabb = worldAABB.Enlarged(_maxLightRadius); foreach (var (uid, comp) in _lightTreeSystem.GetIntersectingTrees(map, lightAabb)) @@ -661,15 +720,102 @@ public int Compare( for (var i = 0; i < state.count; i++) { - expandedBounds = expandedBounds.ExtendToContain(_lightsToRenderList[i].pos); + expandedBounds = expandedBounds.ExtendToContain(_lightsToRenderList[i].Position); } + var renderedShadowCastingCount = AssignShadowMapRows(_lightsToRenderList.AsSpan(0, state.count), _maxShadowcastingLights); + _debugStats.TotalLights += state.count; - _debugStats.ShadowLights += Math.Min(state.shadowCastingCount, _maxShadowcastingLights); + _debugStats.ShadowLights += renderedShadowCastingCount; return (state.count, expandedBounds); } + private static int AssignShadowMapRows(Span lights, int maxShadowcastingLights) + { + var shadowMapIndex = 0; + + for (var i = 0; i < lights.Length; i++) + { + ref var lightData = ref lights[i]; + lightData.ShadowMapIndex = -1; + + if (!lightData.CastShadows || shadowMapIndex >= maxShadowcastingLights) + continue; + + lightData.ShadowMapIndex = shadowMapIndex; + shadowMapIndex++; + } + + return shadowMapIndex; + } + + private bool IsLightEmbeddedInOccluder( + MapId map, + Vector2 lightPosition, + EntityQuery xforms) + { + // Shadow-casting lights inside an occluder produce unstable/inside-out shadows. + // Do a narrow tree query around the light and only run the expensive polygon TestPoint + // for occluders whose cached AABB can contain the light. + var pointBounds = new Box2(lightPosition, lightPosition).Enlarged(SharedOccluderEdgeTolerance); + + foreach (var (treeUid, comp) in _occluderSystem.GetIntersectingTrees(map, pointBounds)) + { + var treeBounds = _transformSystem.GetInvWorldMatrix(treeUid, xforms).TransformBox(pointBounds); + var state = new LightEmbeddedOccluderQueryState( + _fixtureSystem, + _transformSystem, + xforms, + lightPosition); + + comp.Tree.QueryAabb(ref state, CheckLightEmbeddedInOccluder, treeBounds, approx: true); + + if (state.Embedded) + return true; + } + + return false; + } + + private static bool CheckLightEmbeddedInOccluder( + ref LightEmbeddedOccluderQueryState state, + in ComponentTreeEntry entry) + { + var occluder = entry.Component; + if (!occluder.Enabled) + return true; + + var (worldPosition, worldRotation) = state.TransformSystem.GetWorldPositionRotation( + entry.Transform, + state.Xforms); + + if (!OccluderOverlapsPoint( + state.FixtureSystem, + occluder.PolygonArray, + new Transform(worldPosition, worldRotation), + state.LightPosition)) + { + return true; + } + + state.Embedded = true; + return false; + } + + private struct LightEmbeddedOccluderQueryState( + FixtureSystem fixtureSystem, + TransformSystem transformSystem, + EntityQuery xforms, + Vector2 lightPosition) + { + public readonly FixtureSystem FixtureSystem = fixtureSystem; + public readonly TransformSystem TransformSystem = transformSystem; + public readonly EntityQuery Xforms = xforms; + public readonly Vector2 LightPosition = lightPosition; + public bool Embedded; + } + /// [Pure] public Color GetClearColor(EntityUid mapUid) @@ -824,7 +970,7 @@ private void MergeWallLayer(Viewport viewport) BindVertexArray(_occlusionMaskVao.Handle); CheckGlError(); - GL.DrawElements(GetQuadGLPrimitiveType(), _occlusionMaskDataLength, DrawElementsType.UnsignedShort, + GL.DrawElements(PrimitiveType.Triangles, _occlusionMaskDataLength, DrawElementsType.UnsignedShort, IntPtr.Zero); CheckGlError(); @@ -940,223 +1086,801 @@ private void FovSetTransformAndBlit(Viewport vp, Vector2 fovCentre, GLShaderProg _drawQuad(Vector2.Zero, Vector2.One, Matrix3x2.Identity, fovShader); } - private void UpdateOcclusionGeometry(MapId map, Box2 expandedBounds, Matrix3x2 eyeTransform) + private static int BuildOccluderEdges( + ReadOnlySpan polygon, + Matrix3x2 worldTransform, + Span edges) + { + if (polygon.Length < 3) + return 0; + + Span worldVertices = polygon.Length <= 64 + ? stackalloc Vector2[polygon.Length] + : new Vector2[polygon.Length]; + + // Occluder polygons are stored as physics hulls, i.e. generally CCW. + // The depth shader is authored for clockwise wall edges, so normalize the order here. + // TODO: Make the shader CCW to get back CPU perf here. + var clockwise = SignedArea(polygon) < 0f; + for (var i = 0; i < polygon.Length; i++) + { + var sourceIndex = clockwise ? i : polygon.Length - 1 - i; + worldVertices[i] = Vector2.Transform(polygon[sourceIndex], worldTransform); + } + + var edgeCount = 0; + for (var i = 0; i < worldVertices.Length && edgeCount < edges.Length; i++) + { + edges[edgeCount++] = EdgeToVector4(worldVertices[i], worldVertices[(i + 1) % worldVertices.Length]); + } + + return edgeCount; + } + + private static void AddOccluderBoundaryEdges( + ReadOnlySpan polygon, + Matrix3x2 worldTransform, + uint sharedEdgeMask, + HashSet sharedBoundaryEdges, + List boundarySegments) + { + if (polygon.Length < 3) + return; + + var clockwise = SignedArea(polygon) < 0f; + for (var i = 0; i < polygon.Length; i++) + { + var sourceIndex = clockwise ? i : polygon.Length - 1 - i; + var nextIndex = clockwise ? (i + 1) % polygon.Length : (polygon.Length - 2 - i + polygon.Length) % polygon.Length; + var a = Vector2.Transform(polygon[sourceIndex], worldTransform); + var b = Vector2.Transform(polygon[nextIndex], worldTransform); + var edge = EdgeToVector4(a, b); + + boundarySegments.Add(edge); + if ((sharedEdgeMask & (1u << i)) != 0) + sharedBoundaryEdges.Add(OccluderEdgeKey.From(edge)); + } + } + + private static void BuildVisibleBoundaryVertices( + IReadOnlyList boundarySegments, + IReadOnlySet sharedBoundaryEdges, + Vector2 eyePosition, + HashSet visibleBoundaryVertices) + { + visibleBoundaryVertices.Clear(); + + foreach (var edge in boundarySegments) + { + if (sharedBoundaryEdges.Contains(OccluderEdgeKey.From(edge)) || !EdgeFacesPoint(edge, eyePosition)) + continue; + + visibleBoundaryVertices.Add(OccluderVertexKey.From(new Vector2(edge.X, edge.Y))); + visibleBoundaryVertices.Add(OccluderVertexKey.From(new Vector2(edge.Z, edge.W))); + } + } + + private static void BuildConvexBoundaryVertices( + IReadOnlyList boundarySegments, + IReadOnlySet sharedBoundaryEdges, + Dictionary boundaryVertexDirections, + HashSet convexBoundaryVertices) + { + boundaryVertexDirections.Clear(); + convexBoundaryVertices.Clear(); + + foreach (var edge in boundarySegments) + { + if (sharedBoundaryEdges.Contains(OccluderEdgeKey.From(edge))) + continue; + + var a = new Vector2(edge.X, edge.Y); + var b = new Vector2(edge.Z, edge.W); + var direction = b - a; + + var aKey = OccluderVertexKey.From(a); + boundaryVertexDirections.TryGetValue(aKey, out var aDirections); + aDirections.Outgoing = direction; + aDirections.OutgoingCount++; + boundaryVertexDirections[aKey] = aDirections; + + var bKey = OccluderVertexKey.From(b); + boundaryVertexDirections.TryGetValue(bKey, out var bDirections); + bDirections.Incoming = direction; + bDirections.IncomingCount++; + boundaryVertexDirections[bKey] = bDirections; + } + + foreach (var (vertex, directions) in boundaryVertexDirections) + { + if (directions.IncomingCount != 1 || directions.OutgoingCount != 1) + continue; + + if (Vector2.Cross(directions.Incoming, directions.Outgoing) < -SharedOccluderEdgeTolerance) + convexBoundaryVertices.Add(vertex); + } + } + + private static void BuildSharedVertexEdges( + IReadOnlyList boundarySegments, + IReadOnlySet sharedBoundaryEdges, + Dictionary> sharedVertexEdges, + HashSet uniqueSharedEdges, + List? staleVertices = null) + { + foreach (var edges in sharedVertexEdges.Values) + { + edges.Clear(); + } + uniqueSharedEdges.Clear(); + + foreach (var edge in boundarySegments) + { + var edgeKey = OccluderEdgeKey.From(edge); + if (!sharedBoundaryEdges.Contains(edgeKey) || !uniqueSharedEdges.Add(edgeKey)) + continue; + + AddSharedVertexEdge(new Vector2(edge.X, edge.Y), edge, sharedVertexEdges); + AddSharedVertexEdge(new Vector2(edge.Z, edge.W), edge, sharedVertexEdges); + } + + if (staleVertices == null) + return; + + staleVertices.Clear(); + foreach (var (vertex, edges) in sharedVertexEdges) + { + if (edges.Count == 0) + staleVertices.Add(vertex); + } + + foreach (var vertex in staleVertices) + { + sharedVertexEdges.Remove(vertex); + } + } + + private static void AddSharedVertexEdge( + Vector2 vertex, + Vector4 edge, + Dictionary> sharedVertexEdges) + { + var key = OccluderVertexKey.From(vertex); + if (!sharedVertexEdges.TryGetValue(key, out var edges)) + { + edges = new List(); + sharedVertexEdges[key] = edges; + } + + edges.Add(edge); + } + + private static bool OccluderOverlapsPoint( + FixtureSystem fixtures, + Vector2[] polygon, + in Transform occluderTransform, + Vector2 worldPoint) + { + if (polygon.Length < 3) + return false; + + var occluderShape = new Polygon(polygon); + return occluderShape.VertexCount >= 3 && fixtures.TestPoint(occluderShape, occluderTransform, worldPoint); + } + + private static bool PointsMatch(Vector2 a, Vector2 b) + { + return Vector2.DistanceSquared(a, b) <= SharedOccluderEdgeToleranceSquared; + } + + private static bool ShouldSuppressSharedOccluderEdge( + int edgeIndex, + ReadOnlySpan edges, + ReadOnlySpan sharedEdges, + IReadOnlySet visibleBoundaryVertices, + IReadOnlySet convexBoundaryVertices, + IReadOnlyDictionary> sharedVertexEdges, + Vector2 eyePosition) + { + if (!sharedEdges[edgeIndex]) + return false; + + var edge = edges[edgeIndex]; + + // Corner-handling for occlusion. + if (EdgeViewedAsCap(edge, eyePosition) + || SharedEdgeContinuesThroughEyeProjection(edge, sharedVertexEdges, eyePosition) + || edges.Length == 3 && SharedEdgeTurnsAwayFromEyeAtCorner(edge, sharedVertexEdges, eyePosition)) + { + return false; + } + + var previous = edgeIndex == 0 ? edges.Length - 1 : edgeIndex - 1; + var next = edgeIndex + 1 == edges.Length ? 0 : edgeIndex + 1; + var a = new Vector2(edge.X, edge.Y); + var b = new Vector2(edge.Z, edge.W); + + var startVisible = !sharedEdges[previous] && EdgeFacesPoint(edges[previous], eyePosition); + if (!startVisible && HasBoundaryVertex(a, convexBoundaryVertices)) + startVisible = HasBoundaryVertex(a, visibleBoundaryVertices); + + var endVisible = !sharedEdges[next] && EdgeFacesPoint(edges[next], eyePosition); + if (!endVisible && HasBoundaryVertex(b, convexBoundaryVertices)) + endVisible = HasBoundaryVertex(b, visibleBoundaryVertices); + + return startVisible || endVisible; + } + + private static bool SharedEdgeContinuesThroughEyeProjection( + Vector4 edge, + IReadOnlyDictionary> sharedVertexEdges, + Vector2 eyePosition) + { + var a = new Vector2(edge.X, edge.Y); + var b = new Vector2(edge.Z, edge.W); + var edgeDelta = b - a; + var edgeLengthSquared = edgeDelta.LengthSquared(); + if (edgeLengthSquared <= SharedOccluderEdgeToleranceSquared) + return false; + + var eyeFromA = eyePosition - a; + var signedArea = Vector2.Cross(edgeDelta, eyeFromA); + if (signedArea * signedArea <= SharedOccluderEdgeToleranceSquared * edgeLengthSquared) + return false; + + var projected = Vector2.Dot(eyeFromA, edgeDelta) / edgeLengthSquared; + // Handle centres of squares essentially, mostly around diagonal walls and ensuring they function + // similarly to normal walls in a block of 2x2 for example. + if (MathF.Abs(projected) <= SharedOccluderEdgeTolerance) + return HasOppositeCollinearSharedEdge(a, b - a, edge, sharedVertexEdges); + + if (MathF.Abs(projected - 1f) <= SharedOccluderEdgeTolerance) + return HasOppositeCollinearSharedEdge(b, a - b, edge, sharedVertexEdges); + + return false; + } + + private static bool SharedEdgeTurnsAwayFromEyeAtCorner( + Vector4 edge, + IReadOnlyDictionary> sharedVertexEdges, + Vector2 eyePosition) + { + var a = new Vector2(edge.X, edge.Y); + var b = new Vector2(edge.Z, edge.W); + var edgeDelta = b - a; + var edgeLengthSquared = edgeDelta.LengthSquared(); + if (edgeLengthSquared <= SharedOccluderEdgeToleranceSquared) + return false; + + var projected = Vector2.Dot(eyePosition - a, edgeDelta) / edgeLengthSquared; + if (projected > SharedOccluderEdgeTolerance && projected < 1f - SharedOccluderEdgeTolerance) + return false; + + var junction = projected <= SharedOccluderEdgeTolerance ? a : b; + var currentFromJunction = projected <= SharedOccluderEdgeTolerance ? b - a : a - b; + var currentLengthSquared = currentFromJunction.LengthSquared(); + var eyeFromJunction = eyePosition - junction; + if (eyeFromJunction.LengthSquared() <= SharedOccluderEdgeToleranceSquared) + return false; + + var key = OccluderVertexKey.From(junction); + var currentKey = OccluderEdgeKey.From(edge); + for (var dx = -1; dx <= 1; dx++) + { + for (var dy = -1; dy <= 1; dy++) + { + if (!sharedVertexEdges.TryGetValue( + new OccluderVertexKey(key.X + dx, key.Y + dy), + out var candidates)) + continue; + + foreach (var candidate in candidates) + { + if (OccluderEdgeKey.From(candidate) == currentKey) + continue; + + var candidateA = new Vector2(candidate.X, candidate.Y); + var candidateB = new Vector2(candidate.Z, candidate.W); + Vector2 candidateFromJunction; + if (PointsMatch(candidateA, junction)) + candidateFromJunction = candidateB - junction; + else if (PointsMatch(candidateB, junction)) + candidateFromJunction = candidateA - junction; + else + continue; + + var candidateLengthSquared = candidateFromJunction.LengthSquared(); + if (candidateLengthSquared <= SharedOccluderEdgeToleranceSquared) + continue; + + var cross = Vector2.Cross(currentFromJunction, candidateFromJunction); + if (cross * cross <= SharedOccluderEdgeToleranceSquared * currentLengthSquared * candidateLengthSquared) + continue; + + // The shared edge is one side of a shared corner. If the eye is opposite the corner's + // outgoing wedge, this edge is behind a wall. + var wedgeDirection = currentFromJunction + candidateFromJunction; + if (wedgeDirection.LengthSquared() <= SharedOccluderEdgeToleranceSquared) + continue; + + if (Vector2.Dot(eyeFromJunction, wedgeDirection) < 0f) + return true; + } + } + } + + return false; + } + + private static bool HasOppositeCollinearSharedEdge( + Vector2 junction, + Vector2 currentFromJunction, + Vector4 currentEdge, + IReadOnlyDictionary> sharedVertexEdges) + { + var key = OccluderVertexKey.From(junction); + var currentKey = OccluderEdgeKey.From(currentEdge); + var currentLengthSquared = currentFromJunction.LengthSquared(); + + for (var dx = -1; dx <= 1; dx++) + { + for (var dy = -1; dy <= 1; dy++) + { + if (!sharedVertexEdges.TryGetValue( + new OccluderVertexKey(key.X + dx, key.Y + dy), + out var candidates)) + continue; + + foreach (var candidate in candidates) + { + if (OccluderEdgeKey.From(candidate) == currentKey) + continue; + + var candidateA = new Vector2(candidate.X, candidate.Y); + var candidateB = new Vector2(candidate.Z, candidate.W); + Vector2 candidateFromJunction; + if (PointsMatch(candidateA, junction)) + candidateFromJunction = candidateB - junction; + else if (PointsMatch(candidateB, junction)) + candidateFromJunction = candidateA - junction; + else + continue; + + var candidateLengthSquared = candidateFromJunction.LengthSquared(); + if (candidateLengthSquared <= SharedOccluderEdgeToleranceSquared) + continue; + + var cross = Vector2.Cross(currentFromJunction, candidateFromJunction); + if (cross * cross > SharedOccluderEdgeToleranceSquared * currentLengthSquared * candidateLengthSquared) + continue; + + if (Vector2.Dot(currentFromJunction, candidateFromJunction) < 0f) + return true; + } + } + } + + return false; + } + + private static bool HasBoundaryVertex( + Vector2 vertex, + IReadOnlySet boundaryVertices) + { + var key = OccluderVertexKey.From(vertex); + for (var dx = -1; dx <= 1; dx++) + { + for (var dy = -1; dy <= 1; dy++) + { + if (boundaryVertices.Contains(new OccluderVertexKey(key.X + dx, key.Y + dy))) + return true; + } + } + + return false; + } + + private static bool EdgeViewedAsCap(Vector4 edge, Vector2 eyePosition) + { + // Corner-handling so we only suppress from the relevant angles as it depends on the eye position. + var a = new Vector2(edge.X, edge.Y); + var b = new Vector2(edge.Z, edge.W); + var edgeDelta = b - a; + var edgeLengthSquared = edgeDelta.LengthSquared(); + if (edgeLengthSquared <= SharedOccluderEdgeToleranceSquared) + return false; + + var eyeFromA = eyePosition - a; + var projected = Vector2.Dot(eyeFromA, edgeDelta) / edgeLengthSquared; + if (projected <= SharedOccluderEdgeTolerance || projected >= 1f - SharedOccluderEdgeTolerance) + return false; + + var signedArea = Vector2.Cross(edgeDelta, eyeFromA); + return signedArea * signedArea > SharedOccluderEdgeToleranceSquared * edgeLengthSquared; + } + + private static bool EdgeFacesPoint(Vector4 edge, Vector2 point) + { + var a = new Vector2(edge.X, edge.Y) - point; + var b = new Vector2(edge.Z, edge.W) - point; + return Vector2.Cross(a, b) > 0f; + } + + private readonly record struct OccluderEdgeKey(long AX, long AY, long BX, long BY) + { + public static OccluderEdgeKey From(Vector4 edge) + { + return From(new Vector2(edge.X, edge.Y), new Vector2(edge.Z, edge.W)); + } + + private static OccluderEdgeKey From(Vector2 a, Vector2 b) + { + var ax = Quantize(a.X); + var ay = Quantize(a.Y); + var bx = Quantize(b.X); + var by = Quantize(b.Y); + + if (ax > bx || ax == bx && ay > by) + return new OccluderEdgeKey(bx, by, ax, ay); + + return new OccluderEdgeKey(ax, ay, bx, by); + } + + private static long Quantize(float value) + { + // We don't want fp inaccuracies to cause issues with edges not being considered together. + return (long) MathF.Round(value / SharedOccluderEdgeTolerance); + } + + } + + private readonly record struct OccluderVertexKey(long X, long Y) + { + public static OccluderVertexKey From(Vector2 vertex) + { + return new OccluderVertexKey(Quantize(vertex.X), Quantize(vertex.Y)); + } + + private static long Quantize(float value) + { + return (long) MathF.Round(value / SharedOccluderEdgeTolerance); + } + } + + private struct BoundaryVertexDirections + { + public Vector2 Incoming; + public Vector2 Outgoing; + public int IncomingCount; + public int OutgoingCount; + } + + private readonly record struct OccluderRenderEntry(int EdgeOffset, int EdgeCount); + + private static float SignedArea(ReadOnlySpan vertices) + { + var area = 0f; + for (var i = 0; i < vertices.Length; i++) + { + var j = (i + 1) % vertices.Length; + area += vertices[i].X * vertices[j].Y; + area -= vertices[i].Y * vertices[j].X; + } + + return area * 0.5f; + } + + private static Vector4 EdgeToVector4(Vector2 a, Vector2 b) + { + return new Vector4(a.X, a.Y, b.X, b.Y); + } + + private void UpdateOcclusionGeometry(MapId map, Box2 expandedBounds, Vector2 eyePosition) { using var _ = _prof.Group("UpdateOcclusionGeometry"); using var _p = DebugGroup(nameof(UpdateOcclusionGeometry)); - // This method generates two sets of occlusion geometry: - // 3D geometry used during depth projection. - // 2D mask geometry used to apply wall bleed. - - // 16 = 4 vertices * 4 directions - var arrayBuffer = ArrayPool.Shared.Rent(_maxOccluders * 4 * 4); - // multiplied by 2 (it's a vector2 of bytes) - var arrayVIBuffer = ArrayPool.Shared.Rent(_maxOccluders * 2 * 4 * 4); - var indexBuffer = ArrayPool.Shared.Rent(_maxOccluders * GetQuadBatchIndexCount() * 4); + var xforms = _entityManager.GetEntityQuery(); + var sharedBoundaryEdges = _occluderSharedBoundaryEdges; + var boundarySegments = _occluderBoundarySegments; + var visibleBoundaryVertices = _occluderVisibleBoundaryVertices; + var convexBoundaryVertices = _occluderConvexBoundaryVertices; + var boundaryVertexDirections = _occluderBoundaryVertexDirections; + var sharedVertexEdges = _occluderSharedVertexEdges; + var uniqueSharedEdges = _occluderUniqueSharedEdges; + var staleSharedVertices = _occluderStaleSharedVertices; + + sharedBoundaryEdges.Clear(); + boundarySegments.Clear(); + visibleBoundaryVertices.Clear(); + _occluderRenderEntries.Clear(); + _occluderRenderVertices.Clear(); + _occluderRenderEdges.Clear(); + _occluderRenderSharedEdges.Clear(); + + BuildFrameOccluderGeometry(map, expandedBounds, xforms); + + BuildSharedVertexEdges( + boundarySegments, + sharedBoundaryEdges, + sharedVertexEdges, + uniqueSharedEdges, + staleSharedVertices); + BuildConvexBoundaryVertices( + boundarySegments, + sharedBoundaryEdges, + boundaryVertexDirections, + convexBoundaryVertices); + + UploadSourceOcclusionDepthGeometry(eyePosition); + } - var arrayMaskBuffer = ArrayPool.Shared.Rent(_maxOccluders * 4); - var indexMaskBuffer = ArrayPool.Shared.Rent(_maxOccluders * GetQuadBatchIndexCount()); + private void BuildFrameOccluderGeometry( + MapId map, + Box2 expandedBounds, + EntityQuery xforms) + { + // This builds source-independent frame geometry: + // - exact occluder edges, later classified into source-specific depth geometry using master's rule; + // - flat 2D mask geometry used to apply wall bleed. + var maxDepthFaces = _maxOccluders * PhysicsConstants.MaxPolygonVertices; + var maxMaskVertices = _maxOccluders * PhysicsConstants.MaxPolygonVertices; + var maxMaskIndices = _maxOccluders * (PhysicsConstants.MaxPolygonVertices - 2) * 3; + var arrayMaskBuffer = ArrayPool.Shared.Rent(maxMaskVertices); + var indexMaskBuffer = ArrayPool.Shared.Rent(maxMaskIndices); - // I love mysterious variable names, it keeps you on your toes. - var ai = 0; - var avi = 0; var ami = 0; - var ii = 0; var imi = 0; - var amiMax = _maxOccluders * 4; + var occluderCount = 0; + var geometryFull = false; - var xforms = _entityManager.GetEntityQuery(); + bool TryWriteMaskPolygon(int vertexOffset, int vertexCount) + { + // Wall bleed uses a flat 2D mask of occupied occluder area. + // Convex occluders are serialized through the physics hull, so a simple fan is sufficient. + if (vertexCount < 3) + return true; - try + var indexCount = (vertexCount - 2) * 3; + if (ami + vertexCount > arrayMaskBuffer.Length || imi + indexCount > indexMaskBuffer.Length) + return false; + + var amiBase = ami; + for (var i = 0; i < vertexCount; i++) + { + arrayMaskBuffer[ami++] = _occluderRenderVertices[vertexOffset + i]; + } + + for (var i = 1; i < vertexCount - 1; i++) + { + indexMaskBuffer[imi++] = (ushort) amiBase; + indexMaskBuffer[imi++] = (ushort) (amiBase + i); + indexMaskBuffer[imi++] = (ushort) (amiBase + i + 1); + } + + return true; + } + + bool TryCacheDepthEdges(int vertexOffset, int vertexCount, byte sharedEdgeMask) { - foreach (var (uid, comp) in _occluderSystem.GetIntersectingTrees(map, expandedBounds)) + if (vertexCount < 3) + return true; + + var remainingFaces = maxDepthFaces - _occluderRenderEdges.Count; + if (remainingFaces < vertexCount) + return false; + + var renderVertices = CollectionsMarshal.AsSpan(_occluderRenderVertices).Slice(vertexOffset, vertexCount); + var edgeOffset = _occluderRenderEdges.Count; + for (var i = 0; i < vertexCount; i++) { - if (ami >= amiMax) - break; + var edge = EdgeToVector4(renderVertices[i], renderVertices[(i + 1) % vertexCount]); + _occluderRenderEdges.Add(edge); + _occluderRenderSharedEdges.Add((sharedEdgeMask & 1 << i) != 0); + } + + _occluderRenderEntries.Add(new OccluderRenderEntry(edgeOffset, vertexCount)); + return true; + } - var treeBounds = _transformSystem.GetInvWorldMatrix(uid).TransformBox(expandedBounds); + try + { + // Include one tile around the rendered area so shared corners on the edge of the viewport have + // complete topology. Visible geometry is filtered back to expandedBounds below. + var boundaryBounds = expandedBounds.Enlarged(SharedOccluderNeighbourQueryPadding); + foreach (var (uid, comp) in _occluderSystem.GetIntersectingTrees(map, boundaryBounds)) + { + var treeBounds = _transformSystem.GetInvWorldMatrix(uid, xforms).TransformBox(boundaryBounds); comp.Tree.QueryAabb((in ComponentTreeEntry entry) => { var (occluder, transform) = entry; if (!occluder.Enabled) - { return true; - } - if (ami >= amiMax) - return false; + var polygon = occluder.Polygon; + if (polygon.Length < 3) + return true; var worldTransform = _transformSystem.GetWorldMatrix(transform, xforms); - var box = occluder.BoundingBox; - - var tl = Vector2.Transform(box.TopLeft, worldTransform); - var tr = Vector2.Transform(box.TopRight, worldTransform); - var br = Vector2.Transform(box.BottomRight, worldTransform); - var bl = tl + br - tr; - - // Faces. - var faceN = new Vector4(tl.X, tl.Y, tr.X, tr.Y); - var faceE = new Vector4(tr.X, tr.Y, br.X, br.Y); - var faceS = new Vector4(br.X, br.Y, bl.X, bl.Y); - var faceW = new Vector4(bl.X, bl.Y, tl.X, tl.Y); - - // - // Buckle up. - // For the front-face culled final FOV to work, we obviously cannot have faces inside a series - // of walls that are perpendicular to you. - // This next code does that by only writing render indices for faces that should be rendered. - // - - // - // Keep in mind, a face only blocks light from *leaving* from the back. - // It does not block light entering. - // - // So first rule: a face always exists if there's no neighboring occluder in that direction. - // Can't have holes after all. - // Second rule: otherwise, if either vertex of the face is "visible" from the camera, - // we don't draw the face. - // This visibility check is significantly more simple and resourceful than you might think. - // A corner becomes "occluded" if it's not visible from either cardinal direction it's on. - // So a the top right corner is occluded if there's something blocking visibility - // on the top AND right. - // This "occluded in direction" check has two parts: whether this is a neighboring occluder (duh) - // And whether the is in that direction of the corner. - // (so a corner on the back of a wall is occluded because the camera is position on the other side). - // - // You'll notice that in some cases like corner walls, ALL corners are marked "occluded". - // This is fine! The occlusion only blocks incoming light, - // and the neighboring walls DO treat those corners as visible. - // Yes, you cannot share the handling of overlapping corners of two aligned neighboring occluders. - // They still have different potential behavior, keeps the code simple(ish). - // - - // Calculate delta positions from camera. - var dTl = Vector2.Transform(tl, eyeTransform); - var dTr = Vector2.Transform(tr, eyeTransform); - var dBl = Vector2.Transform(bl, eyeTransform); - var dBr = dBl + dTr - dTl; - - // Get which neighbors are occluding. - var no = (occluder.Occluding & OccluderDir.North) != 0; - var so = (occluder.Occluding & OccluderDir.South) != 0; - var eo = (occluder.Occluding & OccluderDir.East) != 0; - var wo = (occluder.Occluding & OccluderDir.West) != 0; - - // Do visibility tests for occluders (described above). - static bool CheckFaceEyeVis(Vector2 a, Vector2 b) - { - // determine which side of the plane the face is on - // the plane is at the origin of this coordinate system, which is also the eye - // the normal of the plane is that of the face - // therefore, if the dot <= 0, the face is facing the camera - // I don't like this, but rotated occluders started happening - - // var normal = (b - a).Rotated90DegreesAnticlockwiseWorld; - // Vector2.Dot(normal, a) <= 0; - // equivalent to: - return a.X * b.Y > a.Y * b.X; - } - var nV = ((!no) && CheckFaceEyeVis(dTl, dTr)); - var sV = ((!so) && CheckFaceEyeVis(dBr, dBl)); - var eV = ((!eo) && CheckFaceEyeVis(dTr, dBr)); - var wV = ((!wo) && CheckFaceEyeVis(dBl, dTl)); - var tlV = nV || wV; - var trV = nV || eV; - var blV = sV || wV; - var brV = sV || eV; - - // Handle faces, rules described above. - // Note that "from above" it should be clockwise. - // Further handling is in the shadow depth vertex shader. - // (I have broken this so many times. - 20kdc) - - void WriteFaceOfBuffer(Vector4 vec) + // Build source-dependent corner topology from the cached client-side shared edge mask. + AddOccluderBoundaryEdges( + polygon, + worldTransform, + occluder.OccludingEdges, + _occluderSharedBoundaryEdges, + _occluderBoundarySegments); + + if (geometryFull + || !worldTransform.TransformBox(occluder.LocalBounds).Intersects(expandedBounds)) { - var aiBase = ai; - for (byte vi = 0; vi < 4; vi++) - { - arrayBuffer[ai++] = vec; - // generates the sequence: - // DddD - // HHhh - // deflection - arrayVIBuffer[avi++] = (byte)((((vi + 1) & 2) != 0) ? 0 : 255); - // height - arrayVIBuffer[avi++] = (byte)(((vi & 2) != 0) ? 0 : 255); - } - - QuadBatchIndexWrite(indexBuffer, ref ii, (ushort)aiBase); + return true; } - // North face (TL/TR) - if (!no || !tlV && !trV) + if (_occluderRenderEntries.Count >= _maxOccluders + || _occluderRenderVertices.Count + polygon.Length > maxMaskVertices + || imi + (polygon.Length - 2) * 3 > indexMaskBuffer.Length) { - WriteFaceOfBuffer(faceN); + geometryFull = true; + return true; } - // East face (TR/BR) - if (!eo || !brV && !trV) + var vertexOffset = _occluderRenderVertices.Count; + var clockwise = SignedArea(polygon) < 0f; + for (var i = 0; i < polygon.Length; i++) { - WriteFaceOfBuffer(faceE); + var sourceIndex = clockwise ? i : polygon.Length - 1 - i; + var worldVertex = Vector2.Transform(polygon[sourceIndex], worldTransform); + _occluderRenderVertices.Add(worldVertex); } - // South face (BR/BL) - if (!so || !brV && !blV) + if (!TryWriteMaskPolygon(vertexOffset, polygon.Length)) { - WriteFaceOfBuffer(faceS); + geometryFull = true; + return true; } - // West face (BL/TL) - if (!wo || !blV && !tlV) + occluderCount += 1; + + if (!TryCacheDepthEdges(vertexOffset, polygon.Length, occluder.OccludingEdges)) { - WriteFaceOfBuffer(faceW); + geometryFull = true; + return true; } - // Generate mask geometry. - arrayMaskBuffer[ami + 0] = new Vector2(tl.X, tl.Y); - arrayMaskBuffer[ami + 1] = new Vector2(tr.X, tr.Y); - arrayMaskBuffer[ami + 2] = new Vector2(br.X, br.Y); - arrayMaskBuffer[ami + 3] = new Vector2(bl.X, bl.Y); + return true; + }, treeBounds); + } - // Generate mask indices. - QuadBatchIndexWrite(indexMaskBuffer, ref imi, (ushort)ami); + _occlusionMaskDataLength = imi; - ami += 4; + BindVertexArray(_occlusionMaskVao.Handle); + CheckGlError(); - return true; - }, treeBounds); + _occlusionMaskVbo.Reallocate(arrayMaskBuffer.AsSpan(0, ami)); + _occlusionMaskEbo.Reallocate(indexMaskBuffer.AsSpan(0, imi)); + } + finally + { + ArrayPool.Shared.Return(arrayMaskBuffer); + ArrayPool.Shared.Return(indexMaskBuffer); + } + + _debugStats.Occluders += occluderCount; + } + + private void UploadSourceOcclusionDepthGeometry(Vector2 sourcePosition) + { + var maxDepthFaces = _occluderRenderEdges.Count; + var maxDepthVertices = maxDepthFaces * 4; + var maxDepthIndices = maxDepthFaces * GetQuadBatchIndexCount(); + + var arrayBuffer = ArrayPool.Shared.Rent(maxDepthVertices); + // multiplied by 2 (it's a vector2 of bytes) + var arrayVIBuffer = ArrayPool.Shared.Rent(maxDepthVertices * 2); + var indexBuffer = ArrayPool.Shared.Rent(maxDepthIndices); + + var ai = 0; + var avi = 0; + var ii = 0; + var geometryFull = false; + + var sharedBoundaryEdges = _occluderSharedBoundaryEdges; + var boundarySegments = _occluderBoundarySegments; + var visibleBoundaryVertices = _occluderVisibleBoundaryVertices; + var convexBoundaryVertices = _occluderConvexBoundaryVertices; + var sharedVertexEdges = _occluderSharedVertexEdges; + + BuildVisibleBoundaryVertices( + boundarySegments, + sharedBoundaryEdges, + sourcePosition, + visibleBoundaryVertices); + + bool TryWriteFaceOfBuffer(Vector4 vec) + { + if (ai + 4 > arrayBuffer.Length || ii + GetQuadBatchIndexCount() > indexBuffer.Length) + return false; + + var aiBase = ai; + for (byte vi = 0; vi < 4; vi++) + { + arrayBuffer[ai++] = vec; + // generates the sequence: + // DddD + // HHhh + // deflection + arrayVIBuffer[avi++] = (byte)((((vi + 1) & 2) != 0) ? 0 : 255); + // height + arrayVIBuffer[avi++] = (byte)(((vi & 2) != 0) ? 0 : 255); + } + + QuadBatchIndexWrite(indexBuffer, ref ii, (ushort)aiBase); + return true; + } + + try + { + var renderEdges = CollectionsMarshal.AsSpan(_occluderRenderEdges); + var renderSharedEdges = CollectionsMarshal.AsSpan(_occluderRenderSharedEdges); + foreach (var entry in _occluderRenderEntries) + { + if (geometryFull || ai >= maxDepthVertices) + break; + + var activeEdges = renderEdges.Slice(entry.EdgeOffset, entry.EdgeCount); + var activeSharedEdges = renderSharedEdges.Slice(entry.EdgeOffset, entry.EdgeCount); + for (var i = 0; i < activeEdges.Length; i++) + { + var edge = activeEdges[i]; + /* + * Okay so essentially for occlusion you draw from edges in the viewport and project it out to the edge of the screen. + * In our case there are some exceptions where we don't in fact want to do that because it doesn't look good. + * e.g. connecting walls, but only sometimes like if not a corner, or only want to do that at specific angles. + * Hence you get the hell that is ShouldSuppressSharedOccluderEdge. + * + * A lot of this was implicitly handled before but now that we allow entirely arbitrary occluders + * this needs to be handled explicitly. + * + * If you know trig you'll be right mate. + */ + + var suppressSharedEdge = ShouldSuppressSharedOccluderEdge( + i, + activeEdges, + activeSharedEdges, + visibleBoundaryVertices, + convexBoundaryVertices, + sharedVertexEdges, + sourcePosition); + + if (suppressSharedEdge) + continue; + + if (!TryWriteFaceOfBuffer(edge)) + { + geometryFull = true; + break; + } + } } _occlusionDataLength = ii; - _occlusionMaskDataLength = imi; - // Upload geometry to OpenGL. BindVertexArray(_occlusionVao.Handle); CheckGlError(); _occlusionVbo.Reallocate(arrayBuffer.AsSpan(0, ai)); _occlusionVIVbo.Reallocate(arrayVIBuffer.AsSpan(0, avi)); _occlusionEbo.Reallocate(indexBuffer.AsSpan(0, ii)); - - BindVertexArray(_occlusionMaskVao.Handle); - CheckGlError(); - - _occlusionMaskVbo.Reallocate(arrayMaskBuffer.AsSpan(0, ami)); - _occlusionMaskEbo.Reallocate(indexMaskBuffer.AsSpan(0, imi)); } finally { ArrayPool.Shared.Return(arrayBuffer); ArrayPool.Shared.Return(arrayVIBuffer); ArrayPool.Shared.Return(indexBuffer); - ArrayPool.Shared.Return(arrayMaskBuffer); - ArrayPool.Shared.Return(indexMaskBuffer); } - - _debugStats.Occluders += ami / 4; } private void RegenLightRts(Viewport viewport) @@ -1263,7 +1987,7 @@ private void MaxOccludersChanged(int value) private void MaxLightsChanged(int value) { _maxLights = value; - _lightsToRenderList = new (PointLightComponent, Vector2, float , Angle)[value]; + _lightsToRenderList = new LightRenderData[value]; DebugTools.Assert(_maxLights >= _maxShadowcastingLights); } } diff --git a/Robust.Client/Graphics/Clyde/Clyde.RenderHandle.cs b/Robust.Client/Graphics/Clyde/Clyde.RenderHandle.cs index f392de2ac..c7aded3db 100644 --- a/Robust.Client/Graphics/Clyde/Clyde.RenderHandle.cs +++ b/Robust.Client/Graphics/Clyde/Clyde.RenderHandle.cs @@ -68,6 +68,12 @@ public void SetProjView(in Matrix3x2 proj, in Matrix3x2 view) public void DrawTextureScreen(Texture texture, Vector2 bl, Vector2 br, Vector2 tl, Vector2 tr, in Color modulate, in UIBox2? subRegion) { + if (subRegion == null && texture is AtlasTexture atlas) + { + DrawTextureScreen(atlas, bl, br, tl, tr, in modulate); + return; + } + var clydeTexture = ExtractTexture(texture, in subRegion, out var csr); var (w, h) = clydeTexture.Size; @@ -76,6 +82,14 @@ public void DrawTextureScreen(Texture texture, Vector2 bl, Vector2 br, Vector2 t _clyde.DrawTexture(clydeTexture.TextureId, bl, br, tl, tr, in modulate, in sr); } + public void DrawTextureScreen(AtlasTexture texture, Vector2 bl, Vector2 br, Vector2 tl, Vector2 tr, + in Color modulate) + { + var texCoords = texture.NormalizedSubRegion; + _clyde.DrawTexture(texture.ClydeTexture!.TextureId, bl, br, tl, tr, in modulate, + in texCoords); + } + /// /// Draws a sprite to the world. The coordinate system is right handed. /// Make sure to set @@ -91,6 +105,12 @@ public void DrawTextureScreen(Texture texture, Vector2 bl, Vector2 br, Vector2 t public void DrawTextureWorld(Texture texture, Vector2 bl, Vector2 br, Vector2 tl, Vector2 tr, Color modulate, in UIBox2? subRegion) { + if (subRegion == null && texture is AtlasTexture atlas) + { + DrawTextureWorld(atlas, bl, br, tl, tr, modulate); + return; + } + var clydeTexture = ExtractTexture(texture, in subRegion, out var csr); var sr = WorldTextureBoundsToUV(clydeTexture, csr); @@ -98,20 +118,55 @@ public void DrawTextureWorld(Texture texture, Vector2 bl, Vector2 br, Vector2 tl _clyde.DrawTexture(clydeTexture.TextureId, bl, br, tl, tr, in modulate, in sr); } + public void DrawTextureWorld(AtlasTexture texture, Vector2 bl, Vector2 br, Vector2 tl, Vector2 tr, + Color modulate) + { + var texCoords = texture.NormalizedSubRegion; + _clyde.DrawTexture(texture.ClydeTexture!.TextureId, bl, br, tl, tr, in modulate, + in texCoords); + } + public void DrawTextureWorldBatch(Texture texture, ReadOnlySpan rects, Color modulate) { + if (texture is AtlasTexture atlas) + { + DrawTextureWorldBatch(atlas, rects, modulate); + return; + } + var clydeTexture = ExtractTexture(texture, null, out var csr); var sr = WorldTextureBoundsToUV(clydeTexture, csr); _clyde.DrawTextureBatch(clydeTexture.TextureId, rects, modulate, in sr); } + public void DrawTextureWorldBatch(AtlasTexture texture, ReadOnlySpan rects, + Color modulate) + { + var texCoords = texture.NormalizedSubRegion; + _clyde.DrawTextureBatch(texture.ClydeTexture!.TextureId, rects, modulate, + in texCoords); + } + public void DrawTextureWorldBatchUnmodulated(Texture texture, ReadOnlySpan rects) { + if (texture is AtlasTexture atlas) + { + DrawTextureWorldBatchUnmodulated(atlas, rects); + return; + } + var clydeTexture = ExtractTexture(texture, null, out var csr); var sr = WorldTextureBoundsToUV(clydeTexture, csr); _clyde.DrawTextureBatchUnmodulated(clydeTexture.TextureId, rects, in sr); } + public void DrawTextureWorldBatchUnmodulated(AtlasTexture texture, ReadOnlySpan rects) + { + var texCoords = texture.NormalizedSubRegion; + _clyde.DrawTextureBatchUnmodulated(texture.ClydeTexture!.TextureId, rects, + in texCoords); + } + public void DrawRectWorldBatch(ReadOnlySpan rects, Color modulate) { _clyde.DrawRectBatch(_whiteClydeTexture.TextureId, rects, modulate, in _whiteUv); @@ -135,7 +190,6 @@ internal static ClydeTexture ExtractTexture(Texture texture, in UIBox2? subRegio { if (texture is AtlasTexture atlas) { - texture = atlas.SourceTexture; if (subRegion.HasValue) { var offset = atlas.SubRegion.TopLeft; @@ -147,14 +201,12 @@ internal static ClydeTexture ExtractTexture(Texture texture, in UIBox2? subRegio { sr = atlas.SubRegion; } - } - else - { - sr = subRegion ?? new UIBox2(0, 0, texture.Width, texture.Height); + + return atlas.ClydeTexture!; } - var clydeTexture = (ClydeTexture) texture; - return clydeTexture; + sr = subRegion ?? new UIBox2(0, 0, texture.Width, texture.Height); + return (ClydeTexture) texture; } public void RenderInRenderTarget(IRenderTarget target, Action a, Color? clearColor) @@ -549,7 +601,12 @@ public override void DrawTextureRectRegion(Texture texture, UIBox2 rect, UIBox2? public override void DrawTexture(Texture texture, Vector2 position, Color? modulate = null) { - base.DrawTexture(texture, position, modulate); + CheckDisposed(); + + var color = (modulate ?? Color.White) * Modulate; + var rect = UIBox2.FromDimensions(position, texture.Size); + _renderHandle.DrawTextureScreen(texture, rect.BottomLeft, rect.BottomRight, + rect.TopLeft, rect.TopRight, color, null); } /// @@ -718,6 +775,15 @@ public override void DrawTextureRectRegion(Texture texture, Box2 quad, quad.TopLeft, quad.TopRight, color, in subRegion); } + public override void DrawTextureRect(AtlasTexture texture, Box2 quad, Color? modulate = null) + { + CheckDisposed(); + + var color = (modulate ?? Color.White) * Modulate; + _renderHandle.DrawTextureWorld(texture, quad.BottomLeft, quad.BottomRight, + quad.TopLeft, quad.TopRight, color); + } + /// /// Draws a sprite to the world. The coordinate system is right handed. /// Make sure to set diff --git a/Robust.Client/Graphics/Clyde/Clyde.Systems.cs b/Robust.Client/Graphics/Clyde/Clyde.Systems.cs index 2d6449f7f..316d43dc2 100644 --- a/Robust.Client/Graphics/Clyde/Clyde.Systems.cs +++ b/Robust.Client/Graphics/Clyde/Clyde.Systems.cs @@ -1,5 +1,6 @@ using Robust.Client.ComponentTrees; using Robust.Client.GameObjects; +using Robust.Shared.Physics.Systems; namespace Robust.Client.Graphics.Clyde; @@ -13,6 +14,7 @@ internal sealed partial class Clyde private SpriteSystem _spriteSystem = default!; private SpriteTreeSystem _spriteTreeSystem = default!; private ClientOccluderSystem _occluderSystem = default!; + private FixtureSystem _fixtureSystem = default!; private void InitSystems() { @@ -28,6 +30,7 @@ private void EntityManagerOnAfterStartup() _spriteSystem = _entitySystemManager.GetEntitySystem(); _spriteTreeSystem = _entitySystemManager.GetEntitySystem(); _occluderSystem = _entitySystemManager.GetEntitySystem(); + _fixtureSystem = _entitySystemManager.GetEntitySystem(); } private void EntityManagerOnAfterShutdown() @@ -38,5 +41,6 @@ private void EntityManagerOnAfterShutdown() _spriteSystem = null!; _spriteTreeSystem = null!; _occluderSystem = null!; + _fixtureSystem = null!; } } diff --git a/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs b/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs index c5b75daa2..173cd5a8a 100644 --- a/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs +++ b/Robust.Client/Graphics/Clyde/Windowing/Sdl3.Key.cs @@ -205,6 +205,16 @@ static Sdl3WindowingImpl() MapKey(SC.SDL_SCANCODE_PAUSE, Key.Pause); MapKey(SC.SDL_SCANCODE_CAPSLOCK, Key.CapsLock); MapKey(SC.SDL_SCANCODE_SCROLLLOCK, Key.ScrollLock); + MapKey(SC.SDL_SCANCODE_HELP, Key.Help); + MapKey(SC.SDL_SCANCODE_CANCEL, Key.Stop); + MapKey(SC.SDL_SCANCODE_AGAIN, Key.Again); + MapKey(SC.SDL_SCANCODE_AC_PROPERTIES, Key.Props); + MapKey(SC.SDL_SCANCODE_UNDO, Key.Undo); + MapKey(SC.SDL_SCANCODE_CUT, Key.Cut); + MapKey(SC.SDL_SCANCODE_COPY, Key.Copy); + MapKey(SC.SDL_SCANCODE_AC_OPEN, Key.Open); + MapKey(SC.SDL_SCANCODE_PASTE, Key.Paste); + MapKey(SC.SDL_SCANCODE_FIND, Key.Find); var keyMapReverse = new Dictionary(); diff --git a/Robust.Client/Graphics/Drawing/DrawingHandleScreen.cs b/Robust.Client/Graphics/Drawing/DrawingHandleScreen.cs index 40f727fb9..1f5c19301 100644 --- a/Robust.Client/Graphics/Drawing/DrawingHandleScreen.cs +++ b/Robust.Client/Graphics/Drawing/DrawingHandleScreen.cs @@ -160,6 +160,24 @@ public Vector2 DrawString(Font font, Vector2 pos, ReadOnlySpan str, float var baseLine = new Vector2(pos.X, font.GetAscent(scale) + pos.Y); var lineHeight = font.GetLineHeight(scale); + if (outline is { } outlineSettings) + { + foreach (var rune in str.EnumerateRunes()) + { + if (rune == new Rune('\n')) + { + baseLine.X = pos.X; + baseLine.Y += lineHeight; + continue; + } + + var advance = font.DrawCharOutline(this, rune, baseLine, scale, outlineSettings); + baseLine.X += advance; + } + + baseLine = new Vector2(pos.X, font.GetAscent(scale) + pos.Y); + } + foreach (var rune in str.EnumerateRunes()) { if (rune == new Rune('\n')) @@ -170,9 +188,9 @@ public Vector2 DrawString(Font font, Vector2 pos, ReadOnlySpan str, float continue; } - var advance = font.DrawChar(this, rune, baseLine, scale, color, outline); + var advance = font.DrawChar(this, rune, baseLine, scale, color); advanceTotal.X += advance; - baseLine += new Vector2(advance, 0); + baseLine.X += advance; } return advanceTotal; diff --git a/Robust.Client/Graphics/Drawing/DrawingHandleWorld.cs b/Robust.Client/Graphics/Drawing/DrawingHandleWorld.cs index 5ab73d358..14fcbc182 100644 --- a/Robust.Client/Graphics/Drawing/DrawingHandleWorld.cs +++ b/Robust.Client/Graphics/Drawing/DrawingHandleWorld.cs @@ -155,6 +155,16 @@ public void DrawTextureRect(Texture texture, Box2 quad, Color? modulate = null) DrawTextureRectRegion(texture, quad, modulate); } + /// + /// Draws an atlas texture without an additional subregion. + /// + public virtual void DrawTextureRect(AtlasTexture texture, Box2 quad, Color? modulate = null) + { + CheckDisposed(); + + DrawTextureRectRegion(texture, quad, modulate); + } + /// /// Draws a full texture sprite to the world. The coordinate system is right handed. /// Make sure to set diff --git a/Robust.Client/Graphics/Font.cs b/Robust.Client/Graphics/Font.cs index d4034fa0f..896f088d5 100644 --- a/Robust.Client/Graphics/Font.cs +++ b/Robust.Client/Graphics/Font.cs @@ -82,15 +82,14 @@ public abstract float DrawChar( Color color, bool fallback=true); /// - /// Draws a character with an optional outline. + /// Draws only the outline of a character and returns its advance. /// - /// - public virtual float DrawChar( + /// + /// Rendering outlines separately from glyph fills allows text renderers to batch both passes by texture. + /// + public abstract float DrawCharOutline( DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, - Color color, TextOutline? outline, bool fallback=true) - { - return DrawChar(handle, rune, baseline, scale, color, fallback); - } + TextOutline outline, bool fallback=true); /// /// Gets metrics describing the dimensions and positioning of a single glyph in the font. @@ -152,40 +151,50 @@ internal VectorFont(IFontInstanceHandle handle, int size) public override int GetLineHeight(float scale) => Handle.GetLineHeight(scale); public override float DrawChar(DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, Color color, bool fallback=true) - => DrawChar(handle, rune, baseline, scale, color, null, fallback); - - public override float DrawChar(DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, Color color, TextOutline? outline, bool fallback=true) { - var metrics = Handle.GetCharMetrics(rune, scale); - if (!metrics.HasValue) - { - if (fallback && !Rune.IsWhiteSpace(rune)) - { - rune = new Rune('�'); - metrics = Handle.GetCharMetrics(rune, scale); - if (!metrics.HasValue) - return 0; - } - else - return 0; - } + if (!TryGetGlyph(rune, scale, fallback, 0, out var metrics, out var texture, out _)) + return 0; - var texture = Handle.GetCharTexture(rune, scale); if (texture == null) - { - return metrics.Value.Advance; - } + return metrics.Advance; + + var glyphPosition = baseline + new Vector2(metrics.BearingX, -metrics.BearingY); + DrawGlyph(handle, texture, glyphPosition, color); + return metrics.Advance; + } - var glyphPosition = baseline + new Vector2(metrics.Value.BearingX, -metrics.Value.BearingY); - if (outline is { Thickness: > 0 } settings && - Handle.GetOutlinedChar(rune, scale, settings.Thickness) is { } outlinedGlyph) + public override float DrawCharOutline( + DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, + TextOutline outline, bool fallback=true) + { + if (!TryGetGlyph(rune, scale, fallback, outline.Thickness, out var metrics, out var texture, out var outlinedGlyph)) + return 0; + + if (outline.Thickness > 0 && texture != null && outlinedGlyph is { } glyph) { - var outlinePosition = baseline + new Vector2(outlinedGlyph.Left, -outlinedGlyph.Top); - DrawGlyph(handle, outlinedGlyph.Texture, outlinePosition, settings.Color); + var outlinePosition = baseline + new Vector2(glyph.Left, -glyph.Top); + DrawGlyph(handle, glyph.Texture, outlinePosition, outline.Color); } - DrawGlyph(handle, texture, glyphPosition, color); - return metrics.Value.Advance; + return metrics.Advance; + } + + private bool TryGetGlyph( + Rune rune, + float scale, + bool fallback, + float outlineThickness, + out CharMetrics metrics, + out Texture? texture, + out OutlinedGlyph? outlinedGlyph) + { + if (Handle.TryGetGlyph(rune, scale, outlineThickness, out metrics, out texture, out outlinedGlyph)) + return true; + + if (!fallback || Rune.IsWhiteSpace(rune)) + return false; + + return Handle.TryGetGlyph(new Rune('�'), scale, outlineThickness, out metrics, out texture, out outlinedGlyph); } private static void DrawGlyph(DrawingHandleBase handle, Texture texture, Vector2 position, Color color) @@ -230,19 +239,33 @@ public StackedFont(params Font[] args) // DrawChar just proxies to the stack, or invokes _main's fallback. public override float DrawChar(DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, Color color, bool fallback=true) - => DrawChar(handle, rune, baseline, scale, color, null, fallback); + { + foreach (var f in Stack) + { + var w = f.DrawChar(handle, rune, baseline, scale, color, fallback: false); + if (w != 0f) + return w; + } - public override float DrawChar(DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, Color color, TextOutline? outline, bool fallback=true) + if (fallback) + return _main.DrawChar(handle, rune, baseline, scale, color, fallback: true); + + return 0f; + } + + public override float DrawCharOutline( + DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, + TextOutline outline, bool fallback=true) { foreach (var f in Stack) { - var w = f.DrawChar(handle, rune, baseline, scale, color, outline, fallback: false); + var w = f.DrawCharOutline(handle, rune, baseline, scale, outline, fallback: false); if (w != 0f) return w; } if (fallback) - return _main.DrawChar(handle, rune, baseline, scale, color, outline, fallback: true); + return _main.DrawCharOutline(handle, rune, baseline, scale, outline, fallback: true); return 0f; } @@ -276,6 +299,14 @@ public override float DrawChar(DrawingHandleBase handle, Rune rune, Vector2 base return 0; } + public override float DrawCharOutline( + DrawingHandleBase handle, Rune rune, Vector2 baseline, float scale, + TextOutline outline, bool fallback=true) + { + // Nada, it's a dummy after all. + return 0; + } + public override CharMetrics? GetCharMetrics(Rune rune, float scale, bool fallback=true) { // Nada, it's a dummy after all. diff --git a/Robust.Client/Graphics/FontManager.cs b/Robust.Client/Graphics/FontManager.cs index c45e883a0..c8d6bed1c 100644 --- a/Robust.Client/Graphics/FontManager.cs +++ b/Robust.Client/Graphics/FontManager.cs @@ -18,6 +18,8 @@ internal sealed class FontManager : IFontManagerInternal { private const int SheetWidth = 256; private const int SheetHeight = 256; + // FreeType's stroker radius is expressed in 26.6 fixed-point pixel units. + private const float FreeType26Dot6Scale = 64f; private readonly IClyde _clyde; private readonly ISawmill _sawmill; @@ -339,16 +341,48 @@ public void ClearSizeData() _scaledData.Clear(); } - public Texture? GetCharTexture(Rune codePoint, float scale) + public bool TryGetGlyph( + Rune codePoint, + float scale, + float outlineThickness, + out CharMetrics metrics, + out Texture? texture, + out OutlinedGlyph? outlinedGlyph) { var glyph = GetGlyph(codePoint); if (glyph == 0) - return null; + { + metrics = default; + texture = null; + outlinedGlyph = null; + return false; + } var scaled = GetScaleDatum(scale); var glyphInfo = _fontManager.EnsureGlyphCached(this, scaled, scale, glyph); + metrics = glyphInfo.Metrics; + texture = glyphInfo.Texture; + outlinedGlyph = null; + + // An outline is never rendered without the normal glyph, so avoid generating or + // looking up outline data when the glyph has no bitmap. + if (texture == null || outlineThickness <= 0) + return true; + + var radius = (int) MathF.Round(outlineThickness * scale * FreeType26Dot6Scale); + if (radius <= 0) + return true; + + var outlinedInfo = _fontManager.EnsureOutlinedGlyphCached(this, scaled, scale, glyph, radius); + if (outlinedInfo.Texture != null) + outlinedGlyph = new OutlinedGlyph(outlinedInfo.Texture, outlinedInfo.Left, outlinedInfo.Top); + + return true; + } - return glyphInfo.Texture; + public Texture? GetCharTexture(Rune codePoint, float scale) + { + return TryGetGlyph(codePoint, scale, 0, out _, out var texture, out _) ? texture : null; } public OutlinedGlyph? GetOutlinedChar(Rune codePoint, float scale, float thickness) @@ -360,7 +394,7 @@ public void ClearSizeData() if (glyph == 0) return null; - var radius = (int) MathF.Round(thickness * scale * 64f); + var radius = (int) MathF.Round(thickness * scale * FreeType26Dot6Scale); if (radius <= 0) return null; @@ -374,16 +408,7 @@ public void ClearSizeData() public CharMetrics? GetCharMetrics(Rune codePoint, float scale) { - var glyph = GetGlyph(codePoint); - if (glyph == 0) - { - return null; - } - - var scaled = GetScaleDatum(scale); - var info = _fontManager.EnsureGlyphCached(this, scaled, scale, glyph); - - return info.Metrics; + return TryGetGlyph(codePoint, scale, 0, out var metrics, out _, out _) ? metrics : null; } public int GetAscent(float scale) diff --git a/Robust.Client/Graphics/IFontManager.cs b/Robust.Client/Graphics/IFontManager.cs index 79db29cbd..edc687fc0 100644 --- a/Robust.Client/Graphics/IFontManager.cs +++ b/Robust.Client/Graphics/IFontManager.cs @@ -33,6 +33,17 @@ internal interface IFontFaceHandle internal interface IFontInstanceHandle { + /// + /// Fetches all data needed to draw a glyph. + /// + bool TryGetGlyph( + Rune codePoint, + float scale, + float outlineThickness, + out CharMetrics metrics, + out Texture? texture, + out OutlinedGlyph? outlinedGlyph); + Texture? GetCharTexture(Rune codePoint, float scale); Texture? GetCharTexture(char chr, float scale) => GetCharTexture((Rune) chr, scale); OutlinedGlyph? GetOutlinedChar(Rune codePoint, float scale, float thickness); diff --git a/Robust.Client/Graphics/RSI/RSI.State.cs b/Robust.Client/Graphics/RSI/RSI.State.cs index 0bd9d2fb7..87b3f7db3 100644 --- a/Robust.Client/Graphics/RSI/RSI.State.cs +++ b/Robust.Client/Graphics/RSI/RSI.State.cs @@ -25,9 +25,10 @@ public sealed class State : IRsiStateLike public readonly float[] Delays; // 2D array for the texture to use for each animation frame at each direction. - public readonly Texture[][] Icons; + public readonly AtlasTexture[][] Icons; - internal State(Vector2i size, RSI rsi, StateId stateId, RsiDirectionType rsiDirection, float[] delays, Texture[][] icons) + internal State(Vector2i size, RSI rsi, StateId stateId, RsiDirectionType rsiDirection, float[] delays, + AtlasTexture[][] icons) { DebugTools.Assert(size.X > 0); DebugTools.Assert(size.Y > 0); @@ -97,6 +98,11 @@ public Texture GetFrame(RsiDirection rsiDirection, int frame) return Icons[(int) rsiDirection][frame]; } + internal AtlasTexture GetAtlasFrame(RsiDirection rsiDirection, int frame) + { + return Icons[(int) rsiDirection][frame]; + } + public Texture[] GetFrames(RsiDirection rsiDirection) { return Icons[(int) rsiDirection]; diff --git a/Robust.Client/Input/IInputManager.cs b/Robust.Client/Input/IInputManager.cs index 29c49d516..191f90864 100644 --- a/Robust.Client/Input/IInputManager.cs +++ b/Robust.Client/Input/IInputManager.cs @@ -41,6 +41,11 @@ public interface IInputManager void KeyDown(KeyEventArgs e); void KeyUp(KeyEventArgs e); + /// + /// Releases all currently held non-toggle key bindings and clears tracked key state. + /// + void ReleaseAllKeys(); + IKeyBinding RegisterBinding(in KeyBindingRegistration reg, bool markModified=true, bool invalid=false); void RemoveBinding(IKeyBinding binding, bool markModified=true); diff --git a/Robust.Client/Input/InputDevices.cs b/Robust.Client/Input/InputDevices.cs index fb41f35dd..323e6106c 100644 --- a/Robust.Client/Input/InputDevices.cs +++ b/Robust.Client/Input/InputDevices.cs @@ -174,7 +174,17 @@ public enum Key : byte Pause, World1, CapsLock, - ScrollLock + ScrollLock, + Help, + Stop, + Again, + Props, + Undo, + Cut, + Copy, + Open, + Paste, + Find, } public static bool IsMouseKey(this Key key) diff --git a/Robust.Client/Input/InputManager.cs b/Robust.Client/Input/InputManager.cs index 973048801..e689fb1c4 100644 --- a/Robust.Client/Input/InputManager.cs +++ b/Robust.Client/Input/InputManager.cs @@ -349,6 +349,26 @@ public void KeyUp(KeyEventArgs args) RaiseRawKeyInput(args, rawInput, RawKeyAction.Up); } + /// + public void ReleaseAllKeys() + { + var hadCanFocus = false; + + foreach (var binding in _bindings.ToArray()) + { + if (binding.State == BoundKeyState.Up || binding.BindingType == KeyBindingType.Toggle) + continue; + + hadCanFocus |= binding.CanFocus; + UpBind(binding); + } + + Array.Clear(_keysPressed); + + if (hadCanFocus) + _uiMgr.HandleCanFocusUp(); + } + private bool DownBind(KeyBinding binding, bool uiOnly, bool isRepeat) { if (binding.State == BoundKeyState.Down) diff --git a/Robust.Client/Physics/GridFixtureSystem.cs b/Robust.Client/Physics/GridFixtureSystem.cs index ac42b0773..40da19944 100644 --- a/Robust.Client/Physics/GridFixtureSystem.cs +++ b/Robust.Client/Physics/GridFixtureSystem.cs @@ -10,7 +10,7 @@ namespace Robust.Client.Physics { - internal sealed partial class GridFixtureSystem : SharedGridFixtureSystem + public sealed partial class GridFixtureSystem : SharedGridFixtureSystem { [Dependency] private IOverlayManager _overlay = default!; [Dependency] private SharedTransformSystem _transform = default!; diff --git a/Robust.Client/Placement/PlacementManager.cs b/Robust.Client/Placement/PlacementManager.cs index b5ec4a6c9..d1066dc83 100644 --- a/Robust.Client/Placement/PlacementManager.cs +++ b/Robust.Client/Placement/PlacementManager.cs @@ -111,7 +111,7 @@ private set /// /// Holds the selection rectangle for the eraser /// - public Box2? EraserRect { get; set; } + public Box2Rotated? EraserRect { get; set; } /// /// Drawing shader for drawing without being affected by lighting @@ -482,12 +482,16 @@ public void HandleDeletion(EntityUid entity) _networkManager.ClientSendMessage(msg); } - public void HandleRectDeletion(EntityCoordinates start, Box2 rect) + public void HandleRectDeletion(EntityCoordinates start, Box2Rotated rect) { - var msg = new MsgPlacement(); - msg.PlaceType = PlacementManagerMessage.RequestRectRemove; - msg.NetCoordinates = new NetCoordinates(EntityManager.GetNetEntity(StartPoint.EntityId), rect.BottomLeft); - msg.RectSize = rect.Size; + var centerCoords = XformSystem.ToCoordinates(new MapCoordinates(rect.Origin, XformSystem.GetMapId(start))); + var msg = new MsgPlacement + { + PlaceType = PlacementManagerMessage.RequestRectRemove, + NetCoordinates = EntityManager.GetNetCoordinates(centerCoords), + RectSize = rect.Box.Size, + RectRotation = (float)rect.Rotation.Theta + }; _networkManager.ClientSendMessage(msg); } @@ -595,28 +599,15 @@ public void FrameUpdate(FrameEventArgs e) { if (!CurrentEraserMouseCoordinates(out EntityCoordinates end)) return; - float b, l, t, r; - if (StartPoint.X < end.X) - { - l = StartPoint.X; - r = end.X; - } - else - { - l = end.X; - r = StartPoint.X; - } - if (StartPoint.Y < end.Y) - { - b = StartPoint.Y; - t = end.Y; - } - else - { - b = end.Y; - t = StartPoint.Y; - } - EraserRect = new Box2(l, b, r, t); + + var startPos = XformSystem.ToWorldPosition(StartPoint); + var endPos = XformSystem.ToWorldPosition(end); + var eyeRot = _eyeManager.CurrentEye.Rotation; + + var diff = eyeRot.RotateVec(endPos - startPos); + var size = new Vector2(MathF.Abs(diff.X), MathF.Abs(diff.Y)); + var centerPos = (startPos + endPos) / 2f; + EraserRect = new Box2Rotated(Box2.CenteredAround(centerPos, size), -eyeRot, centerPos); } return; } @@ -665,7 +656,9 @@ private void EraseRectMode() return; StartPoint = coordinates; - EraserRect = new Box2(coordinates.Position, Vector2.Zero); + var startPos = XformSystem.ToWorldPosition(coordinates); + var eyeRot = _eyeManager.CurrentEye.Rotation; + EraserRect = new Box2Rotated(new Box2(startPos, startPos), -eyeRot, startPos); } private bool DeactivateSpecialPlacement() diff --git a/Robust.Client/Replays/Loading/BufferedReplayDataProvider.cs b/Robust.Client/Replays/Loading/BufferedReplayDataProvider.cs new file mode 100644 index 000000000..a48c1b271 --- /dev/null +++ b/Robust.Client/Replays/Loading/BufferedReplayDataProvider.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Robust.Shared.GameStates; +using Robust.Shared.Log; +using Robust.Shared.Replays; +using Robust.Shared.Serialization; +using Robust.Shared.Upload; +using Robust.Shared.Utility; + +namespace Robust.Client.Replays.Loading; + +/// +/// A windowed : instead of keeping every and +/// resident, it keeps only a small number of recently-used data blocks +/// loaded and lazily (re)reads blocks from the replay file on demand. +/// +/// +/// Each block corresponds to one data_N file in the replay (≈1 MB uncompressed). When playing +/// back linearly a block boundary is crossed only every few seconds, so the synchronous decompress + +/// deserialize cost is negligible and no background prefetch thread is needed. Jumping around (scrubbing) +/// loads whichever blocks lie between the nearest checkpoint and the target tick; older blocks are +/// evicted once the window limit is exceeded. +/// +public sealed class BufferedReplayDataProvider : IReplayDataProvider +{ + /// + /// Describes where a contiguous run of ticks lives: which file and the index range it covers. + /// + public readonly struct BlockMeta + { + public readonly ResPath FileName; + public readonly int Start; + public readonly int Count; + + public BlockMeta(ResPath fileName, int start, int count) + { + FileName = fileName; + Start = start; + Count = count; + } + } + + private sealed class LoadedBlock + { + public readonly GameState[] States; + public readonly ReplayMessage[] Messages; + + public LoadedBlock(GameState[] states, ReplayMessage[] messages) + { + States = states; + Messages = messages; + } + } + + private readonly IReplayFileReader _fileReader; + private readonly IRobustSerializer _serializer; + private readonly ISawmill _sawmill; + private readonly BlockMeta[] _blocks; + private readonly int[] _blockStarts; // parallel to _blocks, for binary search + private readonly int _maxLoadedBlocks; + + private readonly Dictionary _loaded = new(); + private readonly Dictionary> _lruNodes = new(); + private readonly LinkedList _lru = new(); // most-recently-used at the front + + private bool _disposed; + + public int Count { get; } + + public BufferedReplayDataProvider( + IReplayFileReader fileReader, + IRobustSerializer serializer, + BlockMeta[] blocks, + int count, + int maxLoadedBlocks, + ISawmill sawmill) + { + _fileReader = fileReader; + _serializer = serializer; + _blocks = blocks; + _sawmill = sawmill; + Count = count; + _maxLoadedBlocks = Math.Max(2, maxLoadedBlocks); + + _blockStarts = new int[blocks.Length]; + for (var i = 0; i < blocks.Length; i++) + _blockStarts[i] = blocks[i].Start; + } + + public GameState GetState(int index) + { + var blockIdx = ResolveBlockIndex(index); + var block = GetOrLoad(blockIdx); + return block.States[index - _blocks[blockIdx].Start]; + } + + public ReplayMessage GetMessages(int index) + { + var blockIdx = ResolveBlockIndex(index); + var block = GetOrLoad(blockIdx); + return block.Messages[index - _blocks[blockIdx].Start]; + } + + private int ResolveBlockIndex(int index) + { + if (index < 0 || index >= Count) + throw new ArgumentOutOfRangeException(nameof(index), index, $"Replay tick index out of range [0, {Count})."); + + // Find the block whose [Start, Start+Count) range contains index. + var i = Array.BinarySearch(_blockStarts, index); + if (i < 0) + i = ~i - 1; // index falls inside the block that starts before it + return i; + } + + private LoadedBlock GetOrLoad(int blockIdx) + { + if (_loaded.TryGetValue(blockIdx, out var block)) + { + Touch(blockIdx); + return block; + } + + block = LoadBlock(_blocks[blockIdx]); + _loaded[blockIdx] = block; + _lruNodes[blockIdx] = _lru.AddFirst(blockIdx); + Evict(); + return block; + } + + private void Touch(int blockIdx) + { + var node = _lruNodes[blockIdx]; + if (node.Previous == null) + return; // already most-recent + _lru.Remove(node); + _lru.AddFirst(node); + } + + private void Evict() + { + while (_loaded.Count > _maxLoadedBlocks) + { + var lru = _lru.Last!; + _lru.RemoveLast(); + _loaded.Remove(lru.Value); + _lruNodes.Remove(lru.Value); + } + } + + private LoadedBlock LoadBlock(in BlockMeta meta) + { + using var fileStream = _fileReader.Open(meta.FileName); + using var decompressStream = new ZStdDecompressStream(fileStream, false); + + var intBuf = new byte[4]; + fileStream.ReadExactly(intBuf); + var uncompressedSize = BitConverter.ToInt32(intBuf); + + var ms = new MemoryStream(uncompressedSize); + decompressStream.CopyTo(ms); + ms.Position = 0; + + var states = new GameState[meta.Count]; + var messages = new ReplayMessage[meta.Count]; + + var i = 0; + while (ms.Position < ms.Length) + { + _serializer.DeserializeDirect(ms, out GameState state); + _serializer.DeserializeDirect(ms, out ReplayMessage msg); + FilterUploadMessages(msg); + states[i] = state; + messages[i] = msg; + i++; + } + + DebugTools.AssertEqual(i, meta.Count); + return new LoadedBlock(states, messages); + } + + /// + /// Prototype and resource uploads are consumed once during checkpoint generation (they are removed + /// from the message list there via RemoveSwap). When a block is re-read for playback we must drop + /// them again, otherwise they would be re-dispatched and the playback code asserts they are absent. + /// All other message types (cvar changes, PVS leaves, entity events) are required for playback. + /// + private static void FilterUploadMessages(ReplayMessage msg) + { + msg.Messages.RemoveAll(static m => + m is ReplayPrototypeUploadMsg + || m is SharedNetworkResourceManager.ReplayResourceUploadMsg); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _loaded.Clear(); + _lruNodes.Clear(); + _lru.Clear(); + _fileReader.Dispose(); + } +} diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs index 85991865d..beb2ba086 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs @@ -45,11 +45,11 @@ public EntityState BakeChanges() } } - public async Task<(CheckpointState[], TimeSpan[])> GenerateCheckpointsAsync( + private async Task<(CheckpointState[], TimeSpan[])> GenerateCheckpointsAsync( ReplayMessage? initMessages, HashSet initialCvars, - List states, - List messages, + IEnumerable<(GameState State, ReplayMessage Messages)> history, + HistoryStreamStats stats, LoadReplayCallback callback) { // Given a set of states [0 to X], [X to X+1], [X+1 to X+2]..., this method will generate additional states @@ -90,8 +90,15 @@ public EntityState BakeChanges() } var timeBase = _timing.TimeBase; - var checkPoints = new List(1 + states.Count / _checkpointInterval); - var state0 = states[0]; + var checkPoints = new List(); + + // The history arrives as a lazy block-by-block stream (see StreamHistory); it is consumed exactly + // once, strictly in tick order, so nothing behind the cursor stays reachable from here. + using var historyEnumerator = history.GetEnumerator(); + if (!historyEnumerator.MoveNext()) + throw new Exception("Replay contains no game states"); + + var (state0, messages0) = historyEnumerator.Current; // Get all initial prototypes var prototypes = new Dictionary>(); @@ -112,7 +119,7 @@ public EntityState BakeChanges() if (initMessages != null) UpdateMessages(initMessages, uploadedFiles, prototypes, cvars, detachQueue, ref timeBase, true); - UpdateMessages(messages[0], uploadedFiles, prototypes, cvars, detachQueue, ref timeBase, true); + UpdateMessages(messages0, uploadedFiles, prototypes, cvars, detachQueue, ref timeBase, true); var entSpan = state0.EntityStates.Value; Dictionary entStates = new(entSpan.Count); @@ -124,7 +131,7 @@ public EntityState BakeChanges() ProcessQueue(GameTick.MaxValue, detachQueue, detached, entStates); - await callback(0, states.Count, LoadingState.ProcessingFiles, true); + await callback(0, stats.TotalBlocks, LoadingState.ProcessingFiles, true); var playerSpan = state0.PlayerStates.Value; Dictionary playerStates = new(playerSpan.Count); foreach (var player in playerSpan) @@ -150,8 +157,7 @@ TimeSpan GetTime(GameTick tick) return timeBase.Item1 + (tick.Value - timeBase.Item2.Value) * period; } - var serverTime = new TimeSpan[states.Count]; - serverTime[0] = TimeSpan.Zero; + var serverTime = new List { TimeSpan.Zero }; var initialTime = GetTime(state0.ToSequence); var ticksSinceLastCheckpoint = 0; @@ -164,21 +170,25 @@ TimeSpan GetTime(GameTick tick) var stats_due_state = 0; var modifiedEntities = new Dictionary(); - for (var i = 1; i < states.Count; i++) + var i = 0; + while (historyEnumerator.MoveNext()) { + i++; + // Progress is reported in data-block units: the total tick count is unknown while streaming. + // BlocksRead is incremented once a block's last tick has been yielded, so +1 = the current block. if (i % 10 == 0) - await callback(i, states.Count, LoadingState.ProcessingFiles, false); + await callback(Math.Min(stats.BlocksRead + 1, stats.TotalBlocks), stats.TotalBlocks, LoadingState.ProcessingFiles, false); var lastState = curState; - curState = states[i]; + (curState, var curMessages) = historyEnumerator.Current; DebugTools.Assert(curState.FromSequence <= lastState.ToSequence); UpdatePlayerStates(curState.PlayerStates.Span, playerStates); UpdateEntityStates(curState.EntityStates.Span, entStates, modifiedEntities, ref spawnedTracker, ref stateTracker, detached); - UpdateMessages(messages[i], uploadedFiles, prototypes, cvars, detachQueue, ref timeBase); + UpdateMessages(curMessages, uploadedFiles, prototypes, cvars, detachQueue, ref timeBase); ProcessQueue(curState.ToSequence, detachQueue, detached, entStates); UpdateDeletions(curState.EntityDeletions, entStates, detached, modifiedEntities); - serverTime[i] = GetTime(curState.ToSequence) - initialTime; + serverTime.Add(GetTime(curState.ToSequence) - initialTime); ticksSinceLastCheckpoint++; // Don't create checkpoints too frequently no matter the circumstance @@ -219,10 +229,10 @@ TimeSpan GetTime(GameTick tick) checkPoints.Add(new CheckpointState(newState, timeBase, cvars, i, detached)); } - _sawmill.Info($"Finished generating {checkPoints.Count} checkpoints. Elapsed time: {st.Elapsed}. Checkpoint every {(float)states.Count / checkPoints.Count} ticks on average"); + _sawmill.Info($"Finished generating {checkPoints.Count} checkpoints. Elapsed time: {st.Elapsed}. Checkpoint every {(float)serverTime.Count / checkPoints.Count} ticks on average"); _sawmill.Info($"Checkpoint stats - Spawning: {stats_due_spawned} StateChanges: {stats_due_state} Ticks: {stats_due_ticks}. "); - await callback(states.Count, states.Count, LoadingState.ProcessingFiles, false); - return (checkPoints.ToArray(), serverTime); + await callback(stats.TotalBlocks, stats.TotalBlocks, LoadingState.ProcessingFiles, false); + return (checkPoints.ToArray(), serverTime.ToArray()); } private void ProcessQueue( diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs index 6e00a0efa..88a9c7458 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs @@ -19,10 +19,11 @@ namespace Robust.Client.Replays.Loading; public sealed partial class ReplayLoadManager { - [SuppressMessage("ReSharper", "UseAwaitUsing")] public async Task LoadReplayAsync(IReplayFileReader fileReader, LoadReplayCallback callback) { - using var _ = fileReader; + // NOTE: fileReader is NOT disposed here. Ownership is transferred to the BufferedReplayDataProvider + // below, which keeps reading data blocks lazily during playback and disposes the reader when the + // replay is unloaded (ReplayPlaybackManager.StopReplay -> ReplayData.Dispose). if (_client.RunLevel == ClientRunLevel.Initialize) _client.StartSinglePlayer(); @@ -30,64 +31,57 @@ public async Task LoadReplayAsync(IReplayFileReader fileReader, Load throw new Exception($"Invalid runlevel: {_client.RunLevel}."); _timing.Paused = true; - List states = new(); - List messages = new(); var compressionContext = new ZStdCompressionContext(); var metaData = LoadMetadata(fileReader); var totalData = fileReader.AllFiles.Count(x => x.Filename.StartsWith(DataFilePrefix)); - var i = 0; - var intBuf = new byte[4]; - var name = new ResPath($"{DataFilePrefix}{i++}.{Ext}"); - while (fileReader.Exists(name)) - { - await callback(i+1, totalData, LoadingState.ReadingFiles, false); - - using var fileStream = fileReader.Open(name); - using var decompressStream = new ZStdDecompressStream(fileStream, false); - - fileStream.ReadExactly(intBuf); - var uncompressedSize = BitConverter.ToInt32(intBuf); - - var decompressedStream = new MemoryStream(uncompressedSize); - decompressStream.CopyTo(decompressedStream); - decompressedStream.Position = 0; - DebugTools.Assert(uncompressedSize == decompressedStream.Length); - - while (decompressedStream.Position < decompressedStream.Length) - { - _serializer.DeserializeDirect(decompressedStream, out GameState state); - _serializer.DeserializeDirect(decompressedStream, out ReplayMessage msg); - states.Add(state); - messages.Add(msg); - } - - name = new ResPath($"{DataFilePrefix}{i++}.{Ext}"); - } - - // Could happen if there's gaps in the numbers of the data. - if (i - 1 != totalData) - throw new Exception("Could not read expected amount of data files from replay"); - - await callback(totalData, totalData, LoadingState.ReadingFiles, false); - + // Init messages are consumed at the very start of checkpoint generation, so load them up front. var initData = LoadInitFile(fileReader, compressionContext); compressionContext.Dispose(); + // Index of which data file backs which range of tick indices, so the provider can re-read blocks + // lazily during playback instead of keeping everything resident. + var blocks = new List(); + var stats = new HistoryStreamStats { TotalBlocks = totalData }; + + // The history is streamed block-by-block straight into checkpoint generation: at no point does the + // whole deserialized replay sit in memory. Only the checkpoints (plus whatever per-entity states + // they share with the last-seen history) survive the pass. var (checkpoints, serverTime) = await GenerateCheckpointsAsync( initData, metaData.CVars, - states, messages, + StreamHistory(fileReader, totalData, blocks, stats), + stats, callback); _timing.Paused = false; + + if (stats.TickCount == 0) + throw new Exception("Replay contains no game states"); + + var provider = new BufferedReplayDataProvider( + fileReader, + _serializer, + blocks.ToArray(), + stats.TickCount, + _loadedBlockWindow, + _sawmill); + + // The streaming pass churns a lot of transient per-block data, some of which gets promoted to + // Gen2/LOH before dying. Compact once so playback starts with a tight heap. One-off cost. + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + + _sawmill.Info($"[BUFFER] Streamed load done. Managed heap now {GC.GetTotalMemory(false) / 1024.0 / 1024.0:N0} MB " + + $"({blocks.Count} data blocks indexed, window={_loadedBlockWindow}, checkpoints={checkpoints.Length}). " + + $"Remaining growth during playback is the live entity world, not replay history."); + return new ReplayData( - states, - messages, + provider, serverTime, - states[0].ToSequence, + stats.FirstTick, + stats.LastTick, metaData.StartTime, metaData.Duration, checkpoints, @@ -96,6 +90,78 @@ public async Task LoadReplayAsync(IReplayFileReader fileReader, Load metaData.YamlData); } + /// + /// Aggregates facts about the replay history that only become known while streaming through it. + /// Filled in by as the consumer advances the enumeration. + /// + private sealed class HistoryStreamStats + { + public GameTick FirstTick; + public GameTick LastTick; + public int TickCount; + public int BlocksRead; + public int TotalBlocks; + } + + /// + /// Lazily decodes the replay history one data block at a time, yielding (state, messages) pairs in tick + /// order. Builds the index as a side effect. Blocks + /// become garbage as soon as the consumer moves past them, keeping the load-time memory peak flat. + /// Loading-screen progress is reported solely by the consumer (checkpoint generation) so that the UI + /// does not flip between the reading/processing phases every block. + /// + private IEnumerable<(GameState State, ReplayMessage Messages)> StreamHistory( + IReplayFileReader fileReader, + int totalData, + List blocks, + HistoryStreamStats stats) + { + var i = 0; + var intBuf = new byte[4]; + var name = new ResPath($"{DataFilePrefix}{i++}.{Ext}"); + while (fileReader.Exists(name)) + { + var blockStart = stats.TickCount; + var blockFile = name; + + using (var fileStream = fileReader.Open(name)) + using (var decompressStream = new ZStdDecompressStream(fileStream, false)) + { + fileStream.ReadExactly(intBuf); + var uncompressedSize = BitConverter.ToInt32(intBuf); + + var decompressedStream = new MemoryStream(uncompressedSize); + decompressStream.CopyTo(decompressedStream); + decompressedStream.Position = 0; + DebugTools.Assert(uncompressedSize == decompressedStream.Length); + + while (decompressedStream.Position < decompressedStream.Length) + { + _serializer.DeserializeDirect(decompressedStream, out GameState state); + _serializer.DeserializeDirect(decompressedStream, out ReplayMessage msg); + + if (stats.TickCount == 0) + stats.FirstTick = state.ToSequence; + stats.LastTick = state.ToSequence; + stats.TickCount++; + + yield return (state, msg); + } + } + + var blockCount = stats.TickCount - blockStart; + if (blockCount > 0) + blocks.Add(new BufferedReplayDataProvider.BlockMeta(blockFile, blockStart, blockCount)); + stats.BlocksRead++; + + name = new ResPath($"{DataFilePrefix}{i++}.{Ext}"); + } + + // Could happen if there's gaps in the numbers of the data. + if (i - 1 != totalData) + throw new Exception("Could not read expected amount of data files from replay"); + } + private ReplayMessage? LoadInitFile( IReplayFileReader fileReader, ZStdCompressionContext compressionContext) diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs index e319e7505..0f2fa9571 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs @@ -91,7 +91,7 @@ public async Task StartReplayAsync(ReplayData data, LoadReplayCallback callback) // TODO add progress bar / loading stage for this? await callback(0, total, LoadingState.Initializing, true); var nextIndex = checkpoint.Index + 1; - var next = nextIndex < data.States.Count ? data.States[nextIndex] : null; + var next = nextIndex < data.Count ? data.GetState(nextIndex) : null; _gameState.ClearDetachQueue(); _gameState.ApplyGameState(checkpoint.State, next); diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.cs index 54ca7dcae..fa33194e8 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.cs @@ -36,6 +36,7 @@ public sealed partial class ReplayLoadManager : IReplayLoadManager private int _checkpointMinInterval; private int _checkpointEntitySpawnThreshold; private int _checkpointEntityStateThreshold; + private int _loadedBlockWindow; private ISawmill _sawmill = default!; public void Initialize() @@ -50,6 +51,7 @@ public void Initialize() true); _confMan.OnValueChanged(CVars.CheckpointEntityStateThreshold, value => _checkpointEntityStateThreshold = value, true); + _confMan.OnValueChanged(CVars.ReplayLoadedBlockWindow, value => _loadedBlockWindow = value, true); _metaId = _factory.GetRegistration(typeof(MetaDataComponent)).NetID!.Value; _sawmill = _logMan.GetSawmill("replay"); } diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs index a3c47528c..7f6b67382 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs @@ -59,7 +59,7 @@ private void ApplyCheckpointState(CheckpointState checkpoint, ReplayData replay) DebugTools.Assert(replay.ClientSideRecording || checkpoint.Detached.Count == 0); var nextIndex = checkpoint.Index + 1; - var next = nextIndex < replay.States.Count ? replay.States[nextIndex] : null; + var next = nextIndex < replay.Count ? replay.GetState(nextIndex) : null; _gameState.PartialStateReset(checkpoint.FullState, false, false); _entMan.EntitySysManager.GetEntitySystem().Reset(); _entMan.EntitySysManager.GetEntitySystem().Reset(); diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs index 9ac3f0e98..a3c9b8abf 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs @@ -29,7 +29,7 @@ public void SetIndex(int value, bool pausePlayback = true) } Playing &= !pausePlayback; - value = Math.Clamp(value, 0, Replay.States.Count - 1); + value = Math.Clamp(value, 0, Replay.Count - 1); if (value == Replay.CurrentIndex) { ScrubbingTarget = null; @@ -115,7 +115,7 @@ public int GetIndex(TimeSpan time) return 0; if (time >= Replay.ReplayTime[^1]) - return Replay.States.Count - 1; + return Replay.Count - 1; var index = Array.BinarySearch(Replay.ReplayTime, time); diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs index a6bbb15dc..c13bf3030 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs @@ -26,7 +26,7 @@ private void TickUpdateOverride(FrameEventArgs args) if (ScrubbingTarget != null) SetIndex(ScrubbingTarget.Value, false); - if (Replay.CurrentIndex + 1 >= Replay.States.Count) + if (Replay.CurrentIndex + 1 >= Replay.Count) Playing = false; // TODO REPLAYS do we actually need to do this? diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs index 19bb7c1ac..9336f7cb8 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs @@ -131,6 +131,7 @@ public void StopReplay() if (Replay == null) return; + var replay = Replay; _playing = false; Replay.CurrentIndex = -1; Replay = null; @@ -141,6 +142,9 @@ public void StopReplay() _netResMan.ClearResources(); _protoMan.Reset(); + // Release the windowed data provider and the underlying replay file handle. + replay.Dispose(); + ReplayPlaybackStopped?.Invoke(); } diff --git a/Robust.Client/Replays/UI/ReplayControlWidget.cs b/Robust.Client/Replays/UI/ReplayControlWidget.cs index e09dc77d2..bc37cbf8b 100644 --- a/Robust.Client/Replays/UI/ReplayControlWidget.cs +++ b/Robust.Client/Replays/UI/ReplayControlWidget.cs @@ -87,8 +87,8 @@ protected override void FrameUpdate(FrameEventArgs args) } var percentage = (100 * TickSlider.GetAsRatio()).ToString("F2"); - var maxIndex = Math.Max(1, replay.States.Count - 1); - var state = replay.States[index]; + var maxIndex = Math.Max(1, replay.Count - 1); + var state = replay.GetState(index); var replayTime = TimeSpan.FromSeconds(TickSlider.Value); var end = replay.Duration == null ? "N/A" : replay.Duration.Value.ToString(TimeFormat); @@ -96,7 +96,7 @@ protected override void FrameUpdate(FrameEventArgs args) ("current", index), ("total", maxIndex), ("percentage", percentage)); TickLabel.Text = Loc.GetString("replay-time-box-tick-label", - ("current", state.ToSequence), ("total", replay.States[^1].ToSequence), ("percentage", percentage)); + ("current", state.ToSequence), ("total", replay.LastTick), ("percentage", percentage)); TimeLabel.Text = Loc.GetString("replay-time-box-replay-time-label", ("current", replayTime.ToString(TimeFormat)), ("end", end), ("percentage", percentage)); diff --git a/Robust.Client/ResourceManagement/IResourceCacheInternal.cs b/Robust.Client/ResourceManagement/IResourceCacheInternal.cs index 7d74ec77c..288a98f51 100644 --- a/Robust.Client/ResourceManagement/IResourceCacheInternal.cs +++ b/Robust.Client/ResourceManagement/IResourceCacheInternal.cs @@ -1,5 +1,7 @@ -using Robust.LoaderApi; +using Robust.Client.GameObjects; +using Robust.LoaderApi; using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; using Robust.Shared.Utility; namespace Robust.Client.ResourceManagement; @@ -12,4 +14,8 @@ internal interface IResourceCacheInternal : IResourceCache void PreloadTextures(); void MountLoaderApi(IResourceManager manager, IFileApi api, string apiPrefix, ResPath? prefix = null); + + void AddToDeserialize(SpriteComponent component); + void LoadBaseRsi(EntityUid uid, SpriteComponent component); + void AfterDeserialization(); } diff --git a/Robust.Client/ResourceManagement/ResourceCache.Preload.cs b/Robust.Client/ResourceManagement/ResourceCache.Preload.cs index 7d1e5a693..e7d9203a4 100644 --- a/Robust.Client/ResourceManagement/ResourceCache.Preload.cs +++ b/Robust.Client/ResourceManagement/ResourceCache.Preload.cs @@ -2,21 +2,21 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using OpenToolkit.Graphics.OpenGL4; -using Robust.Client.Audio; +using Robust.Client.GameObjects; 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.GameObjects; using Robust.Shared.Graphics; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Maths; -using Robust.Shared.Utility; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; @@ -32,10 +32,13 @@ internal partial class ResourceCache [Dependency] private ILogManager _logManager = default!; [Dependency] private IConfigurationManager _configurationManager = default!; + private readonly List _toDeserialize = new(); + public void PreloadTextures() { var sawmill = _logManager.GetSawmill("res.preload"); + PreloadRsis(sawmill); if (!_configurationManager.GetCVar(CVars.ResTexturePreloadingEnabled)) { sawmill.Debug($"Skipping texture preloading due to CVar value."); @@ -43,7 +46,43 @@ public void PreloadTextures() } PreloadTextures(sawmill); - PreloadRsis(sawmill); + } + + public void AddToDeserialize(SpriteComponent component) + { + _toDeserialize.Add(component); + } + + public void LoadBaseRsi(EntityUid uid, SpriteComponent component) + { + if (!string.IsNullOrWhiteSpace(component.rsi)) + { + var rsiPath = SpriteSystem.TextureRoot / component.rsi; + if (TryGetResource(rsiPath, out RSIResource? resource)) + component._baseRsi = resource.RSI; + else + Sawmill.Error($"Unable to load RSI '{rsiPath}'."); + } + + if (component.layerDatums.Count != 0) + { + component.LayerMap.Clear(); + component.Layers.Clear(); + foreach (var datum in component.layerDatums) + { + var layer = new SpriteComponent.Layer((uid, component), component.Layers.Count); + component.Layers.Add(layer); + component.LayerSetData(layer, datum); + } + } + } + + public void AfterDeserialization() + { + foreach (var sprite in _toDeserialize) + { + LoadBaseRsi(default, sprite); + } } private void PreloadTextures(ISawmill sawmill) @@ -147,39 +186,57 @@ private void PreloadRsis(ISawmill sawmill) .Where(p => p.Extension == "rsic") .Select(c => c.WithExtension("rsi")); - var rsiList = foundRsiList - .Concat(foundRsicList) - .Where(p => !resList.ContainsKey(p)) + var rsiListEnumerable = foundRsiList + .Concat(foundRsicList); + + if (resList.Count > 0) + rsiListEnumerable = rsiListEnumerable.Where(p => !resList.ContainsKey(p)); + + var rsiList = rsiListEnumerable .Select(p => new RSIResource.LoadStepData {Path = p}) .ToArray(); - Parallel.ForEach(rsiList, data => - { - try - { - RSIResource.LoadPreTexture(_manager, data); - } - catch (Exception e) + Parallel.For( + 0, + rsiList.Length, + i => { - // Mark failed loads as bad and skip them in the next few stages. - // Avoids any silly array resizing or similar. - sawmill.Error($"Exception while loading RSI {data.Path}:\n{e}"); - data.Bad = true; + ref var datum = ref rsiList[i]; + try + { + RSIResource.LoadPreTexture(_manager, ref datum); + } + catch (Exception e) + { + // Mark failed loads as bad and skip them in the next few stages. + // Avoids any silly array resizing or similar. + sawmill.Error($"Exception while loading RSI {datum.Path}:\n{e}"); + datum.Bad = true; + } } - }); + ); - var atlasLookup = rsiList.ToLookup(ShouldMetaAtlas); - var atlasList = atlasLookup[true].ToArray(); - var nonAtlasList = atlasLookup[false].ToArray(); + var atlasList = new List(); + var nonAtlasList = new List(); + var span = rsiList.AsSpan(); + for (var i = 0; i < span.Length; i++) + { + ref var data = ref span[i]; + if (ShouldMetaAtlas(data)) + atlasList.Add(i); + else + nonAtlasList.Add(i); + } - foreach (var data in nonAtlasList) + foreach (var i in nonAtlasList) { + ref var data = ref rsiList[i]; if (data.Bad) continue; try { - RSIResource.LoadTexture(Clyde, data); + RSIResource.LoadTexture(Clyde, ref data); } catch (Exception e) { @@ -215,9 +272,22 @@ private void PreloadRsis(ISawmill sawmill) // - 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)); + atlasList.Sort((b, a) => rsiList[a].AtlasSheet.Height.CompareTo(rsiList[b].AtlasSheet.Height)); + #if FULL_RELEASE var maxSize = Math.Min(GL.GetInteger(GetPName.MaxTextureSize), _configurationManager.GetCVar(CVars.ResRSIAtlasSize)); + #else + // For tests + var maxSize = 12288; + try + { + maxSize = Math.Min(GL.GetInteger(GetPName.MaxTextureSize), _configurationManager.GetCVar(CVars.ResRSIAtlasSize)); + } + catch (Exception) + { + // ignored + } + #endif // 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. @@ -236,27 +306,28 @@ private void PreloadRsis(ISawmill sawmill) // 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) + foreach (var i in atlasList) { + ref var rsi = ref rsiList[i]; var insertHeight = rsi.AtlasSheet.Height; var insertWidth = rsi.AtlasSheet.Width; var found = false; - for (var i = 0; i < levels.Count && !found; i++) + for (var j = 0; j < levels.Count && !found; j++) { - var levelPosition = levels[i].Position; - var levelWidth = levels[i].Width; - var levelHeight = levels[i].Height; + var levelPosition = levels[j].Position; + var levelWidth = levels[j].Width; + var levelHeight = levels[j].Height; // Check if it can fit in this level. - if (levelHeight < insertHeight || levelWidth + insertWidth > levels[i].MaxWidth) + if (levelHeight < insertHeight || levelWidth + insertWidth > levels[j].MaxWidth) continue; found = true; - levels[i].Width += insertWidth; + levels[j].Width += insertWidth; rsi.AtlasOffset = levelPosition + new Vector2i(levelWidth, 0); - levels[i].RSIList.Add(rsi); + levels[j].RSIList.Add(i); // 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. @@ -266,7 +337,7 @@ private void PreloadRsis(ISawmill sawmill) var freeLevel = new Level { - AtlasId = levels[i].AtlasId, + AtlasId = levels[j].AtlasId, Position = levelPosition + new Vector2i(levelWidth, insertHeight), Height = levelHeight - insertHeight, Width = 0, @@ -299,7 +370,7 @@ private void PreloadRsis(ISawmill sawmill) Height = insertHeight, Width = insertWidth, MaxWidth = maxSize, - RSIList = [ rsi ] + RSIList = [ i ] }; levels.Add(newLevel); @@ -313,8 +384,9 @@ private void PreloadRsis(ISawmill sawmill) // Put all textures on the atlases foreach (var level in levels) { - foreach (var rsi in level.RSIList) + foreach (var i in level.RSIList) { + ref var rsi = ref rsiList[i]; var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height); rsi.AtlasSheet.Blit(box, imageAtlases[level.AtlasId], rsi.AtlasOffset); @@ -342,8 +414,10 @@ private void PreloadRsis(ISawmill sawmill) // Finally, reference the actual atlas from the RSIs. foreach (var level in levels) { - foreach (var rsi in level.RSIList) + var levelSpan = CollectionsMarshal.AsSpan(level.RSIList); + foreach (var i in levelSpan) { + ref var rsi = ref rsiList[i]; rsi.AtlasTexture = finalAtlases[level.AtlasId]; } } @@ -355,7 +429,7 @@ private void PreloadRsis(ISawmill sawmill) try { - RSIResource.LoadPostTexture(data); + RSIResource.LoadPostTexture(ref data); } catch (Exception e) { @@ -365,7 +439,7 @@ private void PreloadRsis(ISawmill sawmill) }); var errors = 0; - foreach (var data in rsiList) + foreach (ref var data in rsiList.AsSpan()) { try { @@ -378,7 +452,7 @@ private void PreloadRsis(ISawmill sawmill) try { var rsiRes = new RSIResource(); - rsiRes.LoadFinish(this, data); + rsiRes.LoadFinish(this, ref data); resList[data.Path] = rsiRes; } catch (Exception e) @@ -398,7 +472,7 @@ private void PreloadRsis(ISawmill sawmill) "Preloaded {CountLoaded} RSIs into {CountAtlas} Atlas(es?) ({CountNotAtlas} not atlassed, {CountErrored} errored) in {LoadTime}", rsiList.Length, finalAtlases.Count, - nonAtlasList.Length, + nonAtlasList.Count, errors, sw.Elapsed); } @@ -440,6 +514,6 @@ internal sealed class Level /// /// List of all the RSIs stored in this level. RSIs are ordered from tallest to smallest per level. /// - public required List RSIList; + public required List RSIList; } } diff --git a/Robust.Client/ResourceManagement/ResourceTypes/RSIResource.cs b/Robust.Client/ResourceManagement/ResourceTypes/RSIResource.cs index 494834e54..327760535 100644 --- a/Robust.Client/ResourceManagement/ResourceTypes/RSIResource.cs +++ b/Robust.Client/ResourceManagement/ResourceTypes/RSIResource.cs @@ -38,15 +38,15 @@ public override void Load(IDependencyCollection dependencies, ResPath path) { var loadStepData = new LoadStepData {Path = path}; var manager = dependencies.Resolve(); - LoadPreTexture(manager, loadStepData); - LoadTexture(dependencies.Resolve(), loadStepData); - LoadPostTexture(loadStepData); - LoadFinish(dependencies.Resolve(), loadStepData); + LoadPreTexture(manager, ref loadStepData); + LoadTexture(dependencies.Resolve(), ref loadStepData); + LoadPostTexture(ref loadStepData); + LoadFinish(dependencies.Resolve(), ref loadStepData); loadStepData.AtlasSheet.Dispose(); } - internal static void LoadTexture(IClyde clyde, LoadStepData loadStepData) + internal static void LoadTexture(IClyde clyde, ref LoadStepData loadStepData) { loadStepData.AtlasTexture = clyde.LoadTextureFromImage( loadStepData.AtlasSheet, @@ -54,19 +54,19 @@ internal static void LoadTexture(IClyde clyde, LoadStepData loadStepData) loadStepData.LoadParameters); } - internal static void LoadPreTexture(IResourceManager manager, LoadStepData data) + internal static void LoadPreTexture(IResourceManager manager, ref LoadStepData data) { var manifestPath = data.Path / "meta.json"; if (manager.TryContentFileRead(manifestPath, out var manifestFile)) { - LoadPreTextureFolder(manager, data, manifestFile); + LoadPreTextureFolder(manager, ref data, manifestFile); } else { var rsicPath = data.Path.WithExtension("rsic"); if (manager.TryContentFileRead(rsicPath, out var rsicFile)) { - LoadPreTextureRsic(data, rsicFile); + LoadPreTextureRsic(ref data, rsicFile); } else { @@ -75,7 +75,7 @@ internal static void LoadPreTexture(IResourceManager manager, LoadStepData data) } } - private static void LoadPreTextureFolder(IResourceManager manager, LoadStepData data, Stream manifestFile) + private static void LoadPreTextureFolder(IResourceManager manager, ref LoadStepData data, Stream manifestFile) { RsiLoading.RsiMetadata metadata; using (manifestFile) @@ -89,12 +89,13 @@ private static void LoadPreTextureFolder(IResourceManager manager, LoadStepData try { data.FrameCounts = RsiLoading.CalculateFrameCounts(metadata); + var path = data.Path; images = RsiLoading.LoadImages( metadata, SixLabors.ImageSharp.Configuration.Default, name => { - var texPath = data.Path / (name + ".png"); + var texPath = path / (name + ".png"); return manager.ContentFileRead(texPath); }); @@ -119,13 +120,13 @@ private static void LoadPreTextureFolder(IResourceManager manager, LoadStepData } } - LoadPreTextureCommon(metadata, data); + LoadPreTextureCommon(metadata, ref data); data.LoadParameters = metadata.LoadParameters; data.MetaAtlas = metadata.MetaAtlas; } - private static void LoadPreTextureRsic(LoadStepData data, Stream rsicFile) + private static void LoadPreTextureRsic(ref LoadStepData data, Stream rsicFile) { Image image; using (rsicFile) @@ -145,7 +146,7 @@ private static void LoadPreTextureRsic(LoadStepData data, Stream rsicFile) data.FrameCounts = RsiLoading.CalculateFrameCounts(metadata); - LoadPreTextureCommon(metadata, data); + LoadPreTextureCommon(metadata, ref data); data.DimX = image.Width / metadata.Size.X; data.LoadParameters = metadata.LoadParameters; @@ -154,7 +155,7 @@ private static void LoadPreTextureRsic(LoadStepData data, Stream rsicFile) private static void LoadPreTextureCommon( RsiLoading.RsiMetadata metadata, - LoadStepData data) + ref LoadStepData data) { var stateCount = metadata.States.Length; var toAtlas = new StateReg[stateCount]; @@ -173,12 +174,12 @@ private static void LoadPreTextureCommon( var (foldedDelays, foldedIndices) = FoldDelays(stateObject.Delays); - var textures = new Texture[foldedIndices.Length][]; + var textures = new AtlasTexture[foldedIndices.Length][]; var callbackOffset = new Vector2i[foldedIndices.Length][]; for (var i = 0; i < textures.Length; i++) { - textures[i] = new Texture[foldedIndices[0].Length]; + textures[i] = new AtlasTexture[foldedIndices[0].Length]; callbackOffset[i] = new Vector2i[foldedIndices[0].Length]; } @@ -213,7 +214,7 @@ private static void LoadPreTextureCommon( data.FrameSize = frameSize; } - internal static void LoadPostTexture(LoadStepData data) + internal static void LoadPostTexture(ref LoadStepData data) { var dimX = data.DimX; var toAtlas = data.AtlasList; @@ -247,7 +248,7 @@ internal static void LoadPostTexture(LoadStepData data) } } - internal void LoadFinish(IResourceCacheInternal cache, LoadStepData data) + internal void LoadFinish(IResourceCacheInternal cache, ref LoadStepData data) { RSI = data.Rsi; cache.RsiLoaded(new RsiLoadedEventArgs(data.Path, this, data.AtlasSheet, data.CallbackOffsets)); @@ -401,26 +402,26 @@ internal static (float[] delays, int[][] indices) FoldDelays(float[][] delays) return (floatDelays, arrayIndices); } - internal sealed class LoadStepData + internal struct LoadStepData() { - public bool Bad; + public bool Bad = false; public ResPath Path = default!; - public Image AtlasSheet = default!; - public int DimX; - public StateReg[] AtlasList = default!; - public int[] FrameCounts = default!; - public Vector2i FrameSize; - public Dictionary CallbackOffsets = default!; - public Texture AtlasTexture = default!; - public Vector2i AtlasOffset; - public RSI Rsi = default!; - public TextureLoadParameters LoadParameters; - public bool MetaAtlas; + public Image AtlasSheet = null!; + public int DimX = 0; + public StateReg[] AtlasList = null!; + public int[] FrameCounts = null!; + public Vector2i FrameSize = default; + public Dictionary CallbackOffsets = null!; + public Texture AtlasTexture = null!; + public Vector2i AtlasOffset = default; + public RSI Rsi = null!; + public TextureLoadParameters LoadParameters = default; + public bool MetaAtlas = false; } internal struct StateReg { - public Texture[][] Output; + public AtlasTexture[][] Output; public int[][] Indices; public Vector2i[][] Offsets; } diff --git a/Robust.Client/UserInterface/Control.cs b/Robust.Client/UserInterface/Control.cs index ebea1db2b..2aa7092c7 100644 --- a/Robust.Client/UserInterface/Control.cs +++ b/Robust.Client/UserInterface/Control.cs @@ -634,6 +634,7 @@ protected virtual void Dispose(bool disposing) /// /// Dispose all children, but leave this one intact. /// + [Obsolete("Use RemoveAllChildren")] public void DisposeAllChildren() { // Cache because the children modify the dictionary. diff --git a/Robust.Client/UserInterface/Controllers/Implementations/TileSpawningUIController.cs b/Robust.Client/UserInterface/Controllers/Implementations/TileSpawningUIController.cs index 3669b2491..11b9573f2 100644 --- a/Robust.Client/UserInterface/Controllers/Implementations/TileSpawningUIController.cs +++ b/Robust.Client/UserInterface/Controllers/Implementations/TileSpawningUIController.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Drawing; using System.Linq; using Robust.Client.Graphics; using Robust.Client.Placement; @@ -9,7 +8,7 @@ using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Enums; -using Robust.Shared.Graphics; +using Robust.Shared.Maths; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Map; @@ -239,7 +238,13 @@ private void BuildTileList(string? searchStr = null) { texture = _resources.GetResource(path); } - _window.TileList.AddItem(Loc.GetString(entry.Name), texture); + + var item = _window.TileList.AddItem(Loc.GetString(entry.Name), texture); + + if (texture != null) + { + item.IconRegion = new UIBox2(0, 0, texture.Width / entry.Variants, texture.Height); + } } } } diff --git a/Robust.Client/UserInterface/Controls/BaseButton.cs b/Robust.Client/UserInterface/Controls/BaseButton.cs index d590b9619..84fa18d24 100644 --- a/Robust.Client/UserInterface/Controls/BaseButton.cs +++ b/Robust.Client/UserInterface/Controls/BaseButton.cs @@ -394,9 +394,9 @@ protected internal override void MouseExited() } } - protected override void Dispose(bool disposing) + protected override void ExitedTree() { - base.Dispose(disposing); + base.ExitedTree(); Group = null; } diff --git a/Robust.Client/UserInterface/Controls/ItemList.cs b/Robust.Client/UserInterface/Controls/ItemList.cs index 8af6fe965..d3cce9847 100644 --- a/Robust.Client/UserInterface/Controls/ItemList.cs +++ b/Robust.Client/UserInterface/Controls/ItemList.cs @@ -501,15 +501,15 @@ protected internal override void Draw(DrawingHandleScreen handle) } else { - handle.DrawTextureRectRegion(item.Icon, UIBox2.FromDimensions(drawOffset, item.Icon.Size * item.IconScale), + handle.DrawTextureRectRegion(item.Icon, UIBox2.FromDimensions(drawOffset, item.IconRegion.Size * item.IconScale), item.IconRegion, item.IconModulate); } } if (item.Text != null) { - var textBox = new UIBox2(contentBox.Left + item.IconSize.X * item.IconScale, contentBox.Top, contentBox.Right, - contentBox.Bottom); + var textStart = Math.Min(contentBox.Left + item.IconSize.X * item.IconScale, contentBox.Right); + var textBox = new UIBox2(textStart, contentBox.Top, contentBox.Right, contentBox.Bottom); DrawTextInternal(handle, item.Text, textBox); } } diff --git a/Robust.Client/UserInterface/Controls/Label.cs b/Robust.Client/UserInterface/Controls/Label.cs index ef035e726..4a25fc222 100644 --- a/Robust.Client/UserInterface/Controls/Label.cs +++ b/Robust.Client/UserInterface/Controls/Label.cs @@ -30,6 +30,14 @@ public class Label : Control private bool _clipText; private AlignMode _align; private Font? _fontOverride; + private Font? _actualFont; + private bool _fontCacheValid; + private AlignMode _actualAlign; + private bool _alignCacheValid; + private float? _outlineThicknessOverride; + private Color? _outlineColorOverride; + private TextOutline? _actualFontOutline; + private bool _fontOutlineCacheValid; public Label() { @@ -98,14 +106,28 @@ public bool ClipText [ViewVariables] public AlignMode Align { get { + if (_alignCacheValid) + return _actualAlign; + if (TryGetStyleProperty(StylePropertyAlignMode, out var alignMode)) { - return alignMode; + _actualAlign = alignMode; + _alignCacheValid = true; + return _actualAlign; } - return _align; + _actualAlign = _align; + _alignCacheValid = true; + return _actualAlign; + } + set + { + if (_align == value) + return; + + _align = value; + _alignCacheValid = false; } - set => _align = value; } [ViewVariables] public VAlignMode VAlign { get; set; } @@ -116,6 +138,7 @@ public Font? FontOverride set { _fontOverride = value; + _fontCacheValid = false; _textDimensionCacheValid = false; InvalidateMeasure(); } @@ -125,17 +148,26 @@ private Font ActualFont { get { + if (_fontCacheValid) + return _actualFont!; + if (FontOverride != null) { - return FontOverride; + _actualFont = FontOverride; + _fontCacheValid = true; + return _actualFont; } if (TryGetStyleProperty(StylePropertyFont, out var font)) { - return font; + _actualFont = font; + _fontCacheValid = true; + return _actualFont; } - return UserInterfaceManager.ThemeDefaults.LabelFont; + _actualFont = UserInterfaceManager.ThemeDefaults.LabelFont; + _fontCacheValid = true; + return _actualFont; } } @@ -167,14 +199,39 @@ private Color ActualFontColor public int? ShadowOffsetYOverride { get; set; } - public float? OutlineThicknessOverride { get; set; } + public float? OutlineThicknessOverride + { + get => _outlineThicknessOverride; + set + { + if (_outlineThicknessOverride == value) + return; + + _outlineThicknessOverride = value; + _fontOutlineCacheValid = false; + } + } - public Color? OutlineColorOverride { get; set; } + public Color? OutlineColorOverride + { + get => _outlineColorOverride; + set + { + if (_outlineColorOverride == value) + return; + + _outlineColorOverride = value; + _fontOutlineCacheValid = false; + } + } private TextOutline? ActualFontOutline { get { + if (_fontOutlineCacheValid) + return _actualFontOutline; + var thickness = OutlineThicknessOverride; if (!thickness.HasValue && TryGetStyleProperty(StylePropertyFontOutlineThickness, out var styleThickness)) thickness = styleThickness; @@ -183,7 +240,9 @@ private TextOutline? ActualFontOutline if (!color.HasValue && TryGetStyleProperty(StylePropertyFontOutlineColor, out var styleColor)) color = styleColor; - return TextOutline.FromOverrides(thickness, color); + _actualFontOutline = TextOutline.FromOverrides(thickness, color); + _fontOutlineCacheValid = true; + return _actualFontOutline; } } @@ -220,14 +279,17 @@ protected internal override void Draw(DrawingHandleScreen handle) var newlines = 0; var font = ActualFont; var actualFontColor = ActualFontColor; - var actualFontOutline = ActualFontOutline; + var outline = ActualFontOutline; + var align = Align; + var ascent = font.GetAscent(UIScale); + var lineHeight = font.GetLineHeight(UIScale); Vector2 CalcBaseline() { DebugTools.Assert(_textDimensionCacheValid); int hOffset; - switch (Align) + switch (align) { case AlignMode.Left: hOffset = 0; @@ -243,21 +305,43 @@ Vector2 CalcBaseline() throw new ArgumentOutOfRangeException(); } - return new Vector2(hOffset, font.GetAscent(UIScale) + font.GetLineHeight(UIScale) * newlines + vOffset); + return new Vector2(hOffset, ascent + lineHeight * newlines + vOffset); } var baseLine = CalcBaseline(); + // Outline + if (outline is { } outlineSettings) + { + foreach (var rune in _textMemory.Span.EnumerateRunes()) + { + if (rune == new Rune('\n')) + { + newlines += 1; + baseLine = CalcBaseline(); + continue; + } + + var advance = font.DrawCharOutline(handle, rune, baseLine, UIScale, outlineSettings); + baseLine.X += advance; + } + + newlines = 0; + baseLine = CalcBaseline(); + } + + // Font itself foreach (var rune in _textMemory.Span.EnumerateRunes()) { if (rune == new Rune('\n')) { newlines += 1; baseLine = CalcBaseline(); + continue; } - var advance = font.DrawChar(handle, rune, baseLine, UIScale, actualFontColor, outline: actualFontOutline); - baseLine += new Vector2(advance, 0); + var advance = font.DrawChar(handle, rune, baseLine, UIScale, actualFontColor); + baseLine.X += advance; } } @@ -346,6 +430,9 @@ private void _calculateTextDimension() protected override void StylePropertiesChanged() { _textDimensionCacheValid = false; + _fontOutlineCacheValid = false; + _fontCacheValid = false; + _alignCacheValid = false; base.StylePropertiesChanged(); } diff --git a/Robust.Client/UserInterface/Controls/RichTextLabel.cs b/Robust.Client/UserInterface/Controls/RichTextLabel.cs index 35edb243f..6d59685ef 100644 --- a/Robust.Client/UserInterface/Controls/RichTextLabel.cs +++ b/Robust.Client/UserInterface/Controls/RichTextLabel.cs @@ -20,6 +20,13 @@ public partial class RichTextLabel : Control private RichTextEntry? _entry; private float _lineHeightScale = 1; private bool _lineHeightOverride; + private readonly MarkupDrawingContext _drawingContext = new(); + private Font? _actualFont; + private bool _fontCacheValid; + private float? _outlineThicknessOverride; + private Color? _outlineColorOverride; + private TextOutline? _actualFontOutline; + private bool _fontOutlineCacheValid; [ViewVariables(VVAccess.ReadWrite)] public float LineHeightScale @@ -133,14 +140,39 @@ public void SetMessage(string message, Type[]? tagsAllowed, Color? defaultColor /// public FormattedMessage? GetFormattedMessage() => _entry == null ? null : new FormattedMessage(_entry.Value.Message); - public float? OutlineThicknessOverride { get; set; } + public float? OutlineThicknessOverride + { + get => _outlineThicknessOverride; + set + { + if (_outlineThicknessOverride == value) + return; + + _outlineThicknessOverride = value; + _fontOutlineCacheValid = false; + } + } + + public Color? OutlineColorOverride + { + get => _outlineColorOverride; + set + { + if (_outlineColorOverride == value) + return; - public Color? OutlineColorOverride { get; set; } + _outlineColorOverride = value; + _fontOutlineCacheValid = false; + } + } private TextOutline? ActualFontOutline { get { + if (_fontOutlineCacheValid) + return _actualFontOutline; + var thickness = OutlineThicknessOverride; if (!thickness.HasValue && TryGetStyleProperty(Label.StylePropertyFontOutlineThickness, out var styleThickness)) thickness = styleThickness; @@ -149,7 +181,9 @@ private TextOutline? ActualFontOutline if (!color.HasValue && TryGetStyleProperty(Label.StylePropertyFontOutlineColor, out var styleColor)) color = styleColor; - return TextOutline.FromOverrides(thickness, color); + _actualFontOutline = TextOutline.FromOverrides(thickness, color); + _fontOutlineCacheValid = true; + return _actualFontOutline; } } @@ -170,18 +204,33 @@ protected override Vector2 MeasureOverride(Vector2 availableSize) protected internal override void Draw(DrawingHandleScreen handle) { base.Draw(handle); - _entry?.Draw(_tagManager, handle, _getFont(), SizeBox, 0, new MarkupDrawingContext(), UIScale, LineHeightScale, ActualFontOutline); + _entry?.Draw(_tagManager, handle, _getFont(), SizeBox, 0, _drawingContext, UIScale, LineHeightScale, ActualFontOutline); + } + + protected override void StylePropertiesChanged() + { + _fontOutlineCacheValid = false; + _fontCacheValid = false; + + base.StylePropertiesChanged(); } [Pure] private Font _getFont() { + if (_fontCacheValid) + return _actualFont!; + if (TryGetStyleProperty("font", out var font)) { - return font; + _actualFont = font; + _fontCacheValid = true; + return _actualFont; } - return UserInterfaceManager.ThemeDefaults.DefaultFont; + _actualFont = UserInterfaceManager.ThemeDefaults.DefaultFont; + _fontCacheValid = true; + return _actualFont; } } } diff --git a/Robust.Client/UserInterface/Controls/SpinBox.cs b/Robust.Client/UserInterface/Controls/SpinBox.cs index 8de122845..9b9153884 100644 --- a/Robust.Client/UserInterface/Controls/SpinBox.cs +++ b/Robust.Client/UserInterface/Controls/SpinBox.cs @@ -186,12 +186,12 @@ public void ClearButtons() { foreach (var button in _leftButtons) { - button.Dispose(); + button.Orphan(); } _leftButtons.Clear(); foreach (var button in _rightButtons) { - button.Dispose(); + button.Orphan(); } _rightButtons.Clear(); } diff --git a/Robust.Client/UserInterface/Controls/VerticalTabContainer.xaml.cs b/Robust.Client/UserInterface/Controls/VerticalTabContainer.xaml.cs index 0c9ce2dc1..f7bdce667 100644 --- a/Robust.Client/UserInterface/Controls/VerticalTabContainer.xaml.cs +++ b/Robust.Client/UserInterface/Controls/VerticalTabContainer.xaml.cs @@ -59,7 +59,7 @@ protected override void ChildRemoved(Control child) { if (_tabs.Remove(child, out var button)) { - button.Dispose(); + button.Orphan(); } // Set the current tab to a different control diff --git a/Robust.Client/UserInterface/CustomControls/DefaultWindow.xaml.cs b/Robust.Client/UserInterface/CustomControls/DefaultWindow.xaml.cs index 9e6568e89..9fed784a4 100644 --- a/Robust.Client/UserInterface/CustomControls/DefaultWindow.xaml.cs +++ b/Robust.Client/UserInterface/CustomControls/DefaultWindow.xaml.cs @@ -126,14 +126,11 @@ public string? Title // Drag resizing and moving code is mostly taken from Godot's WindowDialog. - protected override void Dispose(bool disposing) + protected override void ExitedTree() { - base.Dispose(disposing); + base.ExitedTree(); - if (disposing) - { - CloseButton.OnPressed -= CloseButtonPressed; - } + CloseButton.OnPressed -= CloseButtonPressed; } private void CloseButtonPressed(BaseButton.ButtonEventArgs args) @@ -145,6 +142,8 @@ private void CloseButtonPressed(BaseButton.ButtonEventArgs args) protected override void FrameUpdate(FrameEventArgs args) { + base.FrameUpdate(args); + // This is to avoid unnecessarily setting a position where our size isn't yet fully updated. // This most commonly happens with saved window positions if your window position is <= 0. if (!IsMeasureValid) diff --git a/Robust.Client/UserInterface/RichTextEntry.cs b/Robust.Client/UserInterface/RichTextEntry.cs index ec6336eb4..d61fcc1fb 100644 --- a/Robust.Client/UserInterface/RichTextEntry.cs +++ b/Robust.Client/UserInterface/RichTextEntry.cs @@ -235,6 +235,46 @@ public readonly void Draw( float uiScale, float lineHeightScale = 1, TextOutline? outline = null) + { + if (outline is { } outlineSettings) + { + DrawPass( + tagManager, + handle, + defaultFont, + drawBox, + verticalOffset, + context, + uiScale, + lineHeightScale, + outlineSettings, + arrangeControls: false); + } + + DrawPass( + tagManager, + handle, + defaultFont, + drawBox, + verticalOffset, + context, + uiScale, + lineHeightScale, + outline: null, + arrangeControls: true); + } + + private readonly void DrawPass( + MarkupTagManager tagManager, + DrawingHandleBase handle, + Font defaultFont, + UIBox2 drawBox, + float verticalOffset, + MarkupDrawingContext context, + float uiScale, + float lineHeightScale, + TextOutline? outline, + bool arrangeControls) { context.Clear(); context.Color.Push(_defaultColor); @@ -244,6 +284,8 @@ public readonly void Draw( var lineBreakIndex = 0; var baseLine = drawBox.TopLeft + new Vector2(0, defaultFont.GetAscent(uiScale) + verticalOffset); var controlYAdvance = 0f; + var hasOutline = outline.HasValue; + var outlineSettings = outline.GetValueOrDefault(); var spaceRune = new Rune(' '); @@ -275,7 +317,9 @@ public readonly void Draw( skipSpaceBaseline = true; } - var advance = font.DrawChar(handle, rune, baseLine, uiScale, color, outline: outline); + var advance = hasOutline + ? font.DrawCharOutline(handle, rune, baseLine, uiScale, outlineSettings) + : font.DrawChar(handle, rune, baseLine, uiScale, color); if (!skipSpaceBaseline) baseLine += new Vector2(advance, 0); @@ -286,18 +330,26 @@ public readonly void Draw( if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control)) continue; - // Controls may have been previously hidden via HideControls due to being "out-of frame". - // If this ever gets replaced with RectClipContents / scissor box testing, this can be removed. - control.Visible = true; - var invertedScale = 1f / uiScale; - control.Measure(new Vector2(Width, Height)); - control.Arrange(UIBox2.FromDimensions( - baseLine.X * invertedScale, - (baseLine.Y - defaultFont.GetAscent(uiScale)) * invertedScale, - control.DesiredSize.X, - control.DesiredSize.Y - )); + if (arrangeControls) + { + // Controls may have been previously hidden via HideControls due to being "out-of frame". + // If this ever gets replaced with RectClipContents / scissor box testing, this can be removed. + control.Visible = true; + control.Measure(new Vector2(Width, Height)); + control.Arrange(UIBox2.FromDimensions( + baseLine.X * invertedScale, + (baseLine.Y - defaultFont.GetAscent(uiScale)) * invertedScale, + control.DesiredSize.X, + control.DesiredSize.Y + )); + } + else + { + // The outline pass still needs the control's advance to place later glyphs correctly. + control.Measure(new Vector2(Width, Height)); + } + var advanceX = control.DesiredPixelSize.X; controlYAdvance = Math.Max(0f, (control.DesiredPixelSize.Y - GetLineHeight(font, uiScale, lineHeightScale)) * invertedScale); baseLine += new Vector2(advanceX, 0); diff --git a/Robust.Client/UserInterface/UserInterfaceManager.Windows.cs b/Robust.Client/UserInterface/UserInterfaceManager.Windows.cs index 753613487..c0cc50970 100644 --- a/Robust.Client/UserInterface/UserInterfaceManager.Windows.cs +++ b/Robust.Client/UserInterface/UserInterfaceManager.Windows.cs @@ -29,7 +29,7 @@ internal partial class UserInterfaceManager _popupsByType.Remove(typeof(T)); } oldPopup.Close(); - oldPopup.Dispose(); + oldPopup.Orphan(); return true; } @@ -62,7 +62,7 @@ public bool TryGetFirstPopup(Type type, out Popup? popup) _windowsByType.Remove(typeof(T)); } _uiManager.StateRoot.RemoveChild(oldWindow); - oldWindow.Dispose(); + oldWindow.Close(); return true; } @@ -111,7 +111,7 @@ public void ClearWindows() { foreach (var data in _windowsByType) { - data.Value.Dequeue().Dispose(); + data.Value.Dequeue().Close(); } _windowsByType.Clear(); } diff --git a/Robust.Client/UserInterface/WordWrap.cs b/Robust.Client/UserInterface/WordWrap.cs index d28ca605c..0483d26f6 100644 --- a/Robust.Client/UserInterface/WordWrap.cs +++ b/Robust.Client/UserInterface/WordWrap.cs @@ -25,9 +25,6 @@ internal struct WordWrap // The horizontal position of the text cursor. public int PosX; public Rune LastRune; - // If a word is larger than maxSizeX, we split it. - // We need to keep track of some data to split it into two words. - public (int breakIndex, int wordSizePixels)? ForceSplitData = null; public WordWrap(float maxSizeX) { @@ -75,7 +72,6 @@ public void NextRune(Rune rune, out int? breakLine, out int? breakNewLine, out b //wordSize = 0; WordSizePixels = 0; WordStartBreakIndex = (BreakIndexCounter, PosX); - ForceSplitData = null; // Just manually handle newlines. if (rune == new Rune('\n')) @@ -110,21 +106,15 @@ public void NextMetrics(in CharMetrics metrics, out int? breakLine, out bool abo // Break the "word" at the last word index if (WordStartBreakIndex.HasValue && oldWordSizePixels != 0) { - breakLine = WordStartBreakIndex!.Value.index; + breakLine = WordStartBreakIndex.Value.index; MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize); PosX = WordSizePixels; } - if (!ForceSplitData.HasValue) - { - ForceSplitData = (BreakIndexCounter, oldWordSizePixels); - } - // Oh hey we get to break a word that doesn't fit on a single line. if (WordSizePixels > _maxSizeX) { - var (breakIndex, splitWordSize) = ForceSplitData.Value; - if (splitWordSize == 0) + if (oldWordSizePixels == 0) { // Happens if there's literally not enough space for a single character so uh... // Yeah just don't. @@ -132,10 +122,8 @@ public void NextMetrics(in CharMetrics metrics, out int? breakLine, out bool abo return; } - // Reset forceSplitData so that we can split again if necessary. - ForceSplitData = null; - breakLine = breakIndex; - WordSizePixels -= splitWordSize; + breakLine = BreakIndexCounter; + WordSizePixels -= oldWordSizePixels; WordStartBreakIndex = null; MaxUsedWidth = Math.Max(MaxUsedWidth, _maxSizeX); PosX = WordSizePixels; @@ -161,7 +149,6 @@ public int FinalizeText(out int? breakLine) Logger.Error($"wordSizePixels: {WordSizePixels}"); Logger.Error($"posX: {PosX}"); Logger.Error($"lastChar: {LastRune}"); - Logger.Error($"forceSplitData: {ForceSplitData}"); // Logger.Error($"LineBreaks: {string.Join(", ", LineBreaks)}"); throw new Exception( diff --git a/Robust.Client/Utility/DiscordRichPresence.cs b/Robust.Client/Utility/DiscordRichPresence.cs index 2462bf4f4..1a3fb42ef 100644 --- a/Robust.Client/Utility/DiscordRichPresence.cs +++ b/Robust.Client/Utility/DiscordRichPresence.cs @@ -13,9 +13,15 @@ namespace Robust.Client.Utility { internal sealed partial class DiscordRichPresence : IDiscordRichPresence { - private static RichPresence _defaultPresence = new() { }; + private readonly static RichPresence _defaultPresence = new() + { + Assets = new Assets() + }; - private RichPresence? _activePresence; + private readonly static RichPresence _activePresence = new() + { + Assets = new Assets() + }; private DiscordRpcClient? _client; @@ -25,21 +31,23 @@ internal sealed partial class DiscordRichPresence : IDiscordRichPresence private bool _initialized; + private bool _active; + public void Initialize() { var state = _loc.GetString("discord-rpc-in-main-menu"); var largeImageKey = _configurationManager.GetCVar(CVars.DiscordRichPresenceSecondIconId); var largeImageText = _loc.GetString("discord-rpc-in-main-menu-logo-text"); - _defaultPresence = new() - { - State = Truncate(state, 128), - Assets = new Assets - { - LargeImageKey = Truncate(largeImageKey, 32), - LargeImageText = Truncate(largeImageText, 128), - } - }; + var startTimestamp = Timestamps.Now; + + _defaultPresence.State = Truncate(state, 128); + _defaultPresence.Assets.LargeImageKey = Truncate(largeImageKey, 32); + _defaultPresence.Assets.LargeImageText = Truncate(largeImageText, 128); + _defaultPresence.Timestamps = startTimestamp; + + _activePresence.Timestamps = startTimestamp; + _configurationManager.OnValueChanged(CVars.DiscordEnabled, newValue => { if (!_initialized) @@ -90,7 +98,7 @@ private void _start() _client.Initialize(); // == Set the presence - _client.SetPresence(_activePresence ?? _defaultPresence); + _client.SetPresence(_active ? _activePresence : _defaultPresence); } private void _stop() @@ -113,17 +121,13 @@ public void Update(string serverName, string username, string maxUsers, string u var smallImageKey = _configurationManager.GetCVar(CVars.DiscordRichPresenceSecondIconId); // Strings are limited by byte count. See the setters in RichPresence. Hence the truncate calls. - _activePresence = new RichPresence - { - Details = Truncate(details, 128), - State = Truncate(state, 128), - Assets = new Assets - { - LargeImageKey = Truncate(largeImageKey, 32), - LargeImageText = Truncate(largeImageText, 128), - SmallImageKey = Truncate(smallImageKey, 32) - } - }; + _activePresence.Details = Truncate(details, 128); + _activePresence.State = Truncate(state, 128); + _activePresence.Assets.LargeImageKey = Truncate(largeImageKey, 32); + _activePresence.Assets.LargeImageText = Truncate(largeImageText, 128); + _activePresence.Assets.SmallImageKey = Truncate(smallImageKey, 32); + + _active = true; _client.SetPresence(_activePresence); } catch (Exception ex) @@ -159,7 +163,7 @@ private string Truncate(string value, int bytes, string postfix, Encoding encodi public void ClearPresence() { - _activePresence = null; + _active = false; _client?.SetPresence(_defaultPresence); } diff --git a/Robust.Client/ViewVariables/ClientViewVariablesManager.cs b/Robust.Client/ViewVariables/ClientViewVariablesManager.cs index a4aecf4df..006759ea1 100644 --- a/Robust.Client/ViewVariables/ClientViewVariablesManager.cs +++ b/Robust.Client/ViewVariables/ClientViewVariablesManager.cs @@ -123,7 +123,7 @@ public async void OpenVV(ViewVariablesObjectSelector selector) instance = new ViewVariablesInstanceObject(this, _robustSerializer); } - loadingLabel.Dispose(); + loadingLabel.Orphan(); instance.Initialize(window, blob, session); window.OnClose += () => _closeInstance(instance, false); _windows.Add(instance, window); @@ -205,7 +205,7 @@ private void _closeInstance(ViewVariablesInstance instance, bool closeWindow) if (closeWindow) { - window.Dispose(); + window.Close(); } _windows.Remove(instance); diff --git a/Robust.Client/ViewVariables/Editors/VVPropEditorIPrototype.cs b/Robust.Client/ViewVariables/Editors/VVPropEditorIPrototype.cs index 1fd809a09..2996eb7b2 100644 --- a/Robust.Client/ViewVariables/Editors/VVPropEditorIPrototype.cs +++ b/Robust.Client/ViewVariables/Editors/VVPropEditorIPrototype.cs @@ -61,7 +61,7 @@ protected override Control MakeUI(object? value) private async void OnListButtonPressed(BaseButton.ButtonEventArgs obj) { - _addWindow?.Dispose(); + _addWindow?.Close(); if (_selector == null) { @@ -110,7 +110,7 @@ private async Task ServerSideWindowList() private void OnAddButtonPressed(ViewVariablesAddWindow.AddButtonPressedEventArgs obj) { _lineEdit.Text = obj.Entry; - _addWindow?.Dispose(); + _addWindow?.Close(); SetNewValue(obj.Entry); } diff --git a/Robust.Client/ViewVariables/Instances/ViewVariablesInstanceEntity.cs b/Robust.Client/ViewVariables/Instances/ViewVariablesInstanceEntity.cs index 4bbfe756e..99286fed4 100644 --- a/Robust.Client/ViewVariables/Instances/ViewVariablesInstanceEntity.cs +++ b/Robust.Client/ViewVariables/Instances/ViewVariablesInstanceEntity.cs @@ -369,7 +369,7 @@ private void OnServerComponentsSearchBarChanged(LineEditEventArgs args) private void OnClientComponentsAddButtonPressed(BaseButton.ButtonEventArgs _) { - _addComponentWindow?.Dispose(); + _addComponentWindow?.Close(); _addComponentWindow = new ViewVariablesAddWindow(GetValidComponentsForAdding(), Loc.GetString("view-variable-instance-entity-add-window-client-components")); _addComponentWindow.AddButtonPressed += TryAdd; @@ -380,7 +380,7 @@ private void OnClientComponentsAddButtonPressed(BaseButton.ButtonEventArgs _) private async void OnServerComponentsAddButtonPressed(BaseButton.ButtonEventArgs _) { - _addComponentWindow?.Dispose(); + _addComponentWindow?.Close(); if (_entitySession == null) return; diff --git a/Robust.Roslyn.Shared/Diagnostics.cs b/Robust.Roslyn.Shared/Diagnostics.cs index 03b44dab2..711cee007 100644 --- a/Robust.Roslyn.Shared/Diagnostics.cs +++ b/Robust.Roslyn.Shared/Diagnostics.cs @@ -61,6 +61,7 @@ public static class Diagnostics public const string IdInvalidContainingTypeForGeneratedSubscription = "RA0055"; public const string IdNonPartialContainingTypeForGeneratedSubscription = "RA0056"; public const string IdDataFieldOutsideDefinition = "RA0057"; + public const string IdEntitySystemSubscriptionConversionPossible = "RA0058"; public static SuppressionDescriptor MeansImplicitAssignment => new SuppressionDescriptor("RADC1000", "CS0649", "Marked as implicitly assigned."); diff --git a/Robust.Serialization.Generator/Generator.cs b/Robust.Serialization.Generator/Generator.cs index c598bbd2b..77d72f873 100644 --- a/Robust.Serialization.Generator/Generator.cs +++ b/Robust.Serialization.Generator/Generator.cs @@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using Robust.Roslyn.Shared; using static Robust.Roslyn.Shared.DataDefinitionHelper; using static Robust.Serialization.Generator.CustomSerializerType; using static Robust.Serialization.Generator.Types; @@ -36,6 +37,7 @@ public class Generator : IIncrementalGenerator private const string SequenceDataNodeName = "Robust.Shared.Serialization.Markdown.Sequence.SequenceDataNode"; private const string ValueDataNodeName = "Robust.Shared.Serialization.Markdown.Value.ValueDataNode"; private const string EntityUidName = "Robust.Shared.GameObjects.EntityUid"; + private const string ComponentName = "Robust.Shared.GameObjects.Component"; public void Initialize(IncrementalGeneratorInitializationContext initContext) { @@ -193,7 +195,7 @@ private static (string, string)? GenerateForDataDefinition( {{GetCopiers(definition)}} - {{GetReader(definition)}} + {{GetReaders(definition)}} {{GetWriter(definition)}} @@ -612,6 +614,41 @@ private static string GetReadBody(DataDefinition definition, string targetPrefix return builder.ToString(); } + private static string GetReadCompMethod(DataDefinition definition) + { + var inheritsComp = TypeSymbolHelper.Inherits(definition.Type, ComponentName); + if (!inheritsComp) + { + if (!TypeSymbolHelper.ShittyTypeMatch(definition.Type, ComponentName)) return string.Empty; + + return """ + public virtual void ReadComp( + ref Component target, + MappingDataNode mappingDataNode, + ISerializationManager serialization, + SerializationHookContext hookCtx, + ISerializationContext? context) + { + Component.Read(ref target, mappingDataNode, serialization, hookCtx, context); + } + """; + } + + return $$""" + public override void ReadComp( + ref Component target, + MappingDataNode mappingDataNode, + ISerializationManager serialization, + SerializationHookContext hookCtx, + ISerializationContext? context) + { + var cast = ({{definition.GenericTypeName}}) target; + {{definition.GenericTypeName}}.Read(ref cast, mappingDataNode, serialization, hookCtx, context); + target = (Component) cast; + } + """; + } + private static string GetInstantiators(DataDefinition definition) { var builder = new StringBuilder(); @@ -773,32 +810,8 @@ private static void GetCopierMethod( if (!definition.IsDataDefinition(type, out _)) return; - var sameType = definition.Type.Equals(type, SymbolEqualityComparer.Default) && - targetType == definition.GenericTypeName && - targetType != "object"; - var isSealedOrStruct = definition.Type.IsSealed || definition.Type.IsValueType; var isAbstract = definition.Type.IsAbstract; - var isInterface = definition.Type.TypeKind == TypeKind.Interface; - var modifier = (sameType, targetType == "object", isSealedOrStruct, isInterface) switch - { - (true, _, true, _) => string.Empty, - (true, _, false, _) => "virtual ", - (false, true, true, _) => string.Empty, - (false, true, false, _) => "virtual ", - (false, false, _, true) => string.Empty, - (false, false, _, false) => "override ", - }; - - if (!sameType && targetType == "object" && forceOverride) - modifier = "override "; - - if (forceOverride && modifier is "" or "virtual ") - { - if (modifier is "") - modifier += "override "; - else if (modifier == "virtual ") - modifier = "override "; - } + var modifier = GetModifier(definition, type, targetType, forceOverride, out var sameType); builder.AppendLine($""" public {modifier}void Copy( @@ -876,7 +889,43 @@ private static void GetCopierMethod( } } - private static string GetReader(DataDefinition definition) + private static object GetModifier( + DataDefinition definition, + ITypeSymbol type, + string targetType, + bool forceOverride, + out bool sameType) + { + sameType = definition.Type.Equals(type, SymbolEqualityComparer.Default) && + targetType == definition.GenericTypeName && + targetType != "object"; + var isSealedOrStruct = definition.Type.IsSealed || definition.Type.IsValueType; + var isInterface = definition.Type.TypeKind == TypeKind.Interface; + var modifier = (sameType, targetType == "object", isSealedOrStruct, isInterface) switch + { + (true, _, true, _) => string.Empty, + (true, _, false, _) => "virtual ", + (false, true, true, _) => string.Empty, + (false, true, false, _) => "virtual ", + (false, false, _, true) => string.Empty, + (false, false, _, false) => "override ", + }; + + if (!sameType && targetType == "object" && forceOverride) + modifier = "override "; + + if (forceOverride && modifier is "" or "virtual ") + { + if (modifier is "") + modifier += "override "; + else if (modifier == "virtual ") + modifier = "override "; + } + + return modifier; + } + + private static string GetReaders(DataDefinition definition) { string body; if (definition.Type.IsAbstract) @@ -947,6 +996,8 @@ public static void Read( { {{body}} } + + {{GetReadCompMethod(definition)}} """; } diff --git a/Robust.Server.IntegrationTests/GameObjects/Components/Container_Test.cs b/Robust.Server.IntegrationTests/GameObjects/Components/Container_Test.cs index 175551d45..1ab115bf6 100644 --- a/Robust.Server.IntegrationTests/GameObjects/Components/Container_Test.cs +++ b/Robust.Server.IntegrationTests/GameObjects/Components/Container_Test.cs @@ -5,7 +5,6 @@ using Robust.Shared.GameStates; using Robust.Shared.IoC; using Robust.Shared.Map; -using Robust.Shared.Serialization; using Robust.Shared.Timing; using Robust.Shared.Utility; using Robust.UnitTesting.Server; @@ -23,7 +22,7 @@ private static ISimulation SimulationFactory() .NewSimulation() .InitializeInstance(); var map = sim.CreateMap(); - _coords = new EntityCoordinates(map.Item1, default); + _coords = new EntityCoordinates(map.Uid, default); return sim; } @@ -32,16 +31,16 @@ private static ISimulation SimulationFactory() public void TestCreation() { var sim = SimulationFactory(); - var entManager = sim.Resolve(); - var containerSys = sim.Resolve().GetEntitySystem(); - var entity = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var entity = entMan.SpawnAttachedTo(null, _coords); var container = containerSys.MakeContainer(entity, "dummy"); Assert.That(container.ID, Is.EqualTo("dummy")); Assert.That(container.Owner, Is.EqualTo(entity)); - var manager = entManager.GetComponent(entity); + var manager = entMan.GetComponent(entity); Assert.That(container.Manager, Is.EqualTo(manager)); Assert.That(() => containerSys.MakeContainer(entity, "dummy"), Throws.ArgumentException); @@ -61,18 +60,18 @@ public void TestCreation() Assert.That(containerSys.GetContainer(entity, "dummy2", manager), Is.EqualTo(container2)); Assert.That(() => containerSys.GetContainer(entity, "dummy3", manager), Throws.TypeOf()); - entManager.DeleteEntity(entity); + entMan.DeleteEntity(entity); } [Test] public void TestInsertion() { var sim = SimulationFactory(); - var entManager = sim.Resolve(); - var containerSys = sim.Resolve().GetEntitySystem(); - var owner = sim.SpawnEntity(null,_coords); - var inserted = sim.SpawnEntity(null,_coords); - var transform = entManager.GetComponent(inserted); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var owner = entMan.SpawnAttachedTo(null, _coords); + var inserted = entMan.SpawnAttachedTo(null, _coords); + var transform = entMan.GetComponent(inserted); var container = containerSys.MakeContainer(owner, "dummy"); Assert.That(containerSys.Insert(inserted, container), Is.True); @@ -88,7 +87,7 @@ public void TestInsertion() Assert.That(success, Is.False); containerSys.Insert(inserted, container); - entManager.DeleteEntity(owner); + entMan.DeleteEntity(owner); // Make sure inserted was detached. Assert.That(transform.Deleted, Is.True); } @@ -97,12 +96,12 @@ public void TestInsertion() public void TestNestedRemoval() { var sim = SimulationFactory(); - var entManager = sim.Resolve(); - var containerSys = sim.Resolve().GetEntitySystem(); - var owner = sim.SpawnEntity(null,_coords); - var inserted = sim.SpawnEntity(null,_coords); - var transform = entManager.GetComponent(inserted); - var entity = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var owner = entMan.SpawnAttachedTo(null, _coords); + var inserted = entMan.SpawnAttachedTo(null, _coords); + var transform = entMan.GetComponent(inserted); + var entity = entMan.SpawnAttachedTo(null, _coords); var container = containerSys.MakeContainer(owner, "dummy"); Assert.That(containerSys.Insert(inserted, container), Is.True); @@ -110,13 +109,13 @@ public void TestNestedRemoval() var container2 = containerSys.MakeContainer(inserted, "dummy"); Assert.That(containerSys.Insert(entity, container2), Is.True); - Assert.That(entManager.GetComponent(entity).ParentUid, Is.EqualTo(inserted)); + Assert.That(entMan.GetComponent(entity).ParentUid, Is.EqualTo(inserted)); Assert.That(containerSys.Remove(entity, container2), Is.True); Assert.That(container.Contains(entity), Is.True); - Assert.That(entManager.GetComponent(entity).ParentUid, Is.EqualTo(owner)); + Assert.That(entMan.GetComponent(entity).ParentUid, Is.EqualTo(owner)); - entManager.DeleteEntity(owner); + entMan.DeleteEntity(owner); Assert.That(transform.Deleted, Is.True); } @@ -125,12 +124,12 @@ public void TestNestedRemovalWithDenial() { var sim = SimulationFactory(); var entMan = sim.Resolve(); - var containerSys = sim.Resolve().GetEntitySystem(); + var containerSys = entMan.System(); var coordinates =_coords; - var entityOne = sim.SpawnEntity(null, coordinates); - var entityTwo = sim.SpawnEntity(null, coordinates); - var entityThree = sim.SpawnEntity(null, coordinates); - var entityItem = sim.SpawnEntity(null, coordinates); + var entityOne = entMan.SpawnAttachedTo(null, coordinates); + var entityTwo = entMan.SpawnAttachedTo(null, coordinates); + var entityThree = entMan.SpawnAttachedTo(null, coordinates); + var entityItem = entMan.SpawnAttachedTo(null, coordinates); var container = containerSys.MakeContainer(entityOne, "dummy"); var container2 = containerSys.MakeContainer(entityTwo, "dummy"); @@ -157,8 +156,9 @@ public void TestNestedRemovalWithDenial() public void BaseContainer_SelfInsert_False() { var sim = SimulationFactory(); - var containerSys = sim.Resolve().GetEntitySystem(); - var entity = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var entity = entMan.SpawnAttachedTo(null,_coords); var container = containerSys.MakeContainer(entity, "dummy"); Assert.That(containerSys.Insert(entity, container), Is.False); @@ -169,9 +169,10 @@ public void BaseContainer_SelfInsert_False() public void BaseContainer_InsertMap_False() { var sim = SimulationFactory(); - var containerSys = sim.Resolve().GetEntitySystem(); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); var mapEnt = new EntityUid(1); - var entity = sim.SpawnEntity(null,_coords); + var entity = entMan.SpawnAttachedTo(null,_coords); var container = containerSys.MakeContainer(entity, "dummy"); Assert.That(containerSys.Insert(mapEnt, container), Is.False); @@ -187,7 +188,7 @@ public void BaseContainer_InsertGrid_False() var containerSys = entMan.System(); var grid = mapSys.CreateGridEntity(new MapId(1)).Owner; - var entity = sim.SpawnEntity(null,_coords); + var entity = entMan.SpawnAttachedTo(null,_coords); var container = containerSys.MakeContainer(entity, "dummy"); Assert.That(containerSys.Insert(grid, container), Is.False); @@ -198,19 +199,19 @@ public void BaseContainer_InsertGrid_False() public void BaseContainer_Insert_True() { var sim = SimulationFactory(); - var entManager = sim.Resolve(); - var containerSys = sim.Resolve().GetEntitySystem(); - var containerEntity = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var containerEntity = entMan.SpawnAttachedTo(null,_coords); var container = containerSys.MakeContainer(containerEntity, "dummy"); - var insertEntity = sim.SpawnEntity(null,_coords); + var insertEntity = entMan.SpawnAttachedTo(null,_coords); var result = containerSys.Insert(insertEntity, container); Assert.That(result, Is.True); Assert.That(container.ContainedEntities.Count, Is.EqualTo(1)); - Assert.That(entManager.GetComponent(containerEntity).ChildCount, Is.EqualTo(1)); - Assert.That(entManager.GetComponent(containerEntity)._children.First(), Is.EqualTo(insertEntity)); + Assert.That(entMan.GetComponent(containerEntity).ChildCount, Is.EqualTo(1)); + Assert.That(entMan.GetComponent(containerEntity)._children.First(), Is.EqualTo(insertEntity)); result = containerSys.TryGetContainingContainer(insertEntity, out var resultContainerMan); Assert.That(result, Is.True); @@ -221,10 +222,11 @@ public void BaseContainer_Insert_True() public void BaseContainer_RemoveNotAdded_False() { var sim = SimulationFactory(); - var containerSys = sim.Resolve().GetEntitySystem(); - var containerEntity = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var containerEntity = entMan.SpawnAttachedTo(null,_coords); var container = containerSys.MakeContainer(containerEntity, "dummy"); - var insertEntity = sim.SpawnEntity(null,_coords); + var insertEntity = entMan.SpawnAttachedTo(null,_coords); var result = containerSys.Remove(insertEntity, container); @@ -235,38 +237,39 @@ public void BaseContainer_RemoveNotAdded_False() public void BaseContainer_Transfer_True() { var sim = SimulationFactory(); - var containerSys = sim.Resolve().GetEntitySystem(); - var entity1 = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var entity1 = entMan.SpawnAttachedTo(null,_coords); var container1 = containerSys.MakeContainer(entity1, "dummy"); - var entity2 = sim.SpawnEntity(null,_coords); + var entity2 = entMan.SpawnAttachedTo(null,_coords); var container2 = containerSys.MakeContainer(entity2, "dummy"); - var transferEntity = sim.SpawnEntity(null,_coords); + var transferEntity = entMan.SpawnAttachedTo(null,_coords); containerSys.Insert(transferEntity, container1); var result = containerSys.Insert(transferEntity, container2); Assert.That(result, Is.True); - Assert.That(container1.ContainedEntities.Count, Is.EqualTo(0)); - Assert.That(container2.ContainedEntities.Count, Is.EqualTo(1)); + Assert.That(container1.ContainedEntities, Is.Empty); + Assert.That(container2.ContainedEntities, Has.Count.EqualTo(1)); } [Test] public void Container_Serialize() { var sim = SimulationFactory(); - var entManager = sim.Resolve(); - var containerSys = entManager.System(); - var entity = sim.SpawnEntity(null,_coords); + var entMan = sim.Resolve(); + var containerSys = entMan.System(); + var entity = entMan.SpawnAttachedTo(null,_coords); var container = containerSys.MakeContainer(entity, "dummy"); - var childEnt = sim.SpawnEntity(null,_coords); + var childEnt = entMan.SpawnAttachedTo(null,_coords); container.OccludesLight = true; container.ShowContents = true; containerSys.Insert(childEnt, container); - var containerMan = entManager.GetComponent(entity); + var containerMan = entMan.GetComponent(entity); var getState = new ComponentGetState(); - entManager.EventBus.RaiseComponentEvent(entity, containerMan, ref getState); + entMan.EventBus.RaiseComponentEvent(entity, containerMan, ref getState); var state = (ContainerManagerComponent.ContainerManagerComponentState)getState.State!; Assert.That(state.Containers, Has.Count.EqualTo(1)); @@ -274,11 +277,10 @@ public void Container_Serialize() Assert.That(state.Containers.Keys.First(), Is.EqualTo("dummy")); Assert.That(cont.OccludesLight, Is.True); Assert.That(cont.ShowContents, Is.True); - Assert.That(cont.ContainedEntities.Count, Is.EqualTo(1)); - Assert.That(cont.ContainedEntities[0], Is.EqualTo(entManager.GetNetEntity(childEnt))); + Assert.That(cont.ContainedEntities, Has.Length.EqualTo(1)); + Assert.That(cont.ContainedEntities[0], Is.EqualTo(entMan.GetNetEntity(childEnt))); } - [SerializedType(nameof(ContainerOnlyContainer))] private sealed partial class ContainerOnlyContainer : BaseContainer { /// diff --git a/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs b/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs index b33ebb219..0a6777009 100644 --- a/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs +++ b/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs @@ -260,6 +260,33 @@ public void GridDeletionDeletesRelevantChunkEntities() Assert.That(entMan.Deleted(chunk.Owner), Is.True); } + /// + /// Ensures grid-rooted chunk entities follow map pause state even though they live in nullspace. + /// + [Test] + public void GridRootedChunkEntityFollowsMapPause() + { + var sim = Simulation(); + var entMan = sim.Resolve(); + var maps = entMan.System(); + var chunks = entMan.System(); + + var map = maps.CreateMap(); + var grid = maps.CreateGridEntity(map); + + maps.SetPaused(map, true); + + var chunk = chunks.GetOrCreateChunk(grid, Vector2i.Zero); + + Assert.That(entMan.GetComponent(chunk.Owner).EntityPaused, Is.True); + + maps.SetPaused(map, false); + + Assert.That(entMan.GetComponent(chunk.Owner).EntityPaused, Is.False); + + entMan.DeleteEntity(map); + } + /// /// Ensures deleting a map deletes any chunk entities rooted on that map. /// diff --git a/Robust.Server.IntegrationTests/Light/LightLevelSystemTests.cs b/Robust.Server.IntegrationTests/Light/LightLevelSystemTests.cs index 2e4d322e3..471d22906 100644 --- a/Robust.Server.IntegrationTests/Light/LightLevelSystemTests.cs +++ b/Robust.Server.IntegrationTests/Light/LightLevelSystemTests.cs @@ -5,14 +5,13 @@ using Robust.Shared; using Robust.Shared.ComponentTrees; using Robust.Shared.Configuration; -using Robust.Shared.Containers; +//using Robust.Shared.Containers; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Light; using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Maths; -using Robust.Shared.Physics; using Robust.Shared.Prototypes; using Robust.UnitTesting.Server; @@ -160,12 +159,12 @@ public void NestedContainerTransferAndRemovalUpdatesOcclusion() var sim = NewSimulation(false); var map = sim.CreateMap(); var entMan = sim.Resolve(); - var containers = Sys(sim); + var containers = entMan.System(); var lightUid = AddLight(sim, map.MapId, Vector2.Zero, castShadows: false); var light = entMan.GetComponent(lightUid); - var outerA = sim.SpawnEntity(null, new MapCoordinates(Vector2.Zero, map.MapId)); - var outerB = sim.SpawnEntity(null, new MapCoordinates(Vector2.Zero, map.MapId)); - var inner = sim.SpawnEntity(null, new MapCoordinates(Vector2.Zero, map.MapId)); + var outerA = entMan.Spawn(null, new MapCoordinates(Vector2.Zero, map.MapId)); + var outerB = entMan.Spawn(null, new MapCoordinates(Vector2.Zero, map.MapId)); + var inner = entMan.Spawn(null, new MapCoordinates(Vector2.Zero, map.MapId)); entMan.AddComponent(outerA); entMan.AddComponent(outerB); @@ -192,21 +191,28 @@ public void NestedContainerTransferAndRemovalUpdatesOcclusion() private static EntityUid AddLight(ISimulation sim, MapId mapId, Vector2 position, bool castShadows, float radius = 6f) { - var uid = sim.SpawnEntity(null, new MapCoordinates(position, mapId)); var entMan = sim.Resolve(); + var uid = entMan.Spawn(null, new MapCoordinates(position, mapId)); + var pointLightSys = entMan.System(); var light = entMan.AddComponent(uid); - Sys(sim).SetRadius(uid, radius, light); - Sys(sim).SetCastShadows(uid, castShadows, light); - Sys(sim).SetColor(uid, Color.White, light); - Sys(sim).SetEnergy(uid, 1f, light); + pointLightSys.SetRadius(uid, radius, light); + pointLightSys.SetCastShadows(uid, castShadows, light); + pointLightSys.SetColor(uid, Color.White, light); + pointLightSys.SetEnergy(uid, 1f, light); return uid; } private static void AddOccluder(ISimulation sim, MapId mapId, Vector2 position) { - var uid = sim.SpawnEntity(null, new MapCoordinates(position, mapId)); var entMan = sim.Resolve(); + var uid = entMan.Spawn(null, new MapCoordinates(position, mapId)); var occluder = entMan.AddComponent(uid); - Sys(sim).SetBoundingBox(uid, new Box2(-0.25f, -2f, 0.25f, 2f), occluder); + entMan.System().SetPolygon(uid, + [ + new(-0.25f, -2f), + new(0.25f, -2f), + new(0.25f, 2f), + new(-0.25f, 2f), + ], occluder); } } diff --git a/Robust.Server.Testing/RobustServerSimulation.cs b/Robust.Server.Testing/RobustServerSimulation.cs index 6be136231..6a6beb8f7 100644 --- a/Robust.Server.Testing/RobustServerSimulation.cs +++ b/Robust.Server.Testing/RobustServerSimulation.cs @@ -12,6 +12,7 @@ using Robust.Server.Network.Transfer; using Robust.Server.Physics; using Robust.Server.Player; +using Robust.Server.Physics.Components; using Robust.Server.Prototypes; using Robust.Server.Reflection; using Robust.Server.Replays; @@ -76,8 +77,6 @@ public interface ISimulation /// Adds a new map directly to the map manager. /// (EntityUid Uid, MapId MapId) CreateMap(); - EntityUid SpawnEntity(string? protoId, EntityCoordinates coordinates); - EntityUid SpawnEntity(string? protoId, MapCoordinates coordinates); } /// @@ -90,19 +89,19 @@ public static T System(this ISimulation simulation) where T : IEntitySystem return simulation.Resolve().GetEntitySystem(); } - public static bool HasComp(this ISimulation simulation, EntityUid entity) where T : IComponent + public static bool HasComp(this ISimulation simulation, EntityUid entity, IEntityManager entMan) where T : IComponent { - return simulation.Resolve().HasComponent(entity); + return entMan.HasComponent(entity); } - public static T Comp(this ISimulation simulation, EntityUid entity) where T : IComponent + public static T Comp(this ISimulation simulation, EntityUid entity, IEntityManager entMan) where T : IComponent { - return simulation.Resolve().GetComponent(entity); + return entMan.GetComponent(entity); } - public static TransformComponent Transform(this ISimulation simulation, EntityUid entity) + public static TransformComponent Transform(this ISimulation simulation, EntityUid entity, IEntityManager entMan) { - return simulation.Comp(entity); + return simulation.Comp(entity, entMan); } } @@ -134,18 +133,6 @@ public T Resolve() return (uid, mapId); } - public EntityUid SpawnEntity(string? protoId, EntityCoordinates coordinates) - { - var entMan = Collection.Resolve(); - return entMan.SpawnEntity(protoId, coordinates); - } - - public EntityUid SpawnEntity(string? protoId, MapCoordinates coordinates) - { - var entMan = Collection.Resolve(); - return entMan.SpawnEntity(protoId, coordinates); - } - private RobustServerSimulation() { } public ISimulationFactory RegisterDependencies(DiContainerDelegate factory) @@ -212,31 +199,48 @@ public ISimulation InitializeInstance() AppDomain.CurrentDomain.GetAssemblyByName("Robust.Shared"), AppDomain.CurrentDomain.GetAssemblyByName("Robust.Server"), }); + realReflection.EnsureGetAllTypesCache(); var reflectionManager = new Mock(); reflectionManager - .Setup(x => x.FindTypesWithAttribute()) - .Returns(() => new[] - { - typeof(DataDefinitionAttribute) - }); + .Setup(x => x.FindTypesWithAttribute()) + .Returns(realReflection.FindTypesWithAttribute); reflectionManager - .Setup(x => x.FindTypesWithAttribute(typeof(DataDefinitionAttribute))) - .Returns(() => new[] - { - typeof(EntityPrototype), - typeof(TransformComponent), - typeof(MetaDataComponent) - }); + .Setup(x => x.FindTypesWithAttribute()) + .Returns(realReflection.FindTypesWithAttribute); reflectionManager .Setup(x => x.FindTypesWithAttribute()) - .Returns(() => realReflection.FindTypesWithAttribute()); + .Returns(realReflection.FindTypesWithAttribute); + + reflectionManager + .Setup(x => x.FindTypesWithAttribute()) + .Returns(realReflection.FindTypesWithAttribute); + + reflectionManager + .Setup(x => x.FindTypesWithAttribute()) + .Returns(realReflection.FindTypesWithAttribute); + + reflectionManager + .Setup(x => x.FindTypesWithAttribute()) + .Returns(realReflection.FindTypesWithAttribute); + + reflectionManager + .Setup(x => x.FindTypesWithAttribute()) + .Returns(realReflection.FindTypesWithAttribute); + + reflectionManager + .Setup(x => x.FindTypesWithAttributeSet()) + .Returns(realReflection.FindTypesWithAttributeSet); reflectionManager .Setup(x => x.FindAllTypes()) - .Returns(() => realReflection.FindAllTypes()); + .Returns(realReflection.FindAllTypes); + + reflectionManager + .Setup(x => x.IsAttributeDefined(It.IsAny(), It.IsAny())) + .Returns((Type type1, Type type2) => realReflection.IsAttributeDefined(type1, type2)); container.RegisterInstance(new Mock().Object); container.RegisterInstance(reflectionManager.Object); // tests should not be searching for types @@ -252,6 +256,7 @@ public ISimulation InitializeInstance() container.Register(); container.Register(); container.Register(); + container.Register(); container.Register(); container.Register(); container.Register(); @@ -309,6 +314,7 @@ public ISimulation InitializeInstance() compFactory.RegisterClass(); compFactory.RegisterClass(); compFactory.RegisterClass(); + compFactory.RegisterClass(); compFactory.RegisterClass(); compFactory.RegisterClass(); diff --git a/Robust.Server/BaseServer.cs b/Robust.Server/BaseServer.cs index 65c1d2b22..06da1b7f8 100644 --- a/Robust.Server/BaseServer.cs +++ b/Robust.Server/BaseServer.cs @@ -340,6 +340,8 @@ public bool Start(ServerOptions options, Func? logHandlerFactory = _modLoader.BroadcastRunLevel(ModRunLevel.PreInit); + _refMan.Initialize(); + // HAS to happen after content gets loaded. // Else the content types won't be included. // TODO: solve this properly. @@ -390,7 +392,6 @@ public bool Start(ServerOptions options, Func? logHandlerFactory = // otherwise the prototypes will be cleared _prototype.Initialize(); _prototype.LoadDefaultPrototypes(); - _refMan.Initialize(); IoCManager.Resolve().Initialize(); _consoleHost.Initialize(); diff --git a/Robust.Server/GameStates/PvsSystem.GetStates.cs b/Robust.Server/GameStates/PvsSystem.GetStates.cs index b2247c32c..40b62835a 100644 --- a/Robust.Server/GameStates/PvsSystem.GetStates.cs +++ b/Robust.Server/GameStates/PvsSystem.GetStates.cs @@ -51,7 +51,10 @@ private EntityState GetEntityState(ICommonSession? player, EntityUid entityUid, if (component.SessionSpecific && player != null && !EntityManager.CanGetComponentState(component, player)) continue; - var state = ComponentState(entityUid, component, netId, ref stateEv); + var state = ComponentState(entityUid, component, netId, ref stateEv, out var excludeReplays); + if (excludeReplays && player == null) + continue; + changed.Add(new ComponentChange(netId, state, component.LastModifiedTick)); if (state != null) @@ -68,13 +71,16 @@ private EntityState GetEntityState(ICommonSession? player, EntityUid entityUid, return entState; } - private IComponentState? ComponentState(EntityUid uid, IComponent comp, ushort netId, ref ComponentGetState stateEv) + private IComponentState? ComponentState(EntityUid uid, IComponent comp, ushort netId, ref ComponentGetState stateEv, out bool excludeReplays) { DebugTools.Assert(comp.NetSyncEnabled, $"Attempting to get component state for an un-synced component: {comp.GetType()}"); - stateEv.State = null; - _getStateHandlers![netId]?.Invoke(uid, comp, ref Unsafe.As(ref stateEv)); - var state = stateEv.State; - return state; +// Reset the ComponentGetState data. +stateEv.State = null; +stateEv.ExcludeReplays = false; +_getStateHandlers![netId]?.Invoke(uid, comp, ref Unsafe.As(ref stateEv)); +var state = stateEv.State; + excludeReplays = stateEv.ExcludeReplays; +return state; } /// @@ -98,7 +104,7 @@ private EntityState GetFullEntityState(ICommonSession player, EntityUid entityUi if (component.SessionSpecific && !EntityManager.CanGetComponentState(bus, component, player)) continue; - var state = ComponentState(entityUid, component, netId, ref stateEv); + var state = ComponentState(entityUid, component, netId, ref stateEv, out _); DebugTools.Assert(state is not IComponentDeltaState); changed.Add(new ComponentChange(netId, state, component.LastModifiedTick)); netComps.Add(netId); diff --git a/Robust.Server/Physics/Components/GridSplitNodeComponent.cs b/Robust.Server/Physics/Components/GridSplitNodeComponent.cs new file mode 100644 index 000000000..1708edbb0 --- /dev/null +++ b/Robust.Server/Physics/Components/GridSplitNodeComponent.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Numerics; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.ViewVariables; + +namespace Robust.Server.Physics.Components; + +/// +/// Holds data for grid-split nodes so we can quickly check if a grid should split. +/// +[RegisterComponent] +public sealed partial class GridSplitNodeComponent : Component +{ + [ViewVariables] + public readonly Dictionary Nodes = new(); +} + +public sealed class ChunkNodeGroup +{ + internal MapChunk Chunk = default!; + public HashSet Nodes = new(); +} + +public sealed class ChunkSplitNode +{ + public ChunkNodeGroup Group = default!; + public List Indices { get; } = new(); + public HashSet Neighbors { get; } = new(); + + public int TileCount + { + get + { + var count = 0; + + foreach (var box in Indices) + { + count += box.Width * box.Height; + } + + return count; + } + } + + public void AddIndex(Vector2i index) + { + Indices.Add(new Box2i(index.X, index.Y, index.X + 1, index.Y + 1)); + } + + public void CompactIndices() + { + if (Indices.Count <= 1) + return; + + var tiles = new List(TileCount); + + foreach (var index in GetTileIndices()) + { + tiles.Add(index); + } + + tiles.Sort((a, b) => + { + var y = a.Y.CompareTo(b.Y); + return y != 0 ? y : a.X.CompareTo(b.X); + }); + + Indices.Clear(); + var start = tiles[0]; + var previous = start; + + for (var i = 1; i < tiles.Count; i++) + { + var tile = tiles[i]; + + if (tile.Y == previous.Y && tile.X == previous.X + 1) + { + previous = tile; + continue; + } + + Indices.Add(new Box2i(start.X, start.Y, previous.X + 1, previous.Y + 1)); + start = previous = tile; + } + + Indices.Add(new Box2i(start.X, start.Y, previous.X + 1, previous.Y + 1)); + } + + public bool Contains(Vector2i index) + { + foreach (var box in Indices) + { + if (!box.ContainsTile(index)) + continue; + + return true; + } + + return false; + } + + public IEnumerable GetTileIndices() + { + foreach (var box in Indices) + { + for (var x = box.Left; x < box.Right; x++) + { + for (var y = box.Bottom; y < box.Top; y++) + { + yield return new Vector2i(x, y); + } + } + } + } + + public Vector2 GetCentre() + { + var centre = Vector2.Zero; + var count = 0; + + foreach (var index in GetTileIndices()) + { + centre += index; + count++; + } + + centre /= count; + return centre; + } +} diff --git a/Robust.Server/Physics/GridFixtureSystem.Merging.cs b/Robust.Server/Physics/GridFixtureSystem.Merging.cs index 9881b3211..cff2006ae 100644 --- a/Robust.Server/Physics/GridFixtureSystem.Merging.cs +++ b/Robust.Server/Physics/GridFixtureSystem.Merging.cs @@ -59,35 +59,32 @@ public void Merge( var sw = new Stopwatch(); var tiles = new List<(Vector2i Indices, Tile Tile)>(); - var enumerator = _maps.GetAllTilesEnumerator(gridBUid, gridB); - - while (enumerator.MoveNext(out var tileRef)) + foreach (var tileRef in _maps.GetAllTiles(gridBUid, gridB)) { - var offsetTile = Vector2.Transform(new Vector2(tileRef.Value.GridIndices.X, tileRef.Value.GridIndices.Y) + gridA.TileSizeHalfVector, matrix); - tiles.Add((offsetTile.Floored(), tileRef.Value.Tile)); + var offsetTile = Vector2.Transform(new Vector2(tileRef.GridIndices.X, tileRef.GridIndices.Y) + gridA.TileSizeHalfVector, matrix); + tiles.Add((offsetTile.Floored(), tileRef.Tile)); } _maps.SetTiles(gridAUid, gridA, tiles); - enumerator = _maps.GetAllTilesEnumerator(gridBUid, gridB); var rotationDiff = matrix.Rotation(); - while (enumerator.MoveNext(out var tileRef)) + foreach (var tileRef in _maps.GetAllTiles(gridBUid, gridB)) { - var chunkOrigin = SharedMapSystem.GetChunkIndices(tileRef.Value.GridIndices, gridB.ChunkSize); + var chunkOrigin = SharedMapSystem.GetChunkIndices(tileRef.GridIndices, gridB.ChunkSize); if (!_maps.TryGetChunk(gridBUid, gridB, chunkOrigin, out var chunk)) { continue; } - var chunkLocalTile = SharedMapSystem.GetChunkRelative(tileRef.Value.GridIndices, gridB.ChunkSize); + var chunkLocalTile = SharedMapSystem.GetChunkRelative(tileRef.GridIndices, gridB.ChunkSize); var snapgrid = chunk.GetSnapGrid((ushort) chunkLocalTile.X, (ushort) chunkLocalTile.Y); if (snapgrid == null || snapgrid.Count == 0) continue; - var offsetTile = Vector2.Transform(new Vector2(tileRef.Value.GridIndices.X, tileRef.Value.GridIndices.Y) + gridA.TileSizeHalfVector, matrix); + var offsetTile = Vector2.Transform(new Vector2(tileRef.GridIndices.X, tileRef.GridIndices.Y) + gridA.TileSizeHalfVector, matrix); var tileIndex = offsetTile.Floored(); for (var j = snapgrid.Count - 1; j >= 0; j--) @@ -96,7 +93,7 @@ public void Merge( var xform = _xformQuery.GetComponent(ent); _xformSystem.ReAnchor(ent, xform, gridB, gridA, - tileRef.Value.GridIndices, tileIndex, + tileRef.GridIndices, tileIndex, gridBUid, gridAUid, xformB, xformA, rotationDiff); @@ -107,11 +104,9 @@ public void Merge( DebugTools.Assert(snapgrid.Count == 0); } - enumerator = _maps.GetAllTilesEnumerator(gridBUid, gridB); - - while (enumerator.MoveNext(out var tileRef)) + foreach (var tileRef in _maps.GetAllTiles(gridBUid, gridB)) { - var bounds = _lookup.GetLocalBounds(tileRef.Value.GridIndices, gridB.TileSize); + var bounds = _lookup.GetLocalBounds(tileRef.GridIndices, gridB.TileSize); _entSet.Clear(); _lookup.GetLocalEntitiesIntersecting(gridBUid, bounds, _entSet, LookupFlags.All | ~LookupFlags.Contained | LookupFlags.Approximate); diff --git a/Robust.Server/Physics/GridFixtureSystem.cs b/Robust.Server/Physics/GridFixtureSystem.cs index 159a4ca15..72150bbe1 100644 --- a/Robust.Server/Physics/GridFixtureSystem.cs +++ b/Robust.Server/Physics/GridFixtureSystem.cs @@ -1,8 +1,10 @@ +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using Robust.Server.Console; +using Robust.Server.Physics.Components; using Robust.Shared; using Robust.Shared.Collections; using Robust.Shared.Configuration; @@ -31,14 +33,23 @@ public sealed partial class GridFixtureSystem : SharedGridFixtureSystem [Dependency] private SharedMapSystem _maps = default!; [Dependency] private SharedPhysicsSystem _physics = default!; [Dependency] private SharedTransformSystem _xformSystem = default!; - - private readonly Dictionary> _nodes = new(); + [Dependency] private EntityQuery _gridQuery = default!; + [Dependency] private EntityQuery _mapQuery = default!; + [Dependency] private EntityQuery _bodyQuery = default!; + [Dependency] private EntityQuery _splitNodeQuery = default!; + [Dependency] private EntityQuery _xformQuery = default!; /// /// Sessions to receive nodes for debug purposes. /// private readonly HashSet _subscribedSessions = new(); + private readonly Queue _splitFrontier = new(4); + private readonly List> _splitGrids = new(1); + private readonly Dictionary, int> _splitGridSizes = new(); + private readonly HashSet _splitTilePositions = new(); + private Comparison> _splitGridSizeComparison = default!; + /// /// Recursion detection to avoid splitting while handling an existing split /// @@ -48,25 +59,58 @@ public sealed partial class GridFixtureSystem : SharedGridFixtureSystem private HashSet _entSet = new(); - private EntityQuery _gridQuery; - private EntityQuery _bodyQuery; - private EntityQuery _xformQuery; - public override void Initialize() { base.Initialize(); - _gridQuery = GetEntityQuery(); - _bodyQuery = GetEntityQuery(); - _xformQuery = GetEntityQuery(); - SubscribeLocalEvent(OnGridRemoval); + _splitGridSizeComparison = (x, y) => _splitGridSizes[x].CompareTo(_splitGridSizes[y]); + SubscribeNetworkEvent(OnDebugRequest); SubscribeNetworkEvent(OnDebugStopRequest); Subs.CVar(_cfg, CVars.GridSplitting, SetSplitAllowed, true); } - private void SetSplitAllowed(bool value) => SplitAllowed = value; + private void SetSplitAllowed(bool value) + { + if (SplitAllowed == value) + return; + + SplitAllowed = value; + + if (!value) + { + var toRemove = new ValueList(); + var splitQuery = EntityQueryEnumerator(); + while (splitQuery.MoveNext(out var uid, out _)) + { + toRemove.Add(uid); + } + + foreach (var uid in toRemove) + { + RemComp(uid); + } + + return; + } + + var grids = new List>(); + var gridQuery = EntityQueryEnumerator(); + while (gridQuery.MoveNext(out var uid, out var grid)) + { + if (!CanHaveSplitNodes(uid)) + continue; + + grids.Add((uid, grid)); + } + + foreach (var (uid, grid) in grids) + { + GenerateSplitNodes(uid, grid); + CheckSplits(uid); + } + } public override void Shutdown() { @@ -79,8 +123,10 @@ public override void Shutdown() /// internal void EnsureGrid(EntityUid uid) { - if (!_nodes.ContainsKey(uid)) - _nodes[uid] = new Dictionary(); + if (!CanHaveSplitNodes(uid)) + return; + + EnsureComp(uid); } protected override void OnGridInit(GridInitializeEvent ev) @@ -89,9 +135,10 @@ protected override void OnGridInit(GridInitializeEvent ev) base.OnGridInit(ev); } + [SubscribeLocalEvent] private void OnGridRemoval(GridRemovalEvent ev) { - _nodes.Remove(ev.EntityUid); + RemCompDeferred(ev.EntityUid); } #region Debug @@ -117,7 +164,8 @@ public void AddDebugSubscriber(ICommonSession session) { if (!_subscribedSessions.Add(session)) return; - foreach (var (uid, _) in _nodes) + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out _)) { SendNodeDebug(uid); } @@ -137,7 +185,10 @@ private void SendNodeDebug(EntityUid uid) Grid = GetNetEntity(uid), }; - foreach (var (index, group) in _nodes[uid]) + if (!_splitNodeQuery.TryGetComponent(uid, out var splitComp)) + return; + + foreach (var (index, group) in splitComp.Nodes) { var list = new List>(); // To avoid double-sending connections. @@ -146,7 +197,7 @@ private void SendNodeDebug(EntityUid uid) foreach (var node in group.Nodes) { conns.Add(node); - list.Add(node.Indices.ToList()); + list.Add(node.GetTileIndices().ToList()); foreach (var neighbor in node.Neighbors) { @@ -174,12 +225,12 @@ private void SendNodeDebug(EntityUid uid) /// public void CheckSplits(EntityUid uid) { - if (!_nodes.TryGetValue(uid, out var nodes)) + if (!_splitNodeQuery.TryGetComponent(uid, out var splitComp)) return; - var dirtyNodes = new HashSet(nodes.Count); + var dirtyNodes = new HashSet(splitComp.Nodes.Count); - foreach (var group in nodes.Values) + foreach (var group in splitComp.Nodes.Values) { foreach (var node in group.Nodes) { @@ -193,11 +244,12 @@ public void CheckSplits(EntityUid uid) /// /// Check for splits on the specified nodes. /// - private void CheckSplits(EntityUid uid, HashSet dirtyNodes) + private void CheckSplits(EntityUid uid, HashSet dirtyNodes, MapGridComponent? grid = null) { - // TODO: We already have mapgrid elsewhere if (_isSplitting || !SplitAllowed || - !TryComp(uid, out var grid) || + !CanHaveSplitNodes(uid) || + !_gridQuery.Resolve(uid, ref grid, false) || + !_splitNodeQuery.TryGetComponent(uid, out var splitComp) || !grid.CanSplit) { return; @@ -205,8 +257,8 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) _isSplitting = true; Log.Debug($"Started split check for {ToPrettyString(uid)}"); - var splitFrontier = new Queue(4); - var grids = new List>(1); + _splitFrontier.Clear(); + _splitGrids.Clear(); while (dirtyNodes.Count > 0) { @@ -214,13 +266,13 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) originEnumerator.MoveNext(); var origin = originEnumerator.Current; originEnumerator.Dispose(); - splitFrontier.Enqueue(origin); + _splitFrontier.Enqueue(origin); var foundSplits = new HashSet { origin }; - while (splitFrontier.TryDequeue(out var split)) + while (_splitFrontier.TryDequeue(out var split)) { dirtyNodes.Remove(split); @@ -228,33 +280,40 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) { if (!foundSplits.Add(neighbor)) continue; - splitFrontier.Enqueue(neighbor); + _splitFrontier.Enqueue(neighbor); } } - grids.Add(foundSplits); + _splitGrids.Add(foundSplits); } - var oldGrid = Comp(uid); + var grids = _splitGrids; + var oldGrid = grid; var oldGridUid = uid; // Split time if (grids.Count > 1) { - Log.Info($"Splitting {ToPrettyString(uid)} into {grids.Count} grids."); + Log.Debug($"Splitting {ToPrettyString(uid)} into {grids.Count} grids."); var sw = new Stopwatch(); sw.Start(); // We'll leave the biggest group as the original grid // anything smaller gets split off. - grids.Sort((x, y) => - x.Sum(o => o.Indices.Count) - .CompareTo(y.Sum(o => o.Indices.Count))); + _splitGridSizes.Clear(); + foreach (var sizeGroup in grids) + { + var tileCount = 0; + foreach (var sizeNode in sizeGroup) + tileCount += sizeNode.TileCount; + _splitGridSizes[sizeGroup] = tileCount; + } + grids.Sort(_splitGridSizeComparison); var oldGridXform = _xformQuery.GetComponent(oldGridUid); var (gridPos, gridRot) = _xformSystem.GetWorldPositionRotation(oldGridXform); var mapBody = _bodyQuery.GetComponent(oldGridUid); - var oldGridComp = _gridQuery.GetComponent(oldGridUid); + var oldGridComp = grid; var newGrids = new EntityUid[grids.Count - 1]; var mapId = oldGridXform.MapID; @@ -264,26 +323,33 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) var newGrid = _maps.CreateGridEntity(mapId); var newGridUid = newGrid.Owner; var newGridXform = _xformQuery.GetComponent(newGridUid); + EnsureComp(newGridUid); newGrids[i] = newGridUid; - // Keep same origin / velocity etc; this makes updating a lot faster and easier. - _xformSystem.SetWorldPositionRotation(newGridUid, gridPos, gridRot, newGridXform); + var tileOffset = GetSplitTileOffset(group); + var worldOffset = gridRot.RotateVec(tileOffset * oldGrid.TileSize); + + // Keep the same velocity and preserve world tile positions while moving the new grid origin close to its tiles. + _xformSystem.SetWorldPositionRotation(newGridUid, gridPos + worldOffset, gridRot, newGridXform); var splitBody = _bodyQuery.GetComponent(newGridUid); _physics.SetLinearVelocity(newGridUid, mapBody.LinearVelocity, body: splitBody); _physics.SetAngularVelocity(newGridUid, mapBody.AngularVelocity, body: splitBody); var gridComp = _gridQuery.GetComponent(newGridUid); - var tileData = new List<(Vector2i GridIndices, Tile Tile)>(group.Sum(o => o.Indices.Count)); + var tileData = new List<(Vector2i GridIndices, Tile Tile)>(_splitGridSizes[group]); + var oldTileData = new List<(Vector2i GridIndices, Tile Tile)>(tileData.Capacity); // Gather all tiles up front and set once to minimise fixture change events foreach (var node in group) { var offset = node.Group.Chunk.Indices * node.Group.Chunk.ChunkSize; - foreach (var index in node.Indices) + foreach (var index in node.GetTileIndices()) { var tilePos = offset + index; - tileData.Add((tilePos, _maps.GetTileRef(oldGridUid, oldGrid, tilePos).Tile)); + var tile = _maps.GetTileRef(oldGridUid, oldGrid, tilePos).Tile; + tileData.Add((tilePos - tileOffset, tile)); + oldTileData.Add((tilePos, Tile.Empty)); } } @@ -295,7 +361,7 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) { var offset = node.Group.Chunk.Indices * node.Group.Chunk.ChunkSize; - foreach (var tile in node.Indices) + foreach (var tile in node.GetTileIndices()) { var tilePos = offset + tile; @@ -309,7 +375,7 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) var xform = _xformQuery.GetComponent(ent); _xformSystem.ReAnchor(ent, xform, oldGridComp, gridComp, - tilePos, tilePos, + tilePos, tilePos - tileOffset, oldGridUid, newGridUid, oldGridXform, newGridXform, Angle.Zero); @@ -319,41 +385,47 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) // Update lookup ents // Needs to be done before setting old tiles as they will be re-parented to the map. - // TODO: Combine tiles into larger rectangles or something; this is gonna be the killer bit. - foreach (var tile in node.Indices) + // Build tile positions and union bounds so we can query once per node. + _splitTilePositions.Clear(); + var nodeBounds = new Box2(); + var first = true; + + foreach (var tile in node.GetTileIndices()) { var tilePos = offset + tile; - var bounds = _lookup.GetLocalBounds(tilePos, oldGrid.TileSize); + _splitTilePositions.Add(tilePos); + var tileBounds = _lookup.GetLocalBounds(tilePos, oldGrid.TileSize); + nodeBounds = first ? tileBounds : nodeBounds.Union(tileBounds); + first = false; + } - _entSet.Clear(); - _lookup.GetLocalEntitiesIntersecting(oldGridUid, tilePos, _entSet, 0f, LookupFlags.All | ~LookupFlags.Uncontained | LookupFlags.Approximate); + _entSet.Clear(); + _lookup.GetLocalEntitiesIntersecting(oldGridUid, nodeBounds, _entSet, LookupFlags.All | ~LookupFlags.Uncontained | LookupFlags.Approximate); - foreach (var ent in _entSet) - { - // Consider centre of entity position maybe? - var entXform = _xformQuery.GetComponent(ent); + foreach (var ent in _entSet) + { + var entXform = _xformQuery.GetComponent(ent); - if (entXform.ParentUid != oldGridUid || - !bounds.Contains(entXform.LocalPosition)) continue; + if (entXform.ParentUid != oldGridUid) + continue; - _xformSystem.SetParent(ent, entXform, newGridUid, _xformQuery, newGridXform); - } + var entTile = _maps.LocalToTile(oldGridUid, oldGrid, entXform.Coordinates); + + if (!_splitTilePositions.Contains(entTile)) + continue; + + _xformSystem.SetCoordinates(ent, entXform, new EntityCoordinates(newGridUid, entXform.LocalPosition - tileOffset), + oldParent: oldGridXform, newParent: newGridXform); } - _nodes[oldGridUid][node.Group.Chunk.Indices].Nodes.Remove(node); + splitComp.Nodes[node.Group.Chunk.Indices].Nodes.Remove(node); } var eevee = new PostGridSplitEvent(oldGridUid, newGridUid); RaiseLocalEvent(uid, ref eevee, true); - for (var j = 0; j < tileData.Count; j++) - { - var (index, _) = tileData[j]; - tileData[j] = (index, Tile.Empty); - } - // Set tiles on old grid - _maps.SetTiles(oldGridUid, oldGrid, tileData); + _maps.SetTiles(oldGridUid, oldGrid, oldTileData); GenerateSplitNodes(newGridUid, newGrid); SendNodeDebug(newGridUid); } @@ -361,7 +433,7 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) // Cull all of the old chunk nodes. var toRemove = new RemQueue(); - foreach (var group in _nodes[oldGridUid].Values) + foreach (var group in splitComp.Nodes.Values) { if (group.Nodes.Count > 0) continue; toRemove.Add(group); @@ -369,7 +441,7 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) foreach (var group in toRemove) { - _nodes[oldGridUid].Remove(group.Chunk.Indices); + splitComp.Nodes.Remove(group.Chunk.Indices); } // Allow content to react to the grid being split... @@ -379,6 +451,8 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) Log.Debug($"Split {grids.Count} grids in {sw.Elapsed}"); } + _splitGrids.Clear(); + _splitFrontier.Clear(); Log.Debug($"Stopped split check for {ToPrettyString(uid)}"); _isSplitting = false; SendNodeDebug(oldGridUid); @@ -386,10 +460,16 @@ private void CheckSplits(EntityUid uid, HashSet dirtyNodes) private void GenerateSplitNodes(EntityUid gridUid, MapGridComponent grid) { + if (!CanHaveSplitNodes(gridUid)) + return; + + var splitComp = EnsureComp(gridUid); + splitComp.Nodes.Clear(); + foreach (var chunk in _maps.GetMapChunks(gridUid, grid).Values) { var group = CreateNodes(gridUid, grid, chunk); - _nodes[gridUid].Add(chunk.Indices, group); + splitComp.Nodes.Add(chunk.Indices, group); } } @@ -403,13 +483,14 @@ private ChunkNodeGroup CreateNodes(EntityUid gridEuid, MapGridComponent grid, Ma Chunk = chunk, }; - var tiles = new HashSet(chunk.ChunkSize * chunk.ChunkSize); + var tiles = new HashSet(chunk.FilledTiles); for (var x = 0; x < chunk.ChunkSize; x++) { for (var y = 0; y < chunk.ChunkSize; y++) { - tiles.Add(new Vector2i(x, y)); + if (!chunk.GetTile((ushort) x, (ushort) y).IsEmpty) + tiles.Add(new Vector2i(x, y)); } } @@ -446,7 +527,7 @@ private ChunkNodeGroup CreateNodes(EntityUid gridEuid, MapGridComponent grid, Ma var tile = chunk.GetTile((ushort) index.X, (ushort) index.Y); if (tile.IsEmpty) continue; - node.Indices.Add(index); + node.AddIndex(index); var enumerator = new NeighborEnumerator(chunk, index); while (enumerator.MoveNext(out var neighbor)) @@ -459,6 +540,7 @@ private ChunkNodeGroup CreateNodes(EntityUid gridEuid, MapGridComponent grid, Ma if (node.Indices.Count == 0) continue; + node.CompactIndices(); group.Nodes.Add(node); } @@ -468,11 +550,14 @@ private ChunkNodeGroup CreateNodes(EntityUid gridEuid, MapGridComponent grid, Ma // Check each tile for node neighbours on other chunks (not possible for us to have neighbours on the same chunk // as they would already be in our node). - // TODO: This could be better (maybe only check edges of the chunk or something). foreach (var chunkNode in group.Nodes) { - foreach (var index in chunkNode.Indices) + foreach (var index in chunkNode.GetTileIndices()) { + if (index.X != 0 && index.Y != 0 && + index.X != chunk.ChunkSize - 1 && index.Y != chunk.ChunkSize - 1) + continue; + // Check for edge tiles. if (index.X == 0) { @@ -526,8 +611,11 @@ private ChunkNodeGroup CreateNodes(EntityUid gridEuid, MapGridComponent grid, Ma /// /// Checks for grid split with 1 chunk updated. /// - internal override void CheckSplit(EntityUid gridEuid, MapChunk chunk, List rectangles) + internal override void CheckSplit(EntityUid gridEuid, MapChunk chunk, List rectangles, MapGridComponent? grid = null) { + if (!CanHaveSplitNodes(gridEuid)) + return; + HashSet nodes; if (chunk.FilledTiles == 0) @@ -536,17 +624,20 @@ internal override void CheckSplit(EntityUid gridEuid, MapChunk chunk, List /// Checks for grid split with many chunks updated. /// - internal override void CheckSplit(EntityUid gridEuid, Dictionary> mapChunks, List removedChunks) + internal override void CheckSplit(EntityUid gridEuid, Dictionary> mapChunks, List removedChunks, MapGridComponent? grid = null) { + if (!CanHaveSplitNodes(gridEuid)) + return; + var nodes = new HashSet(); foreach (var chunk in removedChunks) @@ -556,7 +647,7 @@ internal override void CheckSplit(EntityUid gridEuid, Dictionary(); @@ -575,7 +666,12 @@ internal override void CheckSplit(EntityUid gridEuid, Dictionary @@ -583,10 +679,10 @@ internal override void CheckSplit(EntityUid gridEuid, Dictionary private HashSet RemoveSplitNode(EntityUid gridEuid, MapChunk chunk) { - var dirtyNodes = new HashSet(); - if (_isSplitting) return new HashSet(); + var dirtyNodes = new HashSet(); + Cleanup(gridEuid, chunk, dirtyNodes); DebugTools.Assert(dirtyNodes.All(o => o.Group.Chunk != chunk)); return dirtyNodes; @@ -595,7 +691,7 @@ private HashSet RemoveSplitNode(EntityUid gridEuid, MapChunk chu /// /// Re-adds this chunk to nodes and dirties its neighbours and itself. /// - private HashSet GenerateSplitNode(EntityUid gridEuid, MapChunk chunk) + private HashSet GenerateSplitNode(EntityUid gridEuid, MapChunk chunk, MapGridComponent? grid = null) { var dirtyNodes = RemoveSplitNode(gridEuid, chunk); @@ -603,9 +699,9 @@ private HashSet GenerateSplitNode(EntityUid gridEuid, MapChunk c DebugTools.Assert(chunk.FilledTiles > 0); - var grid = Comp(gridEuid); + grid ??= _gridQuery.GetComponent(gridEuid); var group = CreateNodes(gridEuid, grid, chunk); - _nodes[gridEuid][chunk.Indices] = group; + EnsureComp(gridEuid).Nodes[chunk.Indices] = group; foreach (var chunkNode in group.Nodes) { @@ -620,7 +716,8 @@ private HashSet GenerateSplitNode(EntityUid gridEuid, MapChunk c /// private bool TryGetNode(EntityUid gridEuid, MapChunk chunk, Vector2i index, [NotNullWhen(true)] out ChunkSplitNode? node) { - if (!_nodes[gridEuid].TryGetValue(chunk.Indices, out var neighborGroup)) + if (!_splitNodeQuery.TryGetComponent(gridEuid, out var splitComp) || + !splitComp.Nodes.TryGetValue(chunk.Indices, out var neighborGroup)) { node = null; return false; @@ -628,7 +725,7 @@ private bool TryGetNode(EntityUid gridEuid, MapChunk chunk, Vector2i index, [Not foreach (var neighborNode in neighborGroup.Nodes) { - if (!neighborNode.Indices.Contains(index)) continue; + if (!neighborNode.Contains(index)) continue; node = neighborNode; return true; } @@ -639,7 +736,11 @@ private bool TryGetNode(EntityUid gridEuid, MapChunk chunk, Vector2i index, [Not private void Cleanup(EntityUid gridEuid, MapChunk chunk, HashSet dirtyNodes) { - if (!_nodes[gridEuid].TryGetValue(chunk.Indices, out var group)) return; + if (!_splitNodeQuery.TryGetComponent(gridEuid, out var splitComp) || + !splitComp.Nodes.TryGetValue(chunk.Indices, out var group)) + { + return; + } foreach (var node in group.Nodes) { @@ -656,33 +757,39 @@ private void Cleanup(EntityUid gridEuid, MapChunk chunk, HashSet node.Neighbors.Clear(); } - _nodes[gridEuid].Remove(chunk.Indices); + splitComp.Nodes.Remove(chunk.Indices); } - internal sealed class ChunkNodeGroup + private static Vector2i GetSplitTileOffset(HashSet nodes) { - internal MapChunk Chunk = default!; - public HashSet Nodes = new(); - } + var min = new Vector2i(int.MaxValue, int.MaxValue); + var max = new Vector2i(int.MinValue, int.MinValue); - internal sealed class ChunkSplitNode - { - public ChunkNodeGroup Group = default!; - public HashSet Indices { get; set; } = new(); - public HashSet Neighbors { get; set; } = new(); - - public Vector2 GetCentre() + foreach (var node in nodes) { - var centre = Vector2.Zero; + var offset = node.Group.Chunk.Indices * node.Group.Chunk.ChunkSize; - foreach (var index in Indices) + foreach (var index in node.GetTileIndices()) { - centre += index; + var tile = offset + index; + min = new Vector2i(Math.Min(min.X, tile.X), Math.Min(min.Y, tile.Y)); + max = new Vector2i(Math.Max(max.X, tile.X), Math.Max(max.Y, tile.Y)); } - - centre /= Indices.Count; - return centre; } + + return new Vector2i( + FloorDiv(min.X + max.X + 1, 2), + FloorDiv(min.Y + max.Y + 1, 2)); + } + + private static int FloorDiv(int value, int divisor) + { + var result = value / divisor; + var remainder = value % divisor; + + return remainder != 0 && (remainder < 0) != (divisor < 0) + ? result - 1 + : result; } private struct NeighborEnumerator @@ -713,7 +820,7 @@ public bool MoveNext([NotNullWhen(true)] out Vector2i? neighbor) neighbor = new Vector2i(_index.X + 1, _index.Y); return true; case 2: - if (_index.Y == _chunk.ChunkSize + 1) break; + if (_index.Y == _chunk.ChunkSize - 1) break; neighbor = new Vector2i(_index.X, _index.Y + 1); return true; case 3: diff --git a/Robust.Server/Placement/PlacementManager.cs b/Robust.Server/Placement/PlacementManager.cs index f4d426f66..06ce09af1 100644 --- a/Robust.Server/Placement/PlacementManager.cs +++ b/Robust.Server/Placement/PlacementManager.cs @@ -147,10 +147,10 @@ public void HandlePlacementRequest(MsgPlacement msg) if (_entityManager.TryGetComponent(gridUid, out var grid)) { var replacementQuery = _entityManager.GetEntityQuery(); - var anc = _maps.GetAnchoredEntitiesEnumerator(gridUid.Value, grid, _maps.LocalToTile(gridUid.Value, grid, coordinates)); + var anc = _maps.GetAnchoredEntities(gridUid.Value, grid, _maps.LocalToTile(gridUid.Value, grid, coordinates)); var toDelete = new ValueList(); - while (anc.MoveNext(out var ent)) + foreach (var ent in anc) { if (!replacementQuery.TryGetComponent(ent, out var repl) || repl.Key != key) @@ -158,7 +158,7 @@ public void HandlePlacementRequest(MsgPlacement msg) continue; } - toDelete.Add(ent.Value); + toDelete.Add(ent); } foreach (var ent in toDelete) @@ -245,10 +245,13 @@ private void HandleEntRemoveReq(MsgPlacement msg) /// private void HandleRectRemoveReq(MsgPlacement msg) { - var start = _entityManager.GetCoordinates(msg.NetCoordinates); - var rectSize = msg.RectSize; + var centerCoords = _xformSystem.ToMapCoordinates(msg.NetCoordinates); + var centerPos = centerCoords.Position; - foreach (var entity in _lookup.GetEntitiesIntersecting(_xformSystem.GetMapId(start), new Box2(start.Position, start.Position + rectSize))) + var box = Box2.CenteredAround(centerPos, msg.RectSize); + var boxRotated = new Box2Rotated(box, msg.RectRotation, centerPos); + + foreach (var entity in _lookup.GetEntitiesIntersecting(centerCoords.MapId, boxRotated)) { if (_entityManager.Deleted(entity) || _entityManager.HasComponent(entity) diff --git a/Robust.Server/Upload/GamePrototypeLoadManager.cs b/Robust.Server/Upload/GamePrototypeLoadManager.cs index e3fabb088..704f00a36 100644 --- a/Robust.Server/Upload/GamePrototypeLoadManager.cs +++ b/Robust.Server/Upload/GamePrototypeLoadManager.cs @@ -29,8 +29,8 @@ public override void Initialize() public override void SendGamePrototype(string prototype) { var msg = new GamePrototypeLoadMessage { PrototypeData = prototype }; - base.LoadPrototypeData(msg); - _netManager.ServerSendToAll(msg); + if (TryLoadPrototypeData(prototype)) + _netManager.ServerSendToAll(msg); } protected override void LoadPrototypeData(GamePrototypeLoadMessage message) @@ -38,9 +38,11 @@ protected override void LoadPrototypeData(GamePrototypeLoadMessage message) var player = _playerManager.GetSessionByChannel(message.MsgChannel); if (_controller.CanCommand(player, "loadprototype")) { - base.LoadPrototypeData(message); - _netManager.ServerSendToAll(message); // everyone load it up! - _sawmill.Info($"Loaded adminbus prototype data from {player.Name}."); + if (TryLoadPrototypeData(message.PrototypeData)) + { + _netManager.ServerSendToAll(message); // everyone load it up! + _sawmill.Info($"Loaded adminbus prototype data from {player.Name}."); + } } else { diff --git a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs index dc891c252..42b99bbc9 100644 --- a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs +++ b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs @@ -50,7 +50,8 @@ public class ComponentNetworkGenerator : ISourceGenerator TypeDeclarationSyntax classSyntax, CSharpCompilation comp, bool raiseAfterAutoHandle, - bool fieldDeltas) + bool fieldDeltas, + bool excludeReplays) { var partialInfo = PartialTypeInfo.FromSymbol(classSymbol, classSyntax); var componentName = classSymbol.Name; @@ -607,7 +608,7 @@ public void ApplyToFullState({stateName} fullState) deltaNetRegister = $@"EntityManager.ComponentFactory.RegisterNetworkedFields<{classSymbol}>({fieldsStr});"; deltaGetState = @$"// Delta state - if (component is IComponentDelta delta) + if (component is IComponentDelta delta && args.FromTick > component.CreationTick) {{ var aspects = EntityManager.GetModifiedAspects(component, args.FromTick); @@ -695,11 +696,24 @@ public void ApplyToFullState({stateName} fullState) }}{eventRaise}"; } + var excludeReplaysStr = string.Empty; + if (excludeReplays) + { + excludeReplaysStr = @" + if (args.ReplayState) + { + args.ExcludeReplays = true; + return; + } +"; + } + var outSb = new StringBuilder(); var stateFieldsText = TrimNewLines(stateFields); var getStateInitText = TrimNewLines(getStateInit); var clientGetStateInitText = TrimNewLines(clientGetStateInit); var cloneMethodText = TrimNewLines(cloneMethod); + var excludeReplaysText = TrimNewLines(excludeReplaysStr); var deltaGetStateText = TrimNewLines(deltaGetState); var clientDeltaGetStateText = TrimNewLines(clientDeltaGetState); var deltaCompFieldsText = TrimNewLines(deltaCompFields); @@ -781,6 +795,12 @@ public void ApplyToFullState({stateName} fullState) outSb.AppendLine($" private void OnGetState(EntityUid uid, {componentName} component, ref ComponentGetState args)"); outSb.AppendLine(" {"); + if (excludeReplaysStr.Length != 0) + { + outSb.AppendLine(IndentFirstLine(excludeReplaysText, 12)); + outSb.AppendLine(); + } + if (deltaGetStateText.Length != 0) { outSb.AppendLine(IndentFirstLine(deltaGetStateText, 12)); @@ -870,14 +890,16 @@ public void Execute(GeneratorExecutionContext context) { var raiseEv = false; var fieldDeltas = false; - if (attribute.ConstructorArguments is [{Value: bool raise}, {Value: bool fields}]) + var excludeReplays = false; + if (attribute.ConstructorArguments is [{Value: bool raise}, {Value: bool fields}, {Value: bool exclude}]) { // Get the afterautohandle bool, which is first constructor arg raiseEv = raise; fieldDeltas = fields; + excludeReplays = exclude; } - var source = GenerateSource(context, classType, classSyntax, comp, raiseEv, fieldDeltas); + var source = GenerateSource(context, classType, classSyntax, comp, raiseEv, fieldDeltas, excludeReplays); // can be null if no members marked with network field, which already has a diagnostic, so // just continue if (source == null) diff --git a/Robust.Shared.EntitySystemSubscriptionsGenerator/EntitySystemSubscriptionGenerator.cs b/Robust.Shared.EntitySystemSubscriptionsGenerator/EntitySystemSubscriptionGenerator.cs index d941b42a8..8eedce813 100644 --- a/Robust.Shared.EntitySystemSubscriptionsGenerator/EntitySystemSubscriptionGenerator.cs +++ b/Robust.Shared.EntitySystemSubscriptionsGenerator/EntitySystemSubscriptionGenerator.cs @@ -77,7 +77,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) productionContext.CancellationToken.ThrowIfCancellationRequested(); var subscriptionMethod = method.Type.ToSubscriptionMethod(); var typeArgs = string.Join(", ", method.TypeArgs); - subscriptionsSyntax.AppendLine($" {subscriptionMethod}<{typeArgs}>({method.MethodName});"); + + var before = method.Before.HasValue ? ("[" + string.Join(", ", method.Before.Value.Select(t => $"typeof({t})")) + "]") : "null"; + var after = method.After.HasValue ? ("[" + string.Join(", ", method.After.Value.Select(t => $"typeof({t})")) + "]") : "null"; + + subscriptionsSyntax.AppendLine($" {subscriptionMethod}<{typeArgs}>({method.MethodName}, {before}, {after});"); } var builder = new StringBuilder(@" @@ -220,11 +224,23 @@ method.Parameters[2].Type is not INamedTypeSymbol eventType || ) { if (annotationName.ToSubscriptionType() is not { } subType || - !AttributeHelper.HasAttribute(method, annotationName, out _) || + !AttributeHelper.HasAttribute(method, annotationName, out var attribute) || parseFunc(method) is not { } parameters) return null; - return new SubscriptionInfo(method.Name, subType, parameters); + var args = attribute.ConstructorArguments; + return new SubscriptionInfo(method.Name, subType, parameters, GetTypes(args[0]), GetTypes(args[1])); + } + + /// + /// Gets an array of type names from the typed constant. + /// + private static ImmutableArray? GetTypes(TypedConstant constant) + { + if (constant.IsNull || constant.Kind != TypedConstantKind.Array) + return null; + + return [.. constant.Values.Select(v => (v.Value as ITypeSymbol)!.ToDisplayString())]; } /// Aggregates all of the s across all the given providers into a single array value @@ -244,5 +260,5 @@ params IncrementalValuesProvider[] more private record struct EntitySystemInfo(PartialTypeInfo Type, EquatableArray Subscriptions); - private record struct SubscriptionInfo(string MethodName, SubscriptionType Type, EquatableArray TypeArgs); + private record struct SubscriptionInfo(string MethodName, SubscriptionType Type, EquatableArray TypeArgs, EquatableArray? Before, EquatableArray? After); } diff --git a/Robust.Shared.IntegrationTests/EntitySerialization/PendingComponentRemovalSerializationTest.cs b/Robust.Shared.IntegrationTests/EntitySerialization/PendingComponentRemovalSerializationTest.cs new file mode 100644 index 000000000..82627d309 --- /dev/null +++ b/Robust.Shared.IntegrationTests/EntitySerialization/PendingComponentRemovalSerializationTest.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Utility; + +namespace Robust.UnitTesting.Shared.EntitySerialization; + +[TestFixture] +internal sealed class PendingComponentRemovalSerializationTest : RobustIntegrationTest +{ + [Test] + public async Task PendingComponentIsNotSerialized() + { + var server = StartServer(); + await server.WaitIdleAsync(); + + var entMan = server.EntMan; + var loader = server.System(); + var mapSystem = server.System(); + var path = new ResPath($"{nameof(PendingComponentIsNotSerialized)}.yml"); + MapId mapId = default; + + await server.WaitPost(() => + { + mapSystem.CreateMap(out mapId); + var uid = entMan.SpawnEntity(null, new MapCoordinates(0, 0, mapId)); + var component = entMan.AddComponent(uid); + + // Deferred removals are processed at the end of the tick. Saving before then used to serialize the + // component even though it had already been queued for removal. + entMan.RemoveComponentDeferred(uid, component); + Assert.That(entMan.Count(), Is.EqualTo(1)); + Assert.That(loader.TrySaveMap(mapId, path), Is.True); + }); + + // Let the deferred removal finish, then remove the original map before loading the saved one. + await server.WaitRunTicks(1); + Assert.That(entMan.Count(), Is.EqualTo(0)); + await server.WaitPost(() => mapSystem.DeleteMap(mapId)); + + // If the pending component was serialized, loading the map will add it back. + await server.WaitPost(() => Assert.That(loader.TryLoadMap(path, out _, out _), Is.True)); + Assert.That(entMan.Count(), Is.EqualTo(0)); + } +} + +[RegisterComponent] +internal sealed partial class PendingRemovalTestComponent : Component; diff --git a/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.OrderedEvents.cs b/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.OrderedEvents.cs index ce7dd52c3..d99fd0dbb 100644 --- a/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.OrderedEvents.cs +++ b/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.OrderedEvents.cs @@ -31,12 +31,13 @@ public void TestDifferentComponentsOrderedSameKeySub() .InitializeInstance(); var map = simulation.CreateMap().MapId; + var entMan = simulation.Resolve(); - var entity = simulation.SpawnEntity(null, new MapCoordinates(0, 0, map)); - simulation.Resolve().AddComponent(entity); + var entity = entMan.Spawn(null, new MapCoordinates(0, 0, map)); + entMan.AddComponent(entity); var foo = new FooEvent(); - simulation.Resolve().EventBus.RaiseLocalEvent(entity, foo, true); + entMan.EventBus.RaiseLocalEvent(entity, foo, true); Assert.That(foo.EventOrder, Is.EquivalentTo(new[]{"Foo", "Transform", "Metadata"}).Or.EquivalentTo(new[]{"Foo", "Metadata", "Transform"})); } diff --git a/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.RefDirectedEvents.cs b/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.RefDirectedEvents.cs index ecf246f52..311c46485 100644 --- a/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.RefDirectedEvents.cs +++ b/Robust.Shared.IntegrationTests/GameObjects/EntityEventBusTests.RefDirectedEvents.cs @@ -1,6 +1,5 @@ using NUnit.Framework; using Robust.Shared.GameObjects; -using Robust.Shared.IoC; using Robust.Shared.Map; using Robust.Shared.Reflection; using Robust.UnitTesting.Server; @@ -20,13 +19,15 @@ public void SubscribeCompRefDirectedEvent() .RegisterEntitySystems(factory => factory.LoadExtraSystemType()) .InitializeInstance(); - var map = simulation.CreateMap().MapId; - var entity = simulation.SpawnEntity(null, new MapCoordinates(0, 0, map)); - IoCManager.Resolve().AddComponent(entity); + var entMan = simulation.Resolve(); + var mapSys = entMan.System(); + mapSys.CreateMap(out var map); + var entity = entMan.Spawn(null, new MapCoordinates(0, 0, map)); + entMan.AddComponent(entity); // Act. var testEvent = new TestStructEvent {TestNumber = 5}; - var eventBus = simulation.Resolve().EventBus; + var eventBus = entMan.EventBus; eventBus.RaiseLocalEvent(entity, ref testEvent, true); // Check that the entity system changed the value correctly @@ -84,15 +85,17 @@ public void SortedDirectedRefEvents() }) .InitializeInstance(); - var map = simulation.CreateMap().MapId; - var entity = simulation.SpawnEntity(null, new MapCoordinates(0, 0, map)); - IoCManager.Resolve().AddComponent(entity); - IoCManager.Resolve().AddComponent(entity); - IoCManager.Resolve().AddComponent(entity); + var entMan = simulation.Resolve(); + var mapSys = entMan.System(); + mapSys.CreateMap(out var map); + var entity = entMan.Spawn(null, new MapCoordinates(0, 0, map)); + entMan.AddComponent(entity); + entMan.AddComponent(entity); + entMan.AddComponent(entity); // Act. var testEvent = new TestStructEvent {TestNumber = 5}; - var eventBus = simulation.Resolve().EventBus; + var eventBus = entMan.EventBus; eventBus.RaiseLocalEvent(entity, ref testEvent, true); // Check that the entity systems changed the value correctly @@ -109,7 +112,7 @@ public override void Initialize() SubscribeLocalEvent(OnA, new[]{typeof(OrderBSystem)}, new[]{typeof(OrderCSystem)}); } - private void OnA(EntityUid uid, OrderAComponent component, ref TestStructEvent args) + private static void OnA(EntityUid uid, OrderAComponent component, ref TestStructEvent args) { // Second handler being ran. Assert.That(args.TestNumber, Is.EqualTo(0)); @@ -127,7 +130,7 @@ public override void Initialize() SubscribeLocalEvent(OnB, null, new []{typeof(OrderASystem)}); } - private void OnB(EntityUid uid, OrderBComponent component, ref TestStructEvent args) + private static void OnB(EntityUid uid, OrderBComponent component, ref TestStructEvent args) { // Last handler being ran. Assert.That(args.TestNumber, Is.EqualTo(10)); @@ -145,7 +148,7 @@ public override void Initialize() SubscribeLocalEvent(OnC); } - private void OnC(EntityUid uid, OrderCComponent component, ref TestStructEvent args) + private static void OnC(EntityUid uid, OrderCComponent component, ref TestStructEvent args) { // First handler being ran. Assert.That(args.TestNumber, Is.EqualTo(5)); @@ -153,9 +156,7 @@ private void OnC(EntityUid uid, OrderCComponent component, ref TestStructEvent a } } - private sealed partial class DummyTwoComponent : Component - { - } + private sealed partial class DummyTwoComponent : Component; [ByRefEvent] private struct TestStructEvent diff --git a/Robust.Shared.IntegrationTests/GameObjects/IEntityManagerTests.cs b/Robust.Shared.IntegrationTests/GameObjects/IEntityManagerTests.cs index d168c7fe1..364472b24 100644 --- a/Robust.Shared.IntegrationTests/GameObjects/IEntityManagerTests.cs +++ b/Robust.Shared.IntegrationTests/GameObjects/IEntityManagerTests.cs @@ -1,8 +1,6 @@ -using System.Numerics; using NUnit.Framework; using Robust.Shared.GameObjects; using Robust.Shared.Map; -using Robust.Shared.Serialization.Manager.Attributes; using Robust.UnitTesting.Server; namespace Robust.UnitTesting.Shared.GameObjects @@ -23,13 +21,14 @@ private static ISimulation SimulationFactory() /// The entity prototype can define field on the TransformComponent, just like any other component. /// [Test] - public void SpawnEntity_PrototypeTransform_Works() + public void Spawn_PrototypeTransform_Works() { var sim = SimulationFactory(); - var map = sim.CreateMap().MapId; var entMan = sim.Resolve(); - var newEnt = entMan.SpawnEntity(null, new MapCoordinates(0, 0, map)); + entMan.System().CreateMap(out var map); + + var newEnt = entMan.Spawn(null, new MapCoordinates(0, 0, map)); Assert.That(newEnt, Is.Not.EqualTo(EntityUid.Invalid)); } @@ -43,7 +42,7 @@ public void ComponentCount_Works() Assert.That(entManager.Count(), Is.EqualTo(0)); - var mapId = sim.CreateMap().MapId; + mapSystem.CreateMap(out var mapId); Assert.That(entManager.Count(), Is.EqualTo(1)); mapSystem.DeleteMap(mapId); Assert.That(entManager.Count(), Is.EqualTo(0)); diff --git a/Robust.Shared.IntegrationTests/GameObjects/Systems/AnchoredSystemTests.cs b/Robust.Shared.IntegrationTests/GameObjects/Systems/AnchoredSystemTests.cs index e4f8e53b3..202f8ec79 100644 --- a/Robust.Shared.IntegrationTests/GameObjects/Systems/AnchoredSystemTests.cs +++ b/Robust.Shared.IntegrationTests/GameObjects/Systems/AnchoredSystemTests.cs @@ -18,15 +18,17 @@ namespace Robust.UnitTesting.Shared.GameObjects.Systems [TestFixture, Parallelizable] internal sealed partial class AnchoredSystemTests { - private const string Prototypes = @" + private const string AnchoredProto = "anchoredEnt"; + + private const string Prototypes = $@" - type: entity name: anchoredEnt - id: anchoredEnt + id: {AnchoredProto} components: - type: Transform anchored: true"; - private static (ISimulation, Entity grid, MapCoordinates, SharedTransformSystem xformSys, SharedMapSystem mapSys) SimulationFactory() + private static (ISimulation, Entity grid, MapCoordinates, SharedTransformSystem xformSys, SharedMapSystem mapSys, IEntityManager entMan) SimulationFactory() { var sim = RobustServerSimulation .NewSimulation() @@ -37,14 +39,16 @@ private static (ISimulation, Entity grid, MapCoordinates, Shar }) .InitializeInstance(); - var mapSystem = sim.System(); + var entManager = sim.Resolve(); + + var mapSystem = entManager.System(); - var testMapId = sim.CreateMap().MapId; + mapSystem.CreateMap(out var testMapId); var coords = new MapCoordinates(new Vector2(7, 7), testMapId); // Add grid 1, as the default grid to anchor things to. var grid = mapSystem.CreateGridEntity(testMapId); - return (sim, grid, coords, sim.System(), mapSystem); + return (sim, grid, coords, entManager.System(), mapSystem, entManager); } // An entity is anchored to the tile it is over on the target grid. @@ -63,18 +67,19 @@ private static (ISimulation, Entity grid, MapCoordinates, Shar [Test] public void OnAnchored_WorldPosition_TileCenter() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); + var (_, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); // can only be anchored to a tile mapSys.SetTile(grid, mapSys.TileIndicesFor(grid, coordinates), new Tile(1)); - var ent1 = sim.SpawnEntity(null, coordinates); // this raises MoveEvent, subscribe after + var ent1 = entMan.Spawn(null, coordinates); // this raises MoveEvent, subscribe after // Act - sim.System().ResetCounters(); + var moveEventTest = entMan.System(); + moveEventTest.ResetCounters(); xformSys.AnchorEntity(ent1); Assert.That(xformSys.GetWorldPosition(ent1), Is.EqualTo(new Vector2(7.5f, 7.5f))); // centered on tile - sim.System().AssertMoved(false); + moveEventTest.AssertMoved(false); } [ComponentProtoName("AnchorOnInit")] @@ -120,13 +125,13 @@ private void OnMove(ref MoveEvent ev) { MoveCounter++; if (FailOnMove) - Assert.Fail($"Move event was raised"); + Assert.Fail("Move event was raised"); } private void OnReparent(ref EntParentChangedMessage ev) { ParentCounter++; if (FailOnMove) - Assert.Fail($"Move event was raised"); + Assert.Fail("Move event was raised"); } public void ResetCounters() @@ -158,24 +163,24 @@ public void OnInitAnchored_AddedToLookup() .RegisterComponents(f => f.RegisterClass()) .InitializeInstance(); - var mapSys = sim.System(); - var entMan = sim.Resolve(); - var mapId = sim.CreateMap().MapId; + var mapSys = entMan.System(); + + mapSys.CreateMap(out var mapId); var grid = mapSys.CreateGridEntity(mapId); var coordinates = new MapCoordinates(new Vector2(7, 7), mapId); var pos = mapSys.TileIndicesFor(grid, coordinates); mapSys.SetTile(grid, pos, new Tile(1)); var ent1 = entMan.SpawnEntity(null, coordinates); - Assert.That(sim.Transform(ent1).Anchored, Is.False); + Assert.That(sim.Transform(ent1, entMan).Anchored, Is.False); Assert.That(!mapSys.GetAnchoredEntities(grid, pos).Any()); entMan.DeleteEntity(ent1); var ent2 = entMan.CreateEntityUninitialized(null, coordinates); entMan.AddComponent(ent2); entMan.InitializeAndStartEntity(ent2); - Assert.That(sim.Transform(ent2).Anchored); + Assert.That(sim.Transform(ent2, entMan).Anchored); Assert.That(mapSys.GetAnchoredEntities(grid, pos).Count(), Is.EqualTo(1)); Assert.That(mapSys.GetAnchoredEntities(grid, pos).Contains(ent2)); } @@ -186,18 +191,18 @@ public void OnInitAnchored_AddedToLookup() [Test] public void OnAnchored_Parent_SetToGrid() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); // can only be anchored to a tile mapSys.SetTile(grid, mapSys.TileIndicesFor(grid, coordinates), new Tile(1)); - var traversal = sim.System(); + var traversal = entMan.System(); traversal.Enabled = false; - var ent1 = sim.SpawnEntity(null, coordinates); // this raises MoveEvent, subscribe after + var ent1 = entMan.Spawn(null, coordinates); // this raises MoveEvent, subscribe after // Act xformSys.AnchorEntity(ent1); - Assert.That(sim.Transform(ent1).ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(sim.Transform(ent1, entMan).ParentUid, Is.EqualTo(grid.Owner)); traversal.Enabled = true; } @@ -207,10 +212,10 @@ public void OnAnchored_Parent_SetToGrid() [Test] public void OnAnchored_EmptyTile_Nop() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1, entMan).Coordinates); mapSys.SetTile(grid, tileIndices, Tile.Empty); // Act @@ -227,20 +232,21 @@ public void OnAnchored_EmptyTile_Nop() [Test] public void OnAnchored_NonEmptyTile_Anchors() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); // Act - sim.Transform(ent1).Anchored = true; + xform.Anchored = true; Assert.That(mapSys.GetAnchoredEntities(grid, tileIndices).First(), Is.EqualTo(ent1)); Assert.That(mapSys.GetTileRef(grid, tileIndices).Tile, Is.Not.EqualTo(Tile.Empty)); - Assert.That(sim.HasComp(ent1), Is.False); + Assert.That(sim.HasComp(ent1, entMan), Is.False); var tempQualifier = grid.Owner; - Assert.That(sim.HasComp(tempQualifier), Is.True); + Assert.That(sim.HasComp(tempQualifier, entMan), Is.True); } /// @@ -251,7 +257,7 @@ public void OnAnchored_NonEmptyTile_Anchors() [Test] public void Anchored_SetPosition_Nop() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); // coordinates are already tile centered to prevent snapping and MoveEvent coordinates = coordinates.Offset(new Vector2(0.5f, 0.5f)); @@ -259,18 +265,20 @@ public void Anchored_SetPosition_Nop() // can only be anchored to a tile mapSys.SetTile(grid, mapSys.TileIndicesFor(grid, coordinates), new Tile(1)); - var ent1 = sim.SpawnEntity(null, coordinates); // this raises MoveEvent, subscribe after - sim.Transform(ent1).Anchored = true; - sim.System().FailOnMove = true; + var ent1 = entMan.Spawn(null, coordinates); // this raises MoveEvent, subscribe after + var xform = sim.Transform(ent1, entMan); + var moveEventTest = entMan.System(); + xform.Anchored = true; + moveEventTest.FailOnMove = true; // Act #pragma warning disable CS0618 // Checking property setters. - sim.Transform(ent1).WorldPosition = new Vector2(99, 99); - sim.Transform(ent1).LocalPosition = new Vector2(99, 99); + xform.WorldPosition = new Vector2(99, 99); + xform.LocalPosition = new Vector2(99, 99); #pragma warning restore CS0618 Assert.That(xformSys.GetMapCoordinates(ent1), Is.EqualTo(coordinates)); - sim.System().FailOnMove = false; + moveEventTest.FailOnMove = false; } /// @@ -279,17 +287,18 @@ public void Anchored_SetPosition_Nop() [Test] public void Anchored_ChangeParent_Unanchors() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coordinates); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coordinates); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); xformSys.AnchorEntity(ent1); // Act xformSys.SetParent(ent1, mapSys.GetMap(coordinates.MapId)); - Assert.That(sim.Transform(ent1).Anchored, Is.False); + Assert.That(xform.Anchored, Is.False); Assert.That(mapSys.GetAnchoredEntities(grid, tileIndices).Count(), Is.EqualTo(0)); Assert.That(mapSys.GetTileRef(grid, tileIndices).Tile, Is.EqualTo(new Tile(1))); } @@ -302,13 +311,13 @@ public void Anchored_ChangeParent_Unanchors() [Test] public void Anchored_SetParentSame_Nop() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = entMan.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); - sim.Transform(ent1).Anchored = true; + xform.Anchored = true; // Act xformSys.SetParent(ent1, grid.Owner); @@ -323,10 +332,11 @@ public void Anchored_SetParentSame_Nop() [Test] public void Anchored_TileToSpace_Unanchors() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); mapSys.SetTile(grid, new Vector2i(100, 100), new Tile(1)); // Prevents the grid from being deleted when the Act happens xformSys.AnchorEntity(ent1); @@ -334,7 +344,7 @@ public void Anchored_TileToSpace_Unanchors() // Act mapSys.SetTile(grid, tileIndices, Tile.Empty); - Assert.That(sim.Transform(ent1).Anchored, Is.False); + Assert.That(xform.Anchored, Is.False); Assert.That(mapSys.GetAnchoredEntities(grid, tileIndices).Count(), Is.EqualTo(0)); Assert.That(mapSys.GetTileRef(grid, tileIndices).Tile, Is.EqualTo(Tile.Empty)); } @@ -349,11 +359,11 @@ public void Anchored_TileToSpace_Unanchors() [Test] public void Anchored_AddToContainer_Unanchors() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); xformSys.AnchorEntity(ent1); @@ -364,7 +374,7 @@ public void Anchored_AddToContainer_Unanchors() var container = containerSys.MakeContainer(grid, "TestContainer", containerMan); containerSys.Insert(ent1, container); - Assert.That(sim.Transform(ent1).Anchored, Is.False); + Assert.That(xform.Anchored, Is.False); Assert.That(mapSys.GetAnchoredEntities(grid, tileIndices).Count(), Is.EqualTo(0)); Assert.That(mapSys.GetTileRef(grid, tileIndices).Tile, Is.EqualTo(new Tile(1))); Assert.That(container.ContainedEntities.Count, Is.EqualTo(1)); @@ -376,11 +386,10 @@ public void Anchored_AddToContainer_Unanchors() [Test] public void Anchored_AddPhysComp_IsStaticBody() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1, entMan).Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); xformSys.AnchorEntity(ent1); @@ -397,14 +406,13 @@ public void Anchored_AddPhysComp_IsStaticBody() [Test] public void OnAnchored_HasPhysicsComp_IsStaticBody() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); - var physSystem = sim.System(); + var (sim, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); + var physSystem = entMan.System(); // can only be anchored to a tile mapSys.SetTile(grid, mapSys.TileIndicesFor(grid, coordinates), new Tile(1)); - var ent1 = entMan.SpawnEntity(null, coordinates); + var ent1 = entMan.Spawn(null, coordinates); var physComp = entMan.AddComponent(ent1); physSystem.SetBodyType(ent1, BodyType.Dynamic, body: physComp); @@ -420,14 +428,14 @@ public void OnAnchored_HasPhysicsComp_IsStaticBody() [Test] public void OnUnanchored_HasPhysicsComp_IsDynamicBody() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); var physComp = entMan.AddComponent(ent1); - sim.Transform(ent1).Anchored = true; + xform.Anchored = true; // Act xformSys.Unanchor(ent1); @@ -441,15 +449,16 @@ public void OnUnanchored_HasPhysicsComp_IsDynamicBody() [Test] public void SpawnAnchored_EmptyTile_Unanchors() { - var (sim, grid, coords, _, mapSys) = SimulationFactory(); + var (sim, grid, coords, _, mapSys, entMan) = SimulationFactory(); // Act - var ent1 = sim.SpawnEntity("anchoredEnt", coords); + var ent1 = entMan.Spawn(AnchoredProto, coords); + var xform = sim.Transform(ent1, entMan); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); Assert.That(mapSys.GetAnchoredEntities(grid, tileIndices).Count(), Is.EqualTo(0)); Assert.That(mapSys.GetTileRef(grid, tileIndices).Tile, Is.EqualTo(Tile.Empty)); - Assert.That(sim.Transform(ent1).Anchored, Is.False); + Assert.That(xform.Anchored, Is.False); } /// @@ -458,11 +467,11 @@ public void SpawnAnchored_EmptyTile_Unanchors() [Test] public void OnAnchored_InContainer_Nop() { - var (sim, grid, coords, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); + var (sim, grid, coords, xformSys, mapSys, entMan) = SimulationFactory(); - var ent1 = sim.SpawnEntity(null, coords); - var tileIndices = mapSys.TileIndicesFor(grid, sim.Transform(ent1).Coordinates); + var ent1 = entMan.Spawn(null, coords); + var xform = sim.Transform(ent1, entMan); + var tileIndices = mapSys.TileIndicesFor(grid, xform.Coordinates); mapSys.SetTile(grid, tileIndices, new Tile(1)); var containerSys = entMan.System(); @@ -473,7 +482,7 @@ public void OnAnchored_InContainer_Nop() // Act xformSys.AnchorEntity(ent1); - Assert.That(sim.Transform(ent1).Anchored, Is.False); + Assert.That(xform.Anchored, Is.False); Assert.That(mapSys.GetAnchoredEntities(grid, tileIndices).Count(), Is.EqualTo(0)); Assert.That(mapSys.GetTileRef(grid, tileIndices).Tile, Is.EqualTo(new Tile(1))); Assert.That(container.ContainedEntities.Count, Is.EqualTo(1)); @@ -485,20 +494,21 @@ public void OnAnchored_InContainer_Nop() [Test] public void Unanchored_Unanchor_Nop() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); + var (sim, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); // can only be anchored to a tile mapSys.SetTile(grid, mapSys.TileIndicesFor(grid, coordinates), new Tile(1)); - var traversal = sim.System(); + var traversal = entMan.System(); traversal.Enabled = false; - var ent1 = sim.SpawnEntity(null, coordinates); // this raises MoveEvent, subscribe after + var ent1 = entMan.Spawn(null, coordinates); // this raises MoveEvent, subscribe after // Act - sim.System().FailOnMove = true; + var moveEventTest = entMan.System(); + moveEventTest.FailOnMove = true; xformSys.Unanchor(ent1); - Assert.That(sim.Transform(ent1).ParentUid, Is.EqualTo(grid.Owner)); - sim.System().FailOnMove = false; + Assert.That(sim.Transform(ent1, entMan).ParentUid, Is.EqualTo(grid.Owner)); + moveEventTest.FailOnMove = false; traversal.Enabled = true; } @@ -508,16 +518,15 @@ public void Unanchored_Unanchor_Nop() [Test] public void Anchored_Unanchored_ParentUnchanged() { - var (sim, grid, coordinates, xformSys, mapSys) = SimulationFactory(); - var entMan = sim.Resolve(); + var (sim, grid, coordinates, xformSys, mapSys, entMan) = SimulationFactory(); // can only be anchored to a tile mapSys.SetTile(grid, mapSys.TileIndicesFor(grid, coordinates), new Tile(1)); - var ent1 = entMan.SpawnEntity("anchoredEnt", mapSys.MapToGrid(grid, coordinates)); + var ent1 = entMan.SpawnAttachedTo(AnchoredProto, mapSys.MapToGrid(grid, coordinates)); xformSys.Unanchor(ent1); - Assert.That(sim.Transform(ent1).ParentUid, Is.EqualTo(grid.Owner)); + Assert.That(sim.Transform(ent1, entMan).ParentUid, Is.EqualTo(grid.Owner)); } } } diff --git a/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs b/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs index 436221773..1ae7eca0a 100644 --- a/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs +++ b/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs @@ -254,6 +254,28 @@ await client.WaitPost(() => Assert.That(cmpClient6?.Field3, Is.EqualTo(103)); // changed from full state }); + await server.WaitPost(() => + { + var ent = server.EntMan.Spawn(null, MapCoordinates.Nullspace); + var noExclude = server.EntMan.EnsureComponent(ent); + var ev = new ComponentGetState(null, GameTick.Zero); + server.EntMan.EventBus.RaiseComponentEvent(ent, noExclude, ref ev); + + // No exclusion comp returns a state for replay states (no player) + Assert.That(ev.ReplayState, Is.True); + Assert.That(ev.State, Is.Not.Null); + Assert.That(ev.ExcludeReplays, Is.False); + + var exclude = server.EntMan.EnsureComponent(ent); + ev = new ComponentGetState(null, GameTick.Zero); + server.EntMan.EventBus.RaiseComponentEvent(ent, exclude, ref ev); + + // Exclusion comp returns null state for replay states (no player) + Assert.That(ev.ReplayState, Is.True); + Assert.That(ev.State, Is.Null); + Assert.That(ev.ExcludeReplays, Is.True); + }); + async Task RunTicks() { for (int i = 0; i < 10; i++) @@ -359,3 +381,17 @@ public sealed partial class AutoNetworkingTestFieldDeltaComponent : Component [DataField, AutoNetworkedField] public int Field3 = 3; } + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class AutoNetworkingTestNoExcludeReplaysComponent : Component +{ + [DataField, AutoNetworkedField] + public int DummyField; +} + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(excludeReplays: true)] +public sealed partial class AutoNetworkingTestExcludeReplaysComponent : Component +{ + [DataField, AutoNetworkedField] + public int DummyField; +} diff --git a/Robust.Shared.IntegrationTests/Map/GridFixtures_Tests.cs b/Robust.Shared.IntegrationTests/Map/GridFixtures_Tests.cs index 34c12cd20..7c9c44581 100644 --- a/Robust.Shared.IntegrationTests/Map/GridFixtures_Tests.cs +++ b/Robust.Shared.IntegrationTests/Map/GridFixtures_Tests.cs @@ -50,6 +50,88 @@ public void TestGridFixtureDeletion() Assert.That(grid.Comp.LocalAABB.Equals(new Box2(0f, 0f, 3f, 1f))); } + [Test] + public void SingleSetTileSameShapeDoesNotRegenerateFixtures() + { + var server = NewFixtureCounterSimulation(); + var map = server.CreateMap(); + var entManager = server.Resolve(); + var mapSystem = entManager.System(); + var grid = mapSystem.CreateGridEntity(map.MapId); + var fixtures = entManager.GetComponent(grid); + var counter = entManager.System(); + + mapSystem.SetTile(grid, Vector2i.Zero, new Tile(1)); + Assert.That(counter.EventCount, Is.EqualTo(1)); + Assert.That(fixtures.Fixtures, Does.ContainKey("grid_chunk-0-0")); + var fixture = fixtures.Fixtures["grid_chunk-0-0"]; + + counter.EventCount = 0; + mapSystem.SetTile(grid, Vector2i.Zero, new Tile(2)); + + Assert.Multiple(() => + { + Assert.That(counter.EventCount, Is.EqualTo(0)); + Assert.That(fixtures.FixtureCount, Is.EqualTo(1)); + Assert.That(fixtures.Fixtures["grid_chunk-0-0"], Is.SameAs(fixture)); + }); + } + + [Test] + public void BulkSetTilesSameShapeDoesNotRegenerateFixtures() + { + var server = NewFixtureCounterSimulation(); + var map = server.CreateMap(); + var entManager = server.Resolve(); + var mapSystem = entManager.System(); + var grid = mapSystem.CreateGridEntity(map.MapId); + var fixtures = entManager.GetComponent(grid); + var counter = entManager.System(); + + mapSystem.SetTiles(grid, new List<(Vector2i GridIndices, Tile Tile)> + { + (Vector2i.Zero, new Tile(1)), + (Vector2i.Right, new Tile(1)), + }); + + Assert.That(counter.EventCount, Is.EqualTo(1)); + Assert.That(fixtures.Fixtures, Does.ContainKey("grid_chunk-0-0")); + var fixture = fixtures.Fixtures["grid_chunk-0-0"]; + + counter.EventCount = 0; + mapSystem.SetTiles(grid, new List<(Vector2i GridIndices, Tile Tile)> + { + (Vector2i.Zero, new Tile(2)), + (Vector2i.Right, new Tile(2)), + }); + + Assert.Multiple(() => + { + Assert.That(counter.EventCount, Is.EqualTo(0)); + Assert.That(fixtures.FixtureCount, Is.EqualTo(1)); + Assert.That(fixtures.Fixtures["grid_chunk-0-0"], Is.SameAs(fixture)); + }); + } + + [Test] + public void FixtureNamesUseTileBounds() + { + var server = RobustServerSimulation.NewSimulation().InitializeInstance(); + var map = server.CreateMap(); + var entManager = server.Resolve(); + var mapSystem = entManager.System(); + var grid = mapSystem.CreateGridEntity(map.MapId); + var fixtures = entManager.GetComponent(grid); + + mapSystem.SetTile(grid, new Vector2i(-1, 2), new Tile(1)); + + Assert.Multiple(() => + { + Assert.That(fixtures.Fixtures, Does.ContainKey("grid_chunk--1-2")); + Assert.That(fixtures.Fixtures.Keys, Does.Not.Contain("grid_chunk--1.01-1.99")); + }); + } + [Test] public async Task TestGridFixtures() { @@ -94,7 +176,29 @@ await server.WaitAssertion(() => Assert.That(manager.FixtureCount, Is.EqualTo(2)); physSystem.SetLinearVelocity(grid, Vector2.One, manager: manager, body: gridBody); - Assert.That(gridBody.LinearVelocity.Length, Is.EqualTo(0f)); + Assert.That(gridBody.LinearVelocity.Length(), Is.EqualTo(0f)); }); } + + private static ISimulation NewFixtureCounterSimulation() + { + return RobustServerSimulation.NewSimulation() + .RegisterEntitySystems(factory => factory.LoadExtraSystemType()) + .InitializeInstance(); + } + + private sealed partial class FixtureChangeCounterSystem : EntitySystem + { + public int EventCount; + + public override void Initialize() + { + SubscribeLocalEvent(OnGridFixtureChange); + } + + private void OnGridFixtureChange(GridFixtureChangeEvent ev) + { + EventCount++; + } + } } diff --git a/Robust.Shared.IntegrationTests/Map/GridSplit_Tests.cs b/Robust.Shared.IntegrationTests/Map/GridSplit_Tests.cs index beca059b3..8ed9845a9 100644 --- a/Robust.Shared.IntegrationTests/Map/GridSplit_Tests.cs +++ b/Robust.Shared.IntegrationTests/Map/GridSplit_Tests.cs @@ -5,7 +5,10 @@ using Robust.Shared.Configuration; using Robust.Shared.GameObjects; using Robust.Shared.Map; +using Robust.Shared.Map.Components; using Robust.Shared.Maths; +using Robust.Shared.Physics.Components; +using Robust.Server.Physics.Components; using Robust.UnitTesting.Server; namespace Robust.UnitTesting.Shared.Map; @@ -76,6 +79,199 @@ public void SimpleSplit() mapSystem.DeleteMap(mapId); } + [Test] + public void CVarDisabledNoSplit() + { + var sim = GetSim(); + var entManager = sim.Resolve(); + var config = sim.Resolve(); + config.SetCVar(CVars.GridSplitting, false); + + var mapSystem = sim.Resolve().System(); + var mapId = sim.CreateMap().MapId; + var gridEnt = mapSystem.CreateGridEntity(mapId); + + for (var x = 0; x < 3; x++) + { + mapSystem.SetTile(gridEnt, new Vector2i(x, 0), new Tile(1)); + } + + mapSystem.SetTile(gridEnt, new Vector2i(1, 0), Tile.Empty); + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(1)); + Assert.That(entManager.HasComponent(gridEnt.Owner), Is.False); + + config.SetCVar(CVars.GridSplitting, true); + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(2)); + + mapSystem.DeleteMap(mapId); + } + + [Test] + public void CVarEnableDisableRebuildsSplitNodes() + { + var sim = GetSim(); + var entManager = sim.Resolve(); + var config = sim.Resolve(); + var mapSystem = entManager.System(); + var mapId = sim.CreateMap().MapId; + var gridEnt = mapSystem.CreateGridEntity(mapId); + + Assert.That(entManager.HasComponent(gridEnt.Owner), Is.True); + + config.SetCVar(CVars.GridSplitting, false); + Assert.That(entManager.HasComponent(gridEnt.Owner), Is.False); + + for (var x = 0; x < 3; x++) + { + mapSystem.SetTile(gridEnt, new Vector2i(x, 0), new Tile(1)); + } + + mapSystem.SetTile(gridEnt, new Vector2i(1, 0), Tile.Empty); + + Assert.Multiple(() => + { + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(1)); + Assert.That(entManager.HasComponent(gridEnt.Owner), Is.False); + }); + + config.SetCVar(CVars.GridSplitting, true); + + var splitGrids = mapSystem.GetAllGrids(mapId).ToArray(); + Assert.Multiple(() => + { + Assert.That(splitGrids, Has.Length.EqualTo(2)); + + foreach (var grid in splitGrids) + { + Assert.That(entManager.HasComponent(grid.Owner), Is.True); + } + }); + + config.SetCVar(CVars.GridSplitting, false); + + foreach (var grid in splitGrids) + { + Assert.That(entManager.HasComponent(grid.Owner), Is.False); + } + + mapSystem.DeleteMap(mapId); + } + + [Test] + public void MapComponentGridDoesNotSplit() + { + var sim = GetSim(); + var entManager = sim.Resolve(); + var mapSystem = entManager.System(); + var map = sim.CreateMap(); + var mapGrid = entManager.AddComponent(map.Uid); + var mapGridEnt = new Entity(map.Uid, mapGrid); + + for (var x = 0; x < 3; x++) + { + mapSystem.SetTile(mapGridEnt, new Vector2i(x, 0), new Tile(1)); + } + + mapSystem.SetTile(mapGridEnt, new Vector2i(1, 0), Tile.Empty); + + Assert.Multiple(() => + { + Assert.That(mapSystem.GetAllGrids(map.MapId).Count(), Is.EqualTo(1)); + Assert.That(entManager.HasComponent(map.Uid), Is.False); + }); + + mapSystem.DeleteMap(map.MapId); + } + + [Test] + public void SplitAcrossChunks() + { + var sim = GetSim(); + var mapSystem = sim.Resolve().System(); + var mapId = sim.CreateMap().MapId; + var gridEnt = mapSystem.CreateGridEntity(mapId); + var chunkSize = gridEnt.Comp.ChunkSize; + + mapSystem.SetTile(gridEnt, new Vector2i(chunkSize - 1, 0), new Tile(1)); + mapSystem.SetTile(gridEnt, new Vector2i(chunkSize, 0), new Tile(1)); + mapSystem.SetTile(gridEnt, new Vector2i(chunkSize + 1, 0), new Tile(1)); + + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(1)); + + mapSystem.SetTile(gridEnt, new Vector2i(chunkSize, 0), Tile.Empty); + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(2)); + + mapSystem.DeleteMap(mapId); + } + + [Test] + public void FourWaySplit() + { + var sim = GetSim(); + var mapSystem = sim.Resolve().System(); + var mapId = sim.CreateMap().MapId; + var gridEnt = mapSystem.CreateGridEntity(mapId); + var center = new Vector2i(1, 1); + + mapSystem.SetTile(gridEnt, center, new Tile(1)); + mapSystem.SetTile(gridEnt, center + new Vector2i(0, 1), new Tile(1)); + mapSystem.SetTile(gridEnt, center + new Vector2i(1, 0), new Tile(1)); + mapSystem.SetTile(gridEnt, center + new Vector2i(0, -1), new Tile(1)); + mapSystem.SetTile(gridEnt, center + new Vector2i(-1, 0), new Tile(1)); + + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(1)); + + mapSystem.SetTile(gridEnt, center, Tile.Empty); + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(4)); + + foreach (var grid in mapSystem.GetAllGrids(mapId)) + { + Assert.That(mapSystem.GetAllTiles(grid.Owner, grid.Comp).Count(), Is.EqualTo(1)); + } + + mapSystem.DeleteMap(mapId); + } + + [Test] + public void SplitReCentersNewGridTiles() + { + var sim = GetSim(); + var entManager = sim.Resolve(); + var mapSystem = entManager.System(); + var xformSystem = entManager.System(); + var mapId = sim.CreateMap().MapId; + var gridEnt = mapSystem.CreateGridEntity(mapId); + var oldGrid = gridEnt.Owner; + var oldGridXform = entManager.GetComponent(oldGrid); + var oldGridPos = xformSystem.GetWorldPosition(oldGridXform); + var splitTile = new Vector2i(1000, 0); + var removedTile = new Vector2i(1001, 0); + var retainedTile = new Vector2i(1002, 0); + var splitTileWorldPos = oldGridPos + splitTile; + + mapSystem.SetTile(gridEnt, splitTile, new Tile(1)); + mapSystem.SetTile(gridEnt, removedTile, new Tile(1)); + mapSystem.SetTile(gridEnt, retainedTile, new Tile(1)); + + mapSystem.SetTile(gridEnt, removedTile, Tile.Empty); + Assert.That(mapSystem.GetAllGrids(mapId).Count(), Is.EqualTo(2)); + + var newGrid = mapSystem.GetAllGrids(mapId).Single(x => x.Owner != oldGrid); + var newGridTiles = mapSystem.GetAllTiles(newGrid.Owner, newGrid.Comp).ToArray(); + var newGridXform = entManager.GetComponent(newGrid.Owner); + var newGridBody = entManager.GetComponent(newGrid.Owner); + + Assert.Multiple(() => + { + Assert.That(newGridTiles, Has.Length.EqualTo(1)); + Assert.That(newGridTiles[0].GridIndices, Is.EqualTo(Vector2i.Zero)); + Assert.That(Vector2.Distance(xformSystem.GetWorldPosition(newGridXform), splitTileWorldPos), Is.LessThan(0.001f)); + Assert.That(newGridBody.LocalCenter.Length(), Is.LessThan(2f)); + }); + + mapSystem.DeleteMap(mapId); + } + [Test] public void DonutSplit() { diff --git a/Robust.Shared.IntegrationTests/Physics/Fixtures_Test.cs b/Robust.Shared.IntegrationTests/Physics/Fixtures_Test.cs index 6bb075a7d..18d6bb9d3 100644 --- a/Robust.Shared.IntegrationTests/Physics/Fixtures_Test.cs +++ b/Robust.Shared.IntegrationTests/Physics/Fixtures_Test.cs @@ -2,7 +2,6 @@ using NUnit.Framework; using Robust.Shared.GameObjects; using Robust.Shared.Map; -using Robust.Shared.Maths; using Robust.Shared.Physics; using Robust.Shared.Physics.Collision.Shapes; using Robust.Shared.Physics.Components; @@ -23,13 +22,12 @@ public void SetDensity() var sim = RobustServerSimulation.NewSimulation().InitializeInstance(); var entManager = sim.Resolve(); - var sysManager = sim.Resolve(); - var fixturesSystem = sysManager.GetEntitySystem(); - var physicsSystem = sysManager.GetEntitySystem(); - var mapSystem = sysManager.GetEntitySystem(); - var map = sim.CreateMap().MapId; + var fixturesSystem =entManager.System(); + var physicsSystem = entManager.System(); + var mapSystem = entManager.System(); + mapSystem.CreateMap(out var map); - var ent = sim.SpawnEntity(null, new MapCoordinates(Vector2.Zero, map)); + var ent = entManager.Spawn(null, new MapCoordinates(Vector2.Zero, map)); var body = entManager.AddComponent(ent); physicsSystem.SetBodyType(ent, BodyType.Dynamic, body: body); var fixture = new Fixture(); diff --git a/Robust.Shared.IntegrationTests/Physics/GridDeletion_Test.cs b/Robust.Shared.IntegrationTests/Physics/GridDeletion_Test.cs index 54214964a..8c0344f92 100644 --- a/Robust.Shared.IntegrationTests/Physics/GridDeletion_Test.cs +++ b/Robust.Shared.IntegrationTests/Physics/GridDeletion_Test.cs @@ -43,14 +43,14 @@ await server.WaitAssertion(() => physics = entManager.GetComponent(grid); physSystem.SetBodyType(grid, BodyType.Dynamic, body: physics); physSystem.SetLinearVelocity(grid, new Vector2(50f, 0f), body: physics); - Assert.That(physics.LinearVelocity.Length, NUnit.Framework.Is.GreaterThan(0f)); + Assert.That(physics.LinearVelocity.Length(), NUnit.Framework.Is.GreaterThan(0f)); }); await server.WaitRunTicks(1); await server.WaitAssertion(() => { - Assert.That(physics.LinearVelocity.Length, NUnit.Framework.Is.GreaterThan(0f)); + Assert.That(physics.LinearVelocity.Length(), NUnit.Framework.Is.GreaterThan(0f)); entManager.DeleteEntity(grid); List> grids = []; diff --git a/Robust.Shared.IntegrationTests/Physics/PhysicsComponent_Test.cs b/Robust.Shared.IntegrationTests/Physics/PhysicsComponent_Test.cs index 70d9b64cd..2f4b54096 100644 --- a/Robust.Shared.IntegrationTests/Physics/PhysicsComponent_Test.cs +++ b/Robust.Shared.IntegrationTests/Physics/PhysicsComponent_Test.cs @@ -43,16 +43,16 @@ await server.WaitAssertion(() => // Check regular impulse works physicsSystem.ApplyLinearImpulse(boxEnt, new Vector2(0f, 1f), body: box); - Assert.That(box.LinearVelocity.Length, Is.GreaterThan(0f)); + Assert.That(box.LinearVelocity.Length(), Is.GreaterThan(0f)); // Reset the box physicsSystem.SetLinearVelocity(boxEnt, Vector2.Zero, body: box); - Assert.That(box.LinearVelocity.Length, Is.EqualTo(0f)); + Assert.That(box.LinearVelocity.Length(), Is.EqualTo(0f)); Assert.That(box.AngularVelocity, Is.EqualTo(0f)); // Check the angular impulse is applied from the point physicsSystem.ApplyLinearImpulse(boxEnt, new Vector2(0f, 1f), new Vector2(0.5f, 0f), body: box); - Assert.That(box.LinearVelocity.Length, Is.GreaterThan(0f)); + Assert.That(box.LinearVelocity.Length(), Is.GreaterThan(0f)); Assert.That(box.AngularVelocity, Is.Not.EqualTo(0f)); }); } diff --git a/Robust.Shared.IntegrationTests/Physics/RayCast_Test.cs b/Robust.Shared.IntegrationTests/Physics/RayCast_Test.cs index 9aafdab13..f5a54e8bc 100644 --- a/Robust.Shared.IntegrationTests/Physics/RayCast_Test.cs +++ b/Robust.Shared.IntegrationTests/Physics/RayCast_Test.cs @@ -203,7 +203,7 @@ private void Setup(ISimulation sim, out MapId mapId) var entManager = sim.Resolve(); var mapSystem = entManager.System(); - sim.System().CreateMap(out mapId); + mapSystem.CreateMap(out mapId); var grid = mapSystem.CreateGridEntity(mapId); diff --git a/Robust.Shared.IntegrationTests/Prototypes/PrototypeComponentInterningTest.cs b/Robust.Shared.IntegrationTests/Prototypes/PrototypeComponentInterningTest.cs new file mode 100644 index 000000000..0aec25143 --- /dev/null +++ b/Robust.Shared.IntegrationTests/Prototypes/PrototypeComponentInterningTest.cs @@ -0,0 +1,165 @@ +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.UnitTesting.Server; + +namespace Robust.Shared.IntegrationTests.Prototypes; + +[TestFixture] +internal sealed class PrototypeComponentInterningTest +{ + private const string ComponentName = "PrototypeInterned"; + private const string ParentId = "PrototypeComponentParent"; + private const string ChildAId = "PrototypeComponentChildA"; + private const string ChildBId = "PrototypeComponentChildB"; + private const string OverrideId = "PrototypeComponentOverride"; + private const string EqualAId = "PrototypeComponentEqualA"; + private const string EqualBId = "PrototypeComponentEqualB"; + private const string NullId = "PrototypeComponentNull"; + private const string StringNullId = "PrototypeComponentStringNull"; + + [Test] + public void InternsInheritedAndEquivalentComponents() + { + var sim = RobustServerSimulation + .NewSimulation() + .RegisterComponents(factory => + { + factory.RegisterClass(); + factory.RegisterClass(); + }) + .RegisterPrototypes(factory => factory.LoadString(InitialPrototypes)) + .InitializeInstance(); + + var componentFactory = sim.Resolve(); + var prototypes = sim.Resolve(); + var childA = prototypes.Index(ChildAId); + var childB = prototypes.Index(ChildBId); + var overridden = prototypes.Index(OverrideId); + var equalA = prototypes.Index(EqualAId); + var equalB = prototypes.Index(EqualBId); + var nullPrototype = prototypes.Index(NullId); + var stringNullPrototype = prototypes.Index(StringNullId); + + var childAEntry = childA.Components[ComponentName]; + Assert.Multiple(() => + { + Assert.That(childAEntry.Component, Is.SameAs(childB.Components[ComponentName].Component), "Unchanged inherited components should share their prototype component."); + Assert.That(childAEntry.Component, Is.Not.SameAs(overridden.Components[ComponentName].Component), "An overridden component must retain a distinct prototype component."); + Assert.That(equalA.Components[ComponentName].Component, Is.SameAs(equalB.Components[ComponentName].Component), "Equivalent component mappings should share their prototype component."); + Assert.That(childA.Components["Transform"].Component, Is.SameAs(childB.Components["Transform"].Component)); + Assert.That(childA.Components["MetaData"].Component, Is.SameAs(childB.Components["MetaData"].Component)); + Assert.That(nullPrototype.Components["PrototypeNullableString"].Component, Is.Not.SameAs(stringNullPrototype.Components["PrototypeNullableString"].Component)); + Assert.That(((PrototypeNullableStringComponent) nullPrototype.Components["PrototypeNullableString"].Component).Value, Is.Null); + Assert.That(((PrototypeNullableStringComponent) stringNullPrototype.Components["PrototypeNullableString"].Component).Value, Is.EqualTo("null")); + }); + + var copied = (PrototypeInternedComponent) componentFactory.GetComponent(childAEntry); + copied.Value = 100; + + var copiedTransform = componentFactory.GetComponent(childA.Components["Transform"]); + var copiedMetaData = componentFactory.GetComponent(childA.Components["MetaData"]); + copiedTransform.NetSyncEnabled = false; + copiedMetaData.NetSyncEnabled = false; + + Assert.Multiple(() => + { + Assert.That(copiedTransform, Is.Not.SameAs(childA.Components["Transform"].Component)); + Assert.That(copiedMetaData, Is.Not.SameAs(childA.Components["MetaData"].Component)); + Assert.That(childA.Components["Transform"].Component.NetSyncEnabled, Is.True); + Assert.That(childA.Components["MetaData"].Component.NetSyncEnabled, Is.True); + }); + + Assert.Multiple(() => + { + Assert.That(((PrototypeInternedComponent) childAEntry.Component).Value, Is.EqualTo(42)); + Assert.That(((PrototypeInternedComponent) childB.Components[ComponentName].Component).Value, Is.EqualTo(42)); + }); + + var changed = new Dictionary>(); + prototypes.LoadString(ReloadedParent, true, changed); + prototypes.ReloadPrototypes(changed); + + childA = prototypes.Index(ChildAId); + childB = prototypes.Index(ChildBId); + + // Assumption MAY change at some point + Assert.Multiple(() => + { + Assert.That(childA.Components[ComponentName].Component, Is.SameAs(childB.Components[ComponentName].Component), "The cache should be rebuilt after reload."); + Assert.That(((PrototypeInternedComponent) childA.Components[ComponentName].Component).Value, Is.EqualTo(99)); + }); + } + + private static readonly string InitialPrototypes = $@" +- type: entity + id: {ParentId} + abstract: true + components: + - type: Transform + - type: MetaData + - type: {ComponentName} + value: 42 + +- type: entity + id: {ChildAId} + parent: {ParentId} + +- type: entity + id: {ChildBId} + parent: {ParentId} + +- type: entity + id: {OverrideId} + parent: {ParentId} + components: + - type: {ComponentName} + value: 77 + +- type: entity + id: {EqualAId} + components: + - type: {ComponentName} + value: 12 + +- type: entity + id: {EqualBId} + components: + - type: {ComponentName} + value: 12 + +- type: entity + id: {NullId} + components: + - type: PrototypeNullableString + value: null + +- type: entity + id: {StringNullId} + components: + - type: PrototypeNullableString + value: ""null"" +"; + + private static readonly string ReloadedParent = $@" +- type: entity + id: {ParentId} + abstract: true + components: + - type: {ComponentName} + value: 99 +"; +} + +internal sealed partial class PrototypeInternedComponent : Component +{ + [DataField("value")] + public int Value; +} + +internal sealed partial class PrototypeNullableStringComponent : Component +{ + [DataField("value")] + public string? Value; +} diff --git a/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypePropertySerialization_Test.cs b/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypePropertySerialization_Test.cs index ea1565aaa..7731a8bce 100644 --- a/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypePropertySerialization_Test.cs +++ b/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypePropertySerialization_Test.cs @@ -24,7 +24,7 @@ public void Setup() [Test] public void SerializeTypePropertiesTest() { - ITestType? type = new TestTypeTwo + ITestType? type = new TestType2 { TestPropertyOne = "B", TestPropertyTwo = 10 @@ -48,7 +48,7 @@ public void DeserializeTypePropertiesTest() { var yaml = @" - test: - !type:testtype2 + !type:TestType2 testPropertyOne: A testPropertyTwo: 5 "; @@ -70,18 +70,17 @@ public void DeserializeTypePropertiesTest() var type = serMan.Read(mapping["test"].ToDataNode(), notNullableOverride: true); Assert.That(type, Is.Not.Null); - Assert.That(type, Is.InstanceOf()); + Assert.That(type, Is.InstanceOf()); - var testTypeTwo = (TestTypeTwo) type!; + var testTypeTwo = (TestType2) type!; Assert.That(testTypeTwo.TestPropertyOne, Is.EqualTo("A")); Assert.That(testTypeTwo.TestPropertyTwo, Is.EqualTo(5)); } } - [SerializedType("testtype2")] [DataDefinition] - public sealed partial class TestTypeTwo : ITestType + public sealed partial class TestType2 : ITestType { [DataField("testPropertyOne")] public string? TestPropertyOne { get; set; } diff --git a/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypeSerialization_Test.cs b/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypeSerialization_Test.cs index 667b89504..547352972 100644 --- a/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypeSerialization_Test.cs +++ b/Robust.Shared.IntegrationTests/Serialization/YamlObjectSerializerTests/TypeSerialization_Test.cs @@ -21,7 +21,7 @@ public void Setup() [Test] public void SerializeTypeTest() { - ITestType type = new TestTypeOne(); + ITestType type = new TestType1(); var serMan = IoCManager.Resolve(); var mapping = serMan.WriteValue(type, notNullableOverride: true); @@ -30,7 +30,7 @@ public void SerializeTypeTest() var scalar = (MappingDataNode) mapping; Assert.That(scalar.Children.Count, Is.EqualTo(0)); - Assert.That(scalar.Tag, Is.EqualTo("!type:TestTypeOne")); + Assert.That(scalar.Tag, Is.EqualTo("!type:TestType1")); } [Test] @@ -38,7 +38,7 @@ public void DeserializeTypeTest() { var yaml = @" test: - !type:testtype1 + !type:TestType1 {}"; using var stream = new MemoryStream(); @@ -57,15 +57,14 @@ public void DeserializeTypeTest() var type = serMan.Read(new MappingDataNode(mapping)["test"], notNullableOverride: true); Assert.That(type, Is.Not.Null); - Assert.That(type, Is.InstanceOf()); + Assert.That(type, Is.InstanceOf()); } } public interface ITestType { } - [SerializedType("testtype1")] [DataDefinition] - public sealed partial class TestTypeOne : ITestType + public sealed partial class TestType1 : ITestType { } } diff --git a/Robust.Shared.IntegrationTests/Spawning/EntitySpawnHelpersTest.cs b/Robust.Shared.IntegrationTests/Spawning/EntitySpawnHelpersTest.cs index 91e96400c..17ec6f19e 100644 --- a/Robust.Shared.IntegrationTests/Spawning/EntitySpawnHelpersTest.cs +++ b/Robust.Shared.IntegrationTests/Spawning/EntitySpawnHelpersTest.cs @@ -107,7 +107,6 @@ public void TearDown() /// /// Simple container that can store up to 2 entities. /// - [SerializedType(nameof(TestContainer))] private sealed partial class TestContainer : BaseContainer { private readonly List _ents = new(); diff --git a/Robust.Shared.IntegrationTests/Upload/PrototypeLoadManager_Test.cs b/Robust.Shared.IntegrationTests/Upload/PrototypeLoadManager_Test.cs new file mode 100644 index 000000000..ee0286008 --- /dev/null +++ b/Robust.Shared.IntegrationTests/Upload/PrototypeLoadManager_Test.cs @@ -0,0 +1,120 @@ +using NUnit.Framework; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Upload; +using Robust.Shared.Utility; + +namespace Robust.UnitTesting.Shared.Upload; + +[TestFixture] +internal sealed class PrototypeLoadManager_Test : OurRobustUnitTest +{ + private const string FirstId = "first"; + private const string SecondId = "second"; + + private IPrototypeManager _prototype = default!; + private TestPrototypeLoadManager _prototypeLoad = default!; + + [OneTimeSetUp] + public void Setup() + { + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + _prototype = IoCManager.Resolve(); + _prototype.RegisterKind(typeof(PrototypeUploadTestPrototype)); + + _prototypeLoad = new TestPrototypeLoadManager(); + IoCManager.InjectDependencies(_prototypeLoad); + _prototypeLoad.Initialize(); + } + + [Test] + public void TestBadPrototypeUploadIsDropped() + { + const string badFirstPrototype = @"- type: prototypeUploadTest + id: first + number: not an integer"; + + const string firstPrototype = @"- type: prototypeUploadTest + id: first + number: 5"; + + const string badSecondPrototype = @"- type: prototypeUploadTest + id: second + number: not an integer"; + + const string partiallyBadPrototype = @"- type: prototypeUploadTest + id: first + number: 10 +- type: prototypeUploadTest + id: second + number: not an integer"; + + const string invalidPathPrototype = @"- type: prototypeUploadTest + id: second + path: Textures/not-a-real-upload-test-file.png"; + + const string secondPrototype = @"- type: prototypeUploadTest + id: second"; + + Assert.That(_prototypeLoad.TryLoad(badFirstPrototype), Is.False); + Assert.That(_prototypeLoad.LoadedPrototypes, Is.Empty); + Assert.That(_prototype.HasIndex(FirstId), Is.False); + + Assert.That(_prototypeLoad.TryLoad(firstPrototype), Is.True); + Assert.That(_prototypeLoad.LoadedPrototypes, Has.Count.EqualTo(1)); + Assert.That(_prototype.HasIndex(FirstId), Is.True); + Assert.That(_prototype.Index(FirstId).Number, Is.EqualTo(5)); + + Assert.That(_prototypeLoad.TryLoad(badFirstPrototype), Is.False); + Assert.That(_prototypeLoad.LoadedPrototypes, Has.Count.EqualTo(1)); + Assert.That(_prototype.HasIndex(FirstId), Is.True); + Assert.That(_prototype.Index(FirstId).Number, Is.EqualTo(5)); + + Assert.That(_prototypeLoad.TryLoad(badSecondPrototype), Is.False); + Assert.That(_prototypeLoad.LoadedPrototypes, Has.Count.EqualTo(1)); + Assert.That(_prototype.HasIndex(FirstId), Is.True); + Assert.That(_prototype.HasIndex(SecondId), Is.False); + + Assert.That(_prototypeLoad.TryLoad(partiallyBadPrototype), Is.False); + Assert.That(_prototypeLoad.LoadedPrototypes, Has.Count.EqualTo(1)); + Assert.That(_prototype.Index(FirstId).Number, Is.EqualTo(5)); + Assert.That(_prototype.HasIndex(SecondId), Is.False); + + Assert.That(_prototypeLoad.TryLoad(invalidPathPrototype), Is.False); + Assert.That(_prototypeLoad.LoadedPrototypes, Has.Count.EqualTo(1)); + Assert.That(_prototype.HasIndex(FirstId), Is.True); + Assert.That(_prototype.HasIndex(SecondId), Is.False); + + Assert.That(_prototypeLoad.TryLoad(secondPrototype), Is.True); + Assert.That(_prototypeLoad.LoadedPrototypes, Has.Count.EqualTo(2)); + Assert.That(_prototype.HasIndex(FirstId), Is.True); + Assert.That(_prototype.HasIndex(SecondId), Is.True); + } + + private sealed class TestPrototypeLoadManager : SharedPrototypeLoadManager + { + public bool TryLoad(string prototype) => TryLoadPrototypeData(prototype); + + public override void SendGamePrototype(string prototype) + { + TryLoadPrototypeData(prototype); + } + } +} + +[Prototype] +internal sealed partial class PrototypeUploadTestPrototype : IPrototype +{ + [IdDataField] + public string ID { get; private set; } = default!; + + [DataField] + public int Number { get; private set; } + + [DataField] + public ResPath? Path { get; private set; } +} diff --git a/Robust.Shared.Tests/GameObjects/ComponentDeltaTest.cs b/Robust.Shared.Tests/GameObjects/ComponentDeltaTest.cs new file mode 100644 index 000000000..a7a055dce --- /dev/null +++ b/Robust.Shared.Tests/GameObjects/ComponentDeltaTest.cs @@ -0,0 +1,52 @@ +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Timing; + +namespace Robust.Shared.Tests.GameObjects; + +[TestFixture] +[Parallelizable(ParallelScope.Fixtures | ParallelScope.All)] +[TestOf(typeof(EntityManager))] +internal sealed partial class ComponentDeltaTest +{ + [Test] + public void FieldDirtyOnlyReturnsFieldAspect() + { + var component = new TestDeltaComponent + { + LastUnclassifiedDirty = GameTick.Zero, + LastModifiedFields = + [ + GameTick.Zero, + new GameTick(11), + ], + }; + + Assert.That(EntityManager.GetModifiedAspects(component, new GameTick(10)), Is.EqualTo(1UL << 1)); + } + + [Test] + public void UnclassifiedDirtyOnSameTickAsFieldDirtyForcesFullState() + { + var dirtyTick = new GameTick(11); + var component = new TestDeltaComponent + { + LastUnclassifiedDirty = dirtyTick, + LastModifiedFields = + [ + dirtyTick, + GameTick.Zero, + ], + }; + + var aspects = EntityManager.GetModifiedAspects(component, new GameTick(10)); + + Assert.That(aspects, Is.GreaterThanOrEqualTo(DeltaAspect.Unclassified)); + } + + private sealed partial class TestDeltaComponent : Component, IComponentDelta + { + public GameTick LastUnclassifiedDirty { get; set; } + public GameTick[] LastModifiedFields { get; set; } = []; + } +} diff --git a/Robust.Shared.Tests/IoC/DependencyCollectionTest.cs b/Robust.Shared.Tests/IoC/DependencyCollectionTest.cs index cb2f340e0..686c463d9 100644 --- a/Robust.Shared.Tests/IoC/DependencyCollectionTest.cs +++ b/Robust.Shared.Tests/IoC/DependencyCollectionTest.cs @@ -1,4 +1,7 @@ -using NUnit.Framework; +using System; +using System.Collections; +using System.Reflection; +using NUnit.Framework; using Robust.Shared.IoC; namespace Robust.Shared.Tests.IoC; @@ -23,7 +26,45 @@ public void TestRegisterSameImplementation() var a = deps.Resolve(); var b = deps.Resolve(); - Assert.That(a, Is.EqualTo(b), () => "A & B instances must be reference equal"); + Assert.That(a, Is.SameAs(b), () => "A & B instances must be reference equal"); + } + + [Test] + public void TestRegisterSameImplementationAcrossBuildGraphReusesInstance() + { + var deps = new DependencyCollection(); + deps.Register(); + deps.BuildGraph(); + + var a = deps.Resolve(); + + deps.Register(); + deps.BuildGraph(); + + var b = deps.Resolve(); + + Assert.That(b, Is.SameAs(a), () => "A & B instances must be reference equal across BuildGraph calls"); + } + + [Test] + public void TestResolveDependencyCollectionDefaultsToSelf() + { + var deps = new DependencyCollection(); + + Assert.That(deps.Resolve(), Is.SameAs(deps)); + Assert.That(deps.ResolveType(typeof(IDependencyCollection)), Is.SameAs(deps)); + } + + [Test] + public void TestResolveDependencyCollectionUsesRegisteredOverride() + { + var deps = new DependencyCollection(); + var overrideCollection = new DependencyCollection(); + deps.RegisterInstance(overrideCollection); + deps.BuildGraph(); + + Assert.That(deps.Resolve(), Is.SameAs(overrideCollection)); + Assert.That(deps.ResolveType(typeof(IDependencyCollection)), Is.SameAs(overrideCollection)); } private interface IA diff --git a/Robust.Shared.Tests/Prototypes/MultiRootGraphTest.cs b/Robust.Shared.Tests/Prototypes/MultiRootGraphTest.cs index 6b166694b..338a7a3fc 100644 --- a/Robust.Shared.Tests/Prototypes/MultiRootGraphTest.cs +++ b/Robust.Shared.Tests/Prototypes/MultiRootGraphTest.cs @@ -33,7 +33,7 @@ public void AddAndRemoveRootAndChild() var parents = graph.GetParents(Id3); Assert.That(parents, Is.Not.Null); - Assert.That(parents!.Count, Is.EqualTo(1)); + Assert.That(parents!, Has.Length.EqualTo(1)); Assert.That(parents.Contains(Id1)); } @@ -45,7 +45,7 @@ public void AddTwoParentsRemoveOne() var parents = graph.GetParents(Id3); Assert.That(parents, Is.Not.Null); - Assert.That(parents!.Count, Is.EqualTo(2)); + Assert.That(parents!, Has.Length.EqualTo(2)); Assert.That(parents.Contains(Id1)); Assert.That(parents.Contains(Id2)); @@ -73,12 +73,12 @@ public void OneParentTwoChildrenRemoveParent() var parents = graph.GetParents(Id3); Assert.That(parents, Is.Not.Null); - Assert.That(parents!.Count, Is.EqualTo(1)); + Assert.That(parents!, Has.Length.EqualTo(1)); Assert.That(parents.Contains(Id1)); parents = graph.GetParents(Id4); Assert.That(parents, Is.Not.Null); - Assert.That(parents!.Count, Is.EqualTo(1)); + Assert.That(parents!, Has.Length.EqualTo(1)); Assert.That(parents.Contains(Id1)); var children = graph.GetChildren(Id1); diff --git a/Robust.Shared.Tests/Utility/CollectionExtensions_Test.cs b/Robust.Shared.Tests/Utility/CollectionExtensions_Test.cs index a135d9414..1b71f1681 100644 --- a/Robust.Shared.Tests/Utility/CollectionExtensions_Test.cs +++ b/Robust.Shared.Tests/Utility/CollectionExtensions_Test.cs @@ -72,5 +72,18 @@ public void DictionaryEqualsTest() Assert.That(dict.DictionaryEquals(differentValue), Is.False); }); } + + [Test] + public void ContainsDuplicatesTest() + { + Assert.Multiple(() => + { + Assert.That(CollectionHelpers.ContainsDuplicates(Array.Empty()), Is.False); + Assert.That(CollectionHelpers.ContainsDuplicates(new[] {1}), Is.False); + Assert.That(CollectionHelpers.ContainsDuplicates(new[] {1, 2, 3}), Is.False); + Assert.That(CollectionHelpers.ContainsDuplicates(new[] {1, 2, 1}), Is.True); + Assert.That(CollectionHelpers.ContainsDuplicates(new string?[] {"a", null, "b", null}), Is.True); + }); + } } } diff --git a/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs b/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs index 2c5f5ee19..e9469b3e5 100644 --- a/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs +++ b/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs @@ -52,10 +52,16 @@ public sealed class AutoGenerateComponentStateAttribute : Attribute /// public readonly bool FieldDeltas; - public AutoGenerateComponentStateAttribute(bool raiseAfterAutoHandleState = false, bool fieldDeltas = false) + /// + /// Should replays get a null component state, or a regular one. + /// + public readonly bool ExcludeReplays; + + public AutoGenerateComponentStateAttribute(bool raiseAfterAutoHandleState = false, bool fieldDeltas = false, bool excludeReplays = false) { RaiseAfterAutoHandleState = raiseAfterAutoHandleState; FieldDeltas = fieldDeltas; + ExcludeReplays = excludeReplays; } } diff --git a/Robust.Shared/Analyzers/EntitySystemSubscriptionsGeneratorAttributes.cs b/Robust.Shared/Analyzers/EntitySystemSubscriptionsGeneratorAttributes.cs index 3a5572ec9..51810f9c2 100644 --- a/Robust.Shared/Analyzers/EntitySystemSubscriptionsGeneratorAttributes.cs +++ b/Robust.Shared/Analyzers/EntitySystemSubscriptionsGeneratorAttributes.cs @@ -24,7 +24,18 @@ namespace Robust.Shared.Analyzers; /// [AttributeUsage(AttributeTargets.Method)] [MeansImplicitUse] -public sealed class SubscribeLocalEventAttribute : Attribute; +public sealed class SubscribeLocalEventAttribute(Type[]? before = null, Type[]? after = null) : Attribute +{ + /// + /// Systems that this event subscription should run before. + /// + public readonly Type[]? Before = before; + + /// + /// Systems that this event subscription should run after. + /// + public readonly Type[]? After = after; +} /// /// This attribute indicates that the annotated method is a handler for an event subscription. Methods annotated with @@ -41,7 +52,18 @@ public sealed class SubscribeLocalEventAttribute : Attribute; /// [AttributeUsage(AttributeTargets.Method)] [MeansImplicitUse] -public sealed class SubscribeNetworkEventAttribute : Attribute; +public sealed class SubscribeNetworkEventAttribute(Type[]? before = null, Type[]? after = null) : Attribute +{ + /// + /// Systems that this event subscription should run before. + /// + public readonly Type[]? Before = before; + + /// + /// Systems that this event subscription should run after. + /// + public readonly Type[]? After = after; +} /// /// This attribute indicates that the annotated method is a handler for an event subscription. Methods annotated with @@ -58,4 +80,15 @@ public sealed class SubscribeNetworkEventAttribute : Attribute; /// [AttributeUsage(AttributeTargets.Method)] [MeansImplicitUse] -public sealed class EventSubscriptionAttribute : Attribute; +public sealed class EventSubscriptionAttribute(Type[]? before = null, Type[]? after = null) : Attribute +{ + /// + /// Systems that this event subscription should run before. + /// + public readonly Type[]? Before = before; + + /// + /// Systems that this event subscription should run after. + /// + public readonly Type[]? After = after; +} diff --git a/Robust.Shared/Audio/SoundSpecifierTypeSerializer.cs b/Robust.Shared/Audio/SoundSpecifierTypeSerializer.cs index 8d1a31857..41df81d21 100644 --- a/Robust.Shared/Audio/SoundSpecifierTypeSerializer.cs +++ b/Robust.Shared/Audio/SoundSpecifierTypeSerializer.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using Robust.Shared.IoC; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager; @@ -27,6 +28,7 @@ private Type GetType(MappingDataNode node) if (hasCollection) return typeof(SoundCollectionSpecifier); + // See Read below if you are adding new types return typeof(SoundPathSpecifier); } @@ -35,7 +37,15 @@ public SoundSpecifier Read(ISerializationManager serializationManager, MappingDa ISerializationManager.InstantiationDelegate? instanceProvider = null) { var type = GetType(node); - return (SoundSpecifier)serializationManager.Read(type, node, hookCtx, context)!; + + if (type == typeof(SoundPathSpecifier)) + return serializationManager.Read(node, hookCtx, context, notNullableOverride: true); + + if (type == typeof(SoundCollectionSpecifier)) + return serializationManager.Read(node, hookCtx, context, notNullableOverride: true); + + // See GetType above if you are adding new types + throw new NotImplementedException(); } public SoundSpecifier Read(ISerializationManager serializationManager, ValueDataNode node, diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 32be29b08..f42b7238b 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1419,6 +1419,12 @@ protected CVars() public static readonly CVarDef AudioMasterVolume = CVarDef.Create("audio.mastervolume", 0.50f, CVar.ARCHIVE | CVar.CLIENTONLY); + /// + /// Whether to mute audio while the game window is unfocused. + /// + public static readonly CVarDef AudioMuteUnfocused = + CVarDef.Create("audio.mute_unfocused", false, CVar.ARCHIVE | CVar.CLIENTONLY); + /// /// Maximum raycast distance for audio occlusion. /// @@ -1474,7 +1480,7 @@ protected CVars() /// Can grids split if not connected by cardinals /// public static readonly CVarDef GridSplitting = - CVarDef.Create("physics.grid_splitting", true, CVar.ARCHIVE); + CVarDef.Create("physics.grid_splitting", true, CVar.ARCHIVE | CVar.REPLICATED | CVar.SERVER); /// /// How much to enlarge grids when determining their fixture bounds. @@ -1923,6 +1929,14 @@ protected CVars() /// public static readonly CVarDef ReplayWriteChannelSize = CVarDef.Create("replay.write_channel_size", 5); + /// + /// Number of replay data blocks (each ≈ one data_N file) the client keeps resident in memory + /// at once during playback. The full replay is no longer loaded entirely into RAM; blocks are read + /// lazily and evicted (LRU) once this window is exceeded. Higher values use more memory but reduce + /// re-reads when scrubbing back and forth. Minimum effective value is 2. + /// + public static readonly CVarDef ReplayLoadedBlockWindow = CVarDef.Create("replay.loaded_block_window", 8); + /// /// Whether or not server-side replay recording is enabled. /// diff --git a/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs b/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs index 1b30413f4..a7dd49e33 100644 --- a/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs +++ b/Robust.Shared/ComponentTrees/ComponentTreeSystem.cs @@ -128,7 +128,14 @@ private void HandleRecursiveMove(EntityUid uid, TransformComponent xform) } private void HandleMove(EntityUid uid, TComp component, ref MoveEvent args) - => QueueTreeUpdate(uid, component, args.Component); + { + QueueTreeUpdate(uid, component, args.Component); + OnComponentMove(uid, component, ref args); + } + + protected virtual void OnComponentMove(EntityUid uid, TComp component, ref MoveEvent args) + { + } public void QueueTreeUpdate(EntityUid uid, TComp component, TransformComponent? xform = null) { @@ -296,16 +303,16 @@ protected virtual Box2 ExtractAabb(in ComponentTreeEntry entry) #endregion #region Queries - public IEnumerable<(EntityUid, TTreeComp)> GetIntersectingTrees(MapId mapId, Box2Rotated worldBounds) + public ValueList<(EntityUid Uid, TTreeComp Comp)> GetIntersectingTrees(MapId mapId, Box2Rotated worldBounds) => GetIntersectingTrees(mapId, worldBounds.CalcBoundingBox()); - public IEnumerable<(EntityUid Uid, TTreeComp Comp)> GetIntersectingTrees(MapId mapId, Box2 worldAABB) + public ValueList<(EntityUid Uid, TTreeComp Comp)> GetIntersectingTrees(MapId mapId, Box2 worldAABB) => GetIntersectingTreesInternal(mapId, worldAABB); internal ValueList<(EntityUid Uid, TTreeComp Comp)> GetIntersectingTreesInternal(MapId mapId, Box2 worldAABB) { if (!CheckEnabled()) - return []; + return default; // Anything that queries these trees should only do so if there are no queued updates, otherwise it can lead to // errors. Currently, there is no easy way to enforce this, but this should work as long as nothing queries the // trees directly: diff --git a/Robust.Shared/Containers/Container.cs b/Robust.Shared/Containers/Container.cs index 40a2c94ab..f315bbcfa 100644 --- a/Robust.Shared/Containers/Container.cs +++ b/Robust.Shared/Containers/Container.cs @@ -15,7 +15,6 @@ namespace Robust.Shared.Containers /// For example, inventory containers should be modified only through an inventory component. /// [UsedImplicitly] - [SerializedType(nameof(Container))] public sealed partial class Container : BaseContainer { /// diff --git a/Robust.Shared/Containers/ContainerSlot.cs b/Robust.Shared/Containers/ContainerSlot.cs index 92f301b03..8c678fb93 100644 --- a/Robust.Shared/Containers/ContainerSlot.cs +++ b/Robust.Shared/Containers/ContainerSlot.cs @@ -10,7 +10,6 @@ namespace Robust.Shared.Containers { [UsedImplicitly] - [SerializedType(nameof(ContainerSlot))] public sealed partial class ContainerSlot : BaseContainer { public override int Count => ContainedEntity == null ? 0 : 1; diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index ead0beb19..a16bfdcde 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -697,7 +697,10 @@ private void LoadEntity( _components.Clear(); CurrentComponent = null; if (missingComps is {Count: > 0}) + { + EntMan.DirtyEntity(uid, meta); meta.LastComponentRemoved = Timing.CurTick; + } } private void GetRootEntities() diff --git a/Robust.Shared/EntitySerialization/EntitySerializer.cs b/Robust.Shared/EntitySerialization/EntitySerializer.cs index 85e56c39f..466c0d223 100644 --- a/Robust.Shared/EntitySerialization/EntitySerializer.cs +++ b/Robust.Shared/EntitySerialization/EntitySerializer.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Linq; using Robust.Shared.Configuration; using Robust.Shared.EntitySerialization.Components; using Robust.Shared.EntitySerialization.Systems; @@ -149,6 +148,8 @@ public sealed partial class EntitySerializer : ISerializationContext, private int _nextYamlTileId; private readonly List _autoInclude = new(); + private readonly List _sortedTileIds = new(); + private readonly List _sortedProtoIds = new(); private readonly EntityQuery _yamlQuery; private readonly EntityQuery _gridQuery; private readonly EntityQuery _mapQuery; @@ -265,7 +266,11 @@ public void SerializeEntityRecursive(HashSet roots) if (roots.Count == 0) return; - InitializeTileMap(roots.First()); + using (var enumerator = roots.GetEnumerator()) + { + enumerator.MoveNext(); + InitializeTileMap(enumerator.Current); + } HashSet allEntities = new(); List<(EntityUid Root, HashSet Children)> entities = new(); @@ -354,7 +359,7 @@ private bool FindSavedTileMap(EntityUid root, [NotNullWhen(true)] out Dictionary private void ProcessAutoInclude() { - DebugTools.AssertEqual(_autoInclude.ToHashSet().Count, _autoInclude.Count); + DebugTools.Assert(!CollectionHelpers.ContainsDuplicates(_autoInclude)); var ents = new HashSet(); @@ -581,7 +586,8 @@ private void SerializeEntityInternal(EntityUid uid) { // try comp instead of has-comp as it checks whether the component is supposed to have been // deleted. - if (EntMan.TryGetComponent(uid, comp.Component.GetType(), out _)) + if (EntMan.TryGetComponent(uid, comp.Component.GetType(), out var component) + && !EntMan.IsComponentPendingRemoval(component)) continue; missingComponents ??= new(); @@ -629,6 +635,9 @@ private void SerializeComponents(EntityUid uid, Dictionary ids) public MappingDataNode WriteTileMap() { var map = new MappingDataNode(); - foreach (var (tileId, yamlTileId) in _tileMap.OrderBy(x => x.Key)) + _sortedTileIds.Clear(); + foreach (var tileId in _tileMap.Keys) + { + _sortedTileIds.Add(tileId); + } + + _sortedTileIds.Sort(); + + foreach (var tileId in _sortedTileIds) { // This can come up if tests try to serialize test maps with custom / placeholder tile ids without registering them with the tile def manager.. if (!_tileDef.TryGetDefinition(tileId, out var def)) throw new Exception($"Attempting to serialize a tile {tileId} with no valid tile definition."); + var yamlTileId = _tileMap[tileId]; var yamlId = yamlTileId.ToString(CultureInfo.InvariantCulture); map.Add(yamlId, def.ID); } @@ -769,10 +787,15 @@ public SequenceDataNode WriteEntitySection() } var prototypes = new SequenceDataNode(); - var protos = Prototypes.Keys.ToList(); - protos.Sort(StringComparer.InvariantCulture); + _sortedProtoIds.Clear(); + foreach (var protoId in Prototypes.Keys) + { + _sortedProtoIds.Add(protoId); + } + + _sortedProtoIds.Sort(StringComparer.InvariantCulture); - foreach (var protoId in protos) + foreach (var protoId in _sortedProtoIds) { var entities = new SequenceDataNode(); var node = new MappingDataNode diff --git a/Robust.Shared/EntitySerialization/MapChunkSerializer.cs b/Robust.Shared/EntitySerialization/MapChunkSerializer.cs index c5df77dfa..79b741ceb 100644 --- a/Robust.Shared/EntitySerialization/MapChunkSerializer.cs +++ b/Robust.Shared/EntitySerialization/MapChunkSerializer.cs @@ -35,7 +35,7 @@ public MapChunk Read(ISerializationManager serializationManager, MappingDataNode ISerializationContext? context = null, ISerializationManager.InstantiationDelegate? instantiationDelegate = null) { - var ind = (Vector2i) serializationManager.Read(typeof(Vector2i), node["ind"], hookCtx, context)!; + var ind = serializationManager.Read(node["ind"], hookCtx, context)!; var tileNode = (ValueDataNode)node["tiles"]; var tileBytes = Convert.FromBase64String(tileNode.Value); @@ -49,9 +49,7 @@ public MapChunk Read(ISerializationManager serializationManager, MappingDataNode // TODO: This should be on the context I think? if (node.TryGet("size", out ValueDataNode? sizeNode)) - { - size = (ushort) serializationManager.Read(typeof(ushort), sizeNode, context)!; - } + size = serializationManager.Read(sizeNode, context)!; var chunk = instantiationDelegate != null ? instantiationDelegate() : new MapChunk(ind.X, ind.Y, size); diff --git a/Robust.Shared/GameObjects/Components/Light/OccluderComponent.cs b/Robust.Shared/GameObjects/Components/Light/OccluderComponent.cs index 4867bad62..8c82b5190 100644 --- a/Robust.Shared/GameObjects/Components/Light/OccluderComponent.cs +++ b/Robust.Shared/GameObjects/Components/Light/OccluderComponent.cs @@ -2,23 +2,48 @@ using Robust.Shared.GameStates; using Robust.Shared.Maths; using Robust.Shared.Physics; -using Robust.Shared.Serialization; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.ViewVariables; using System; +using System.Numerics; namespace Robust.Shared.GameObjects; [RegisterComponent] [NetworkedComponent()] -[Access(typeof(OccluderSystem))] +[AutoGenerateComponentState(true)] +[Access(typeof(OccluderSystem), Other = AccessPermissions.ReadExecute)] public sealed partial class OccluderComponent : Component, IComponentTreeEntry { - [DataField("enabled")] + [DataField, AutoNetworkedField] public bool Enabled = true; - [DataField("boundingBox")] - public Box2 BoundingBox = new(-0.5f, -0.5f, 0.5f, 0.5f); + /// + /// Local-space convex polygon vertices. + /// + [DataField("polygon", customTypeSerializer: typeof(PhysicsHullSerializer)), AutoNetworkedField] + private Vector2[] _polygon = + [ + new(-0.5f, 0.5f), + new(0.5f, 0.5f), + new(0.5f, -0.5f), + new(-0.5f, -0.5f), + ]; + + public ReadOnlySpan Polygon => _polygon; + + internal Vector2[] PolygonArray + { + get => _polygon; + set => _polygon = value; + } + + /// + /// Cached local-space bounds for . + /// + [ViewVariables] + public Box2 LocalBounds { get; internal set; } = Box2.Empty; // Leave as empty so we remember to always update the cache on init. public EntityUid? TreeUid { get; set; } public DynamicTree>? Tree { get; set; } @@ -26,29 +51,16 @@ public sealed partial class OccluderComponent : Component, IComponentTreeEntry Enabled; public bool TreeUpdateQueued { get; set; } = false; - [ViewVariables] public (EntityUid Grid, Vector2i Tile)? LastPosition; - [ViewVariables] public OccluderDir Occluding; + /// + /// Cached client-side shared-edge mask. Bit i is set when polygon render edge i is exactly shared + /// with another enabled occluder edge. + /// + [ViewVariables] + public byte OccludingEdges; - [Flags] - public enum OccluderDir : byte - { - None = 0, - North = 1, - East = 1 << 1, - South = 1 << 2, - West = 1 << 3, - } - - [NetSerializable, Serializable] - public sealed class OccluderComponentState : ComponentState - { - public bool Enabled { get; } - public Box2 BoundingBox { get; } - - public OccluderComponentState(bool enabled, Box2 boundingBox) - { - Enabled = enabled; - BoundingBox = boundingBox; - } - } + /// + /// Last tree-local bounds used to dirty neighbours when this occluder moves, changes polygon, or is removed. + /// + [ViewVariables] + public (EntityUid TreeUid, Box2 Bounds)? LastTreeBounds; } diff --git a/Robust.Shared/GameObjects/EntityManager.Components.cs b/Robust.Shared/GameObjects/EntityManager.Components.cs index 82b920ac2..000a6e67f 100644 --- a/Robust.Shared/GameObjects/EntityManager.Components.cs +++ b/Robust.Shared/GameObjects/EntityManager.Components.cs @@ -518,6 +518,14 @@ public void RemoveComponentDeferred(EntityUid owner, Component component) RemoveComponentDeferred(component, owner, false); } + /// + /// Returns whether a component is being removed or is queued for deferred removal. + /// + internal bool IsComponentPendingRemoval(IComponent component) + { + return component.LifeStage >= ComponentLifeStage.Stopping || _deleteSet.Contains(component); + } + private static IEnumerable InSafeOrder(IEnumerable comps, bool forCreation = false) { static int Sequence(IComponent x) diff --git a/Robust.Shared/GameObjects/EntitySystem.Proxy.cs b/Robust.Shared/GameObjects/EntitySystem.Proxy.cs index e9bb3b5d7..70c6becdf 100644 --- a/Robust.Shared/GameObjects/EntitySystem.Proxy.cs +++ b/Robust.Shared/GameObjects/EntitySystem.Proxy.cs @@ -569,6 +569,36 @@ protected bool TryComp([NotNullWhen(true)] EntityUid? uid, [NotNullWhen(true)] o return EntityManager.MetaQuery.TryGetComponent(uid.Value, out comp); } + /// + /// Retrieves the given entity's component of the specified type, assembled into an . If no + /// such component exists, returns null. + /// + /// The type of component to retrieve. + /// The UID of the entity whose component will be retrieved. + /// The assembled entity UID and component, if the component exists; otherwise null. + protected Entity? WithCompOrNull(EntityUid uid) where T : IComponent + { + return EntityManager.TryGetComponent(uid, out var comp) ? new Entity(uid, comp) : null; + } + + /// + /// Retrieves the given entity's component of the specified type, assembled into an . If the + /// given entity already contains a component value, that is returned in the assembled return value. If the given + /// entity has no such component, returns null. + /// + /// The type of component to retrieve. + /// + /// An containing the UID of the entity whose component will be retrieved. Note that this + /// MAY already contain a component value. + /// + /// The assembled entity UID and component, if the component exists; otherwise null. + protected Entity? WithCompOrNull(Entity entity) where T : IComponent + { + return entity.Comp is { } comp || EntityManager.TryGetComponent(entity, out comp) + ? new Entity(entity, comp) + : null; + } + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] [ProxyFor(typeof(EntityManager), nameof(EntityManager.GetComponents))] diff --git a/Robust.Shared/GameObjects/EntitySystemManager.cs b/Robust.Shared/GameObjects/EntitySystemManager.cs index edae00b15..5e7a70ce6 100644 --- a/Robust.Shared/GameObjects/EntitySystemManager.cs +++ b/Robust.Shared/GameObjects/EntitySystemManager.cs @@ -181,15 +181,10 @@ public void Initialize(bool discover = true) // which instance to return if we retrieved it by the supertype if (excludedTypes.Contains(baseType)) continue; - if (subTypes.ContainsKey(baseType)) - { - subTypes.Remove(baseType); + if (subTypes.Remove(baseType)) excludedTypes.Add(baseType); - } else - { subTypes.Add(baseType, type); - } } } diff --git a/Robust.Shared/GameObjects/Systems/OccluderSystem.cs b/Robust.Shared/GameObjects/Systems/OccluderSystem.cs index 8f1ef5581..8289cfe38 100644 --- a/Robust.Shared/GameObjects/Systems/OccluderSystem.cs +++ b/Robust.Shared/GameObjects/Systems/OccluderSystem.cs @@ -1,35 +1,49 @@ using System; +using System.Collections.Generic; using System.Numerics; using Robust.Shared.ComponentTrees; using Robust.Shared.GameStates; +using Robust.Shared.IoC; using Robust.Shared.Map; using Robust.Shared.Maths; using Robust.Shared.Physics; +using Robust.Shared.Physics.Shapes; +using Robust.Shared.Physics.Systems; using Robust.Shared.Utility; namespace Robust.Shared.GameObjects; -public abstract class OccluderSystem : ComponentTreeSystem +public abstract partial class OccluderSystem : ComponentTreeSystem { public const float MaxRaycastRange = 100f; + [Dependency] private FixtureSystem _fixtureSystem = default!; + + [Dependency] private EntityQuery _occluderQuery; + [Dependency] private EntityQuery _xformQuery; + + private readonly List _raycastResults = new(); + public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnGetState); - SubscribeLocalEvent(OnHandleState); + SubscribeLocalEvent(OnCompInit); + SubscribeLocalEvent(OnAfterAutoHandleState); } - private void OnGetState(EntityUid uid, OccluderComponent comp, ref ComponentGetState args) + private void OnCompInit(EntityUid uid, OccluderComponent comp, ComponentInit args) { - args.State = new OccluderComponent.OccluderComponentState(comp.Enabled, comp.BoundingBox); + UpdatePolygonCache(comp); } - private void OnHandleState(EntityUid uid, OccluderComponent comp, ref ComponentHandleState args) + + private void OnAfterAutoHandleState(EntityUid uid, OccluderComponent comp, ref AfterAutoHandleStateEvent args) { - if (args.Current is not OccluderComponent.OccluderComponentState state) - return; + UpdatePolygonCache(comp); + QueueTreeUpdate(uid, comp); + OnOccluderAfterAutoHandleState(uid, comp, ref args); + } - SetEnabled(uid, state.Enabled, comp); - SetBoundingBox(uid, state.BoundingBox, comp); + protected virtual void OnOccluderAfterAutoHandleState(EntityUid uid, OccluderComponent comp, ref AfterAutoHandleStateEvent args) + { } #region Component Tree Overrides @@ -43,20 +57,31 @@ private void OnHandleState(EntityUid uid, OccluderComponent comp, ref ComponentH protected override Box2 ExtractAabb(in ComponentTreeEntry entry) { DebugTools.Assert(entry.Transform.ParentUid == entry.Component.TreeUid); - return entry.Component.BoundingBox.Translated(entry.Transform.LocalPosition); + var position = entry.Transform.LocalPosition; + return new Box2Rotated( + entry.Component.LocalBounds.Translated(position), + entry.Transform.LocalRotation, + position).CalcBoundingBox(); } protected override Box2 ExtractAabb(in ComponentTreeEntry entry, Vector2 pos, Angle rot) - => ExtractAabb(in entry); + => new Box2Rotated(entry.Component.LocalBounds.Translated(pos), rot, pos).CalcBoundingBox(); #endregion #region Setters - public void SetBoundingBox(EntityUid uid, Box2 box, OccluderComponent? comp = null) + public virtual void SetPolygon(EntityUid uid, Vector2[]? polygon, OccluderComponent? comp = null) { if (!Resolve(uid, ref comp)) return; - comp.BoundingBox = box; + comp.PolygonArray = polygon ?? + [ + new(-0.5f, 0.5f), + new(0.5f, 0.5f), + new(0.5f, -0.5f), + new(-0.5f, -0.5f), + ]; + UpdatePolygonCache(comp); Dirty(uid, comp); if (comp.TreeUid != null) @@ -74,6 +99,17 @@ public virtual void SetEnabled(EntityUid uid, bool enabled, OccluderComponent? c } #endregion + protected override void OnCompStartup(EntityUid uid, OccluderComponent component, ComponentStartup args) + { + UpdatePolygonCache(component); + base.OnCompStartup(uid, component, args); + } + + private static void UpdatePolygonCache(OccluderComponent occluder) + { + occluder.LocalBounds = CalculateLocalBounds(occluder.Polygon); + } + #region InRangeUnoccluded /// @@ -90,24 +126,53 @@ public bool InRangeUnoccluded( if (!GetRay(origin, other, range, out var length, out var ray, out var result)) return result; - return IntersectRay(origin.MapId, ray, length, state, ignore) == null; + IntersectRay(_raycastResults, origin.MapId, ray, length); + foreach (var rayResult in _raycastResults) + { + if (!_occluderQuery.TryComp(rayResult.HitEntity, out var occluder) || + !_xformQuery.TryComp(rayResult.HitEntity, out var xform)) + { + return false; + } + + if (!ignore(new Entity(rayResult.HitEntity, occluder, xform), state)) + return false; + } + + return true; } /// /// Returns true if two points are within the specified range and there are no occluders between them. /// - /// If true, this will use as a predicate to ignore \ - /// occluders that are touching the start or end point. + /// If true, this will ignore occluders that contain the start or end point. public bool InRangeUnoccluded(MapCoordinates origin, MapCoordinates other, float range, bool ignoreTouching) { if (!GetRay(origin, other, range, out var length, out var ray, out var result)) return result; - if (!ignoreTouching) - return IntersectRay(origin.MapId, ray, length) == null; + IntersectRay(_raycastResults, origin.MapId, ray, length); + foreach (var rayResult in _raycastResults) + { + if (!ignoreTouching) + return false; + + if (!_occluderQuery.TryComp(rayResult.HitEntity, out var occluder) || + !_xformQuery.TryComp(rayResult.HitEntity, out var xform)) + { + return false; + } - var state = (XformSystem, origin.Position, other.Position); - return IntersectRay(origin.MapId, ray, length, state, IsTouchingEndpoint) == null; + if (ContainsPoint(occluder, xform, origin.Position) || + ContainsPoint(occluder, xform, other.Position)) + { + continue; + } + + return false; + } + + return true; } private bool GetRay(MapCoordinates origin, MapCoordinates other, float range, out float length, out Ray ray, out bool result) @@ -141,16 +206,33 @@ private bool GetRay(MapCoordinates origin, MapCoordinates other, float range, ou return true; } - /// - /// Simple predicate for use with that will ignore any occluders that intersect the - /// start and end points. - /// - public static bool IsTouchingEndpoint(Entity ent, (SharedTransformSystem Sys, Vector2 Start, Vector2 End) state) + public bool ContainsPoint(OccluderComponent occluder, TransformComponent xform, Vector2 point) { - var occluderBox = ent.Comp1.BoundingBox; - occluderBox = occluderBox.Translated(state.Sys.GetWorldPosition(ent.Comp2)); - return occluderBox.Contains(state.Start) || occluderBox.Contains(state.End); + // Broadphase check + var (worldPosition, worldRotation) = XformSystem.GetWorldPositionRotation(xform); + var worldBounds = new Box2Rotated( + occluder.LocalBounds.Translated(worldPosition), + worldRotation, + worldPosition).CalcBoundingBox(); + + if (!worldBounds.Contains(point)) + return false; + + // Narrowphase check + var polygon = new Polygon(occluder.PolygonArray); + return polygon.VertexCount >= 3 && + _fixtureSystem.TestPoint(polygon, new Transform(worldPosition, worldRotation), point); } + private static Box2 CalculateLocalBounds(ReadOnlySpan polygon) + { + var bounds = new Box2(polygon[0], polygon[0]); + for (var i = 1; i < polygon.Length; i++) + { + bounds = bounds.ExtendToContain(polygon[i]); + } + + return bounds; + } #endregion } diff --git a/Robust.Shared/GameObjects/Systems/SharedGridFixtureSystem.cs b/Robust.Shared/GameObjects/Systems/SharedGridFixtureSystem.cs index fafa4baa0..a0e5138c4 100644 --- a/Robust.Shared/GameObjects/Systems/SharedGridFixtureSystem.cs +++ b/Robust.Shared/GameObjects/Systems/SharedGridFixtureSystem.cs @@ -24,9 +24,15 @@ public abstract partial class SharedGridFixtureSystem : EntitySystem [Dependency] private FixtureSystem _fixtures = default!; [Dependency] private SharedMapSystem _map = default!; [Dependency] private IConfigurationManager _cfg = default!; + [Dependency] private EntityQuery _mapQuery = default!; + [Dependency] private EntityQuery _bodyQuery = default!; + [Dependency] private EntityQuery _fixturesQuery = default!; + [Dependency] private EntityQuery _xformQuery = default!; private bool _enabled; private float _fixtureEnlargement; + private readonly Dictionary _changedFixtures = new(); + private readonly Dictionary _newFixtures = new(); internal const string ShowGridNodesCommand = "showgridnodes"; @@ -37,23 +43,22 @@ public override void Initialize() Subs.CVar(_cfg, CVars.GenerateGridFixtures, SetEnabled, true); Subs.CVar(_cfg, CVars.GridFixtureEnlargement, SetEnlargement, true); - - SubscribeLocalEvent(OnGridInit); - SubscribeLocalEvent(OnGridBoundsRegenerate); } + [SubscribeLocalEvent] private void OnGridBoundsRegenerate(ref RegenerateGridBoundsEvent ev) { - RegenerateCollision(ev.Entity, ev.ChunkRectangles, ev.RemovedChunks); + RegenerateCollision(ev.Entity, ev.ChunkRectangles, ev.RemovedChunks, ev.Grid); } + [SubscribeLocalEvent] protected virtual void OnGridInit(GridInitializeEvent ev) { - if (HasComp(ev.EntityUid)) + if (_mapQuery.HasComponent(ev.EntityUid)) return; // This will also check for grid splits if applicable. - var grid = Comp(ev.EntityUid); + var grid = ev.Grid; _map.RegenerateCollision(ev.EntityUid, grid, _map.GetMapChunks(ev.EntityUid, grid).Values.ToHashSet()); } @@ -64,145 +69,117 @@ protected virtual void OnGridInit(GridInitializeEvent ev) internal void RegenerateCollision( EntityUid uid, Dictionary> mapChunks, - List removedChunks) + List removedChunks, + MapGridComponent? grid = null) { if (!_enabled) return; - if (!TryComp(uid, out PhysicsComponent? body)) + if (!_bodyQuery.TryGetComponent(uid, out var body)) { Log.Error($"Trying to regenerate collision for {uid} that doesn't have {nameof(body)}"); return; } - if (!TryComp(uid, out FixturesComponent? manager)) + if (!_fixturesQuery.TryGetComponent(uid, out var manager)) { Log.Error($"Trying to regenerate collision for {uid} that doesn't have {nameof(manager)}"); return; } - if (!TryComp(uid, out TransformComponent? xform)) + if (!_xformQuery.TryGetComponent(uid, out var xform)) { Log.Error($"Trying to regenerate collision for {uid} that doesn't have {nameof(TransformComponent)}"); return; } - var fixtures = new Dictionary(mapChunks.Count); + _changedFixtures.Clear(); + var anyUpdated = false; foreach (var (chunk, rectangles) in mapChunks) { - UpdateFixture(uid, chunk, rectangles, body, manager, xform); + if (!UpdateFixture(uid, chunk, rectangles, body, manager, xform)) + continue; + + anyUpdated = true; foreach (var id in chunk.Fixtures) { - fixtures[id] = manager.Fixtures[id]; + _changedFixtures[id] = manager.Fixtures[id]; } } - EntityManager.EventBus.RaiseLocalEvent(uid,new GridFixtureChangeEvent {NewFixtures = fixtures}, true); + if (!anyUpdated) + { + CheckSplit(uid, mapChunks, removedChunks, grid); + return; + } + + EntityManager.EventBus.RaiseLocalEvent(uid,new GridFixtureChangeEvent {NewFixtures = _changedFixtures}, true); _fixtures.FixtureUpdate(uid, manager: manager, body: body); - CheckSplit(uid, mapChunks, removedChunks); + CheckSplit(uid, mapChunks, removedChunks, grid); } internal virtual void CheckSplit(EntityUid gridEuid, Dictionary> mapChunks, - List removedChunks) {} + List removedChunks, MapGridComponent? grid = null) {} - internal virtual void CheckSplit(EntityUid gridEuid, MapChunk chunk, List rectangles) {} + internal virtual void CheckSplit(EntityUid gridEuid, MapChunk chunk, List rectangles, MapGridComponent? grid = null) {} private bool UpdateFixture(EntityUid uid, MapChunk chunk, List rectangles, PhysicsComponent body, FixturesComponent manager, TransformComponent xform) { var origin = chunk.Indices * chunk.ChunkSize; - // So we store a reference to the fixture on the chunk because it's easier to cross-reference it. - // This is because when we get multiple fixtures per chunk there's no easy way to tell which the old one - // corresponds with. // We also ideally want to avoid re-creating the fixture every time a tile changes and pushing that data // to the client hence we diff it. - // Additionally, we need to handle map deserialization where content may have stored its own data // on the grid (e.g. mass) which we want to preserve. - var newFixtures = new ValueList<(string Id, Fixture Fixture)>(); - - Span vertices = stackalloc Vector2[4]; + _newFixtures.Clear(); foreach (var rectangle in rectangles) { - var bounds = ((Box2) rectangle.Translated(origin)).Enlarged(_fixtureEnlargement); - var poly = new PolygonShape(); - - vertices[0] = bounds.BottomLeft; - vertices[1] = bounds.BottomRight; - vertices[2] = bounds.TopRight; - vertices[3] = bounds.TopLeft; - - poly.Set(vertices, 4); - -#pragma warning disable CS0618 - var newFixture = new Fixture( - poly, - MapGridHelpers.CollisionGroup, - MapGridHelpers.CollisionGroup, - true) - { - Owner = uid - }; -#pragma warning restore CS0618 - - var key = string.Create(CultureInfo.InvariantCulture, $"grid_chunk-{bounds.Left}-{bounds.Bottom}"); - newFixtures.Add((key, newFixture)); + var tileBounds = rectangle.Translated(origin); + var bounds = ((Box2) tileBounds).Enlarged(_fixtureEnlargement); + var key = string.Create(CultureInfo.InvariantCulture, $"grid_chunk-{tileBounds.Left}-{tileBounds.Bottom}"); + _newFixtures.Add(key, CreateGridFixture(uid, bounds)); } - // Check if we even need to issue an eventbus event var updated = false; + var toRemove = new ValueList(); + // Cross-reference old fixtures by ID. If the shape hasn't changed, keep the existing fixture + // to preserve any properties set by content (e.g. density from ShuttleSystem). foreach (var oldId in chunk.Fixtures) { - var oldFixture = manager.Fixtures[oldId]; - var existing = false; - - // Handle deleted / updated fixtures - // (TODO: Check IDs and cross-reference for updates?) - for (var i = newFixtures.Count - 1; i >= 0; i--) + if (_newFixtures.TryGetValue(oldId, out var newFixture) && + manager.Fixtures.TryGetValue(oldId, out var oldFixture) && + oldFixture.Shape is PolygonShape oldPoly && + newFixture.Shape is PolygonShape newPoly && + oldPoly.EqualsApprox(newPoly)) { - var fixture = newFixtures[i].Fixture; - - // TODO GRIDS - // Fix this - // This **only** works if we assume the density is always the default (PhysicsConstants.DefaultDensity). - // Hence, this always fails in SS14 because ShuttleSystem.OnGridFixtureChange changes the density. - // So it constantly creats & destroys fixtures unnecessarily - // AAAAA - if (!oldFixture.Equals(fixture)) - continue; - - existing = true; - newFixtures.RemoveSwap(i); - break; - } - - if (existing) + _newFixtures.Remove(oldId); continue; + } - // Doesn't align with any new fixtures so delete - chunk.Fixtures.Remove(oldId); - _fixtures.DestroyFixture(uid, oldId, oldFixture, false, body: body, manager: manager, xform: xform); - updated = true; + toRemove.Add(oldId); } - if (newFixtures.Count > 0) + foreach (var oldId in toRemove) { + chunk.Fixtures.Remove(oldId); + + if (manager.Fixtures.TryGetValue(oldId, out var fixture)) + _fixtures.DestroyFixture(uid, oldId, fixture, false, body: body, manager: manager, xform: xform); + updated = true; } // Anything remaining is a new fixture (or at least, may have not serialized onto the chunk yet). - foreach (var (id, fixture) in newFixtures.Span) + foreach (var (id, fixture) in _newFixtures) { chunk.Fixtures.Add(id); + var existingFixture = _fixtures.GetFixtureOrNull(uid, id, manager: manager); - // Check if it's the same (otherwise remove anyway). - // TODO GRIDS - // wasn't this already checked? if (existingFixture?.Shape is PolygonShape poly && poly.EqualsApprox((PolygonShape) fixture.Shape)) { @@ -210,10 +187,34 @@ private bool UpdateFixture(EntityUid uid, MapChunk chunk, List rectangles } _fixtures.CreateFixture(uid, id, fixture, false, manager, body, xform); + updated = true; } return updated; } + + private static Fixture CreateGridFixture(EntityUid uid, Box2 bounds) + { + Span vertices = stackalloc Vector2[4]; + vertices[0] = bounds.BottomLeft; + vertices[1] = bounds.BottomRight; + vertices[2] = bounds.TopRight; + vertices[3] = bounds.TopLeft; + + var poly = new PolygonShape(); + poly.Set(vertices, 4); + +#pragma warning disable CS0618 + return new Fixture( + poly, + MapGridHelpers.CollisionGroup, + MapGridHelpers.CollisionGroup, + true) + { + Owner = uid + }; +#pragma warning restore CS0618 + } } /// diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.Queries.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.Queries.cs index 4d754b590..de69190f5 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.Queries.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.Queries.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Numerics; @@ -424,16 +425,9 @@ public void FindGridsIntersecting( /// /// Enumerates all of the grids located on a given map. /// - public IEnumerable> GetAllGrids(MapId mapId) + public AllGridsEnumerator GetAllGrids(MapId mapId) { - var query = AllEntityQuery(); - while (query.MoveNext(out var uid, out var grid, out var xform)) - { - if (xform.MapID != mapId) - continue; - - yield return (uid, grid); - } + return new AllGridsEnumerator(mapId, AllEntityQuery()); } /// @@ -441,13 +435,110 @@ public IEnumerable> GetAllGrids(MapId mapId) /// /// [Obsolete("use GetAllGrids instead")] - public IEnumerable GetAllMapGrids(MapId mapId) + public AllMapGridsEnumerator GetAllMapGrids(MapId mapId) + { + return new AllMapGridsEnumerator(GetAllGrids(mapId)); + } + + public struct AllGridsEnumerator : IEnumerable>, IEnumerator> { - var query = AllEntityQuery(); - while (query.MoveNext(out var grid, out var xform)) + private readonly MapId _mapId; + private AllEntityQueryEnumerator _query; + private Entity _current; + + internal AllGridsEnumerator(MapId mapId, AllEntityQueryEnumerator query) + { + _mapId = mapId; + _query = query; + _current = default; + } + + public readonly AllGridsEnumerator GetEnumerator() => this; + + public readonly Entity Current => _current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (_query.MoveNext(out var uid, out var grid, out var xform)) + { + if (xform.MapID != _mapId) + continue; + + _current = (uid, grid); + return true; + } + + return false; + } + + readonly IEnumerator> IEnumerable>.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Dispose() + { + _query.Dispose(); + } + + public void Reset() + { + throw new NotSupportedException(); + } + } + + [Obsolete("Use AllGridsEnumerator instead.")] + public struct AllMapGridsEnumerator : IEnumerable, IEnumerator + { + private AllGridsEnumerator _grids; + private MapGridComponent _current; + + internal AllMapGridsEnumerator(AllGridsEnumerator grids) + { + _grids = grids; + _current = default!; + } + + public readonly AllMapGridsEnumerator GetEnumerator() => this; + + public readonly MapGridComponent Current => _current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + if (!_grids.MoveNext()) + return false; + + _current = _grids.Current.Comp; + return true; + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Dispose() + { + _grids.Dispose(); + } + + public void Reset() { - if (xform.MapID == mapId) - yield return grid; + throw new NotSupportedException(); } } diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs index c08ce739e..b01e586ab 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Grid.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; @@ -593,7 +594,7 @@ private void OnGridInit(EntityUid uid, MapGridComponent component, ComponentInit } } - var msg = new GridInitializeEvent(uid); + var msg = new GridInitializeEvent(uid, component); RaiseLocalEvent(uid, msg, true); } @@ -736,7 +737,7 @@ internal void RegenerateCollision(EntityUid uid, MapGridComponent grid, IReadOnl _physics.WakeBody(uid); OnGridBoundsChange(uid, grid); - var ev = new RegenerateGridBoundsEvent(uid, chunkRectangles, removedChunks); + var ev = new RegenerateGridBoundsEvent(uid, chunkRectangles, removedChunks, grid); RaiseLocalEvent(ref ev); } @@ -841,24 +842,9 @@ internal TileRef GetTileRef(EntityUid uid, MapGridComponent grid, MapChunk mapCh return new TileRef(uid, indices, mapChunk.GetTile(xIndex, yIndex)); } - public IEnumerable GetAllTiles(EntityUid uid, MapGridComponent grid, bool ignoreEmpty = true) + public GridTileEnumerator GetAllTiles(EntityUid uid, MapGridComponent grid, bool ignoreEmpty = true) { - foreach (var chunk in grid.Chunks.Values) - { - for (ushort x = 0; x < grid.ChunkSize; x++) - { - for (ushort y = 0; y < grid.ChunkSize; y++) - { - var tile = chunk.GetTile(x, y); - - if (ignoreEmpty && tile.IsEmpty) - continue; - - var (gridX, gridY) = new Vector2i(x, y) + chunk.Indices * grid.ChunkSize; - yield return new TileRef(uid, gridX, gridY, tile); - } - } - } + return new GridTileEnumerator(uid, grid.Chunks.GetEnumerator(), grid.ChunkSize, ignoreEmpty); } /// @@ -873,9 +859,10 @@ public int GetFilledTileCount(Entity ent) return ent.Comp.Chunks.Values.Sum(chunk => chunk.FilledTiles); } + [Obsolete("Use GetAllTiles instead.")] public GridTileEnumerator GetAllTilesEnumerator(EntityUid uid, MapGridComponent grid, bool ignoreEmpty = true) { - return new GridTileEnumerator(uid, grid.Chunks.GetEnumerator(), grid.ChunkSize, ignoreEmpty); + return GetAllTiles(uid, grid, ignoreEmpty); } public void SetTile(Entity grid, EntityCoordinates coordinates, Tile tile) @@ -945,147 +932,99 @@ public void SetTiles(EntityUid uid, MapGridComponent grid, List<(Vector2i GridIn var offset = chunk.GridTileToChunkTile(gridIndices); chunk.SuppressCollisionRegeneration = true; - if (SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile, out var oldTile)) + var changed = SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile, out var oldTile, out var shapeChanged); + chunk.SuppressCollisionRegeneration = false; + + if (changed) { - modified.Add(chunk); + if (shapeChanged) + modified.Add(chunk); + tileChanges.Add(new TileChangedEntry(tile, oldTile, offset, gridIndices)); } } - foreach (var chunk in modified) - { - chunk.SuppressCollisionRegeneration = false; - } - // Notify of all tile changes in one event var ev = new TileChangedEvent((uid, grid), tileChanges.ToArray()); RaiseLocalEvent(uid, ref ev, true); - RegenerateCollision(uid, grid, modified); + if (modified.Count > 0) + RegenerateCollision(uid, grid, modified); // Back to normal SuppressOnTileChanged = false; } + [Obsolete("Use GetLocalTilesIntersecting instead.")] public TilesEnumerator GetLocalTilesEnumerator(EntityUid uid, MapGridComponent grid, Box2 aabb, bool ignoreEmpty = true, Predicate? predicate = null) { - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, aabb); - return enumerator; + return GetLocalTilesIntersecting(uid, grid, aabb, ignoreEmpty, predicate); } + [Obsolete("Use GetTilesIntersecting instead.")] public TilesEnumerator GetTilesEnumerator(EntityUid uid, MapGridComponent grid, Box2 aabb, bool ignoreEmpty = true, Predicate? predicate = null) { - var invMatrix = _transform.GetInvWorldMatrix(uid); - var localAABB = invMatrix.TransformBox(aabb); - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localAABB); - return enumerator; + return GetTilesIntersecting(uid, grid, aabb, ignoreEmpty, predicate); } + [Obsolete("Use GetTilesIntersecting instead.")] public TilesEnumerator GetTilesEnumerator(EntityUid uid, MapGridComponent grid, Box2Rotated bounds, bool ignoreEmpty = true, Predicate? predicate = null) { - var invMatrix = _transform.GetInvWorldMatrix(uid); - var localAABB = invMatrix.TransformBox(bounds); - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localAABB); - return enumerator; + return GetTilesIntersecting(uid, grid, bounds, ignoreEmpty, predicate); } - public IEnumerable GetLocalTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2 localAABB, bool ignoreEmpty = true, + public TilesEnumerator GetLocalTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2 localAABB, bool ignoreEmpty = true, Predicate? predicate = null) { - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localAABB); - - while (enumerator.MoveNext(out var tileRef)) - { - yield return tileRef; - } + return new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localAABB); } - public IEnumerable GetLocalTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2Rotated localArea, bool ignoreEmpty = true, + public TilesEnumerator GetLocalTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2Rotated localArea, bool ignoreEmpty = true, Predicate? predicate = null) { var localAABB = localArea.CalcBoundingBox(); - - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localAABB); - - while (enumerator.MoveNext(out var tileRef)) - { - yield return tileRef; - } + return new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localAABB); } - public IEnumerable GetTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2Rotated worldArea, bool ignoreEmpty = true, + public TilesEnumerator GetTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2Rotated worldArea, bool ignoreEmpty = true, Predicate? predicate = null) { var matrix = _transform.GetInvWorldMatrix(uid); var localArea = matrix.TransformBox(worldArea); - - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localArea); - - while (enumerator.MoveNext(out var tileRef)) - { - yield return tileRef; - } + return new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localArea); } - public IEnumerable GetTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2 worldArea, bool ignoreEmpty = true, + public TilesEnumerator GetTilesIntersecting(EntityUid uid, MapGridComponent grid, Box2 worldArea, bool ignoreEmpty = true, Predicate? predicate = null) { var matrix = _transform.GetInvWorldMatrix(uid); var localArea = matrix.TransformBox(worldArea); - - var enumerator = new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localArea); - - while (enumerator.MoveNext(out var tileRef)) - { - yield return tileRef; - } + return new TilesEnumerator(this, ignoreEmpty, predicate, uid, grid, localArea); } - public IEnumerable GetLocalTilesIntersecting(EntityUid uid, MapGridComponent grid, Circle localCircle, bool ignoreEmpty = true, + public CircleTilesEnumerator GetLocalTilesIntersecting(EntityUid uid, MapGridComponent grid, Circle localCircle, bool ignoreEmpty = true, Predicate? predicate = null) { var aabb = new Box2(localCircle.Position.X - localCircle.Radius, localCircle.Position.Y - localCircle.Radius, localCircle.Position.X + localCircle.Radius, localCircle.Position.Y + localCircle.Radius); - var tileEnumerator = GetLocalTilesEnumerator(uid, grid, aabb, ignoreEmpty, predicate); - - while (tileEnumerator.MoveNext(out var tile)) - { - var tileCenter = tile.GridIndices + grid.TileSizeHalfVector; - var direction = tileCenter - localCircle.Position; - - if (direction.IsShorterThanOrEqualTo(localCircle.Radius)) - { - yield return tile; - } - } + var tileEnumerator = GetLocalTilesIntersecting(uid, grid, aabb, ignoreEmpty, predicate); + return new CircleTilesEnumerator(tileEnumerator, grid, localCircle.Position, localCircle.Radius); } - public IEnumerable GetTilesIntersecting(EntityUid uid, MapGridComponent grid, Circle worldArea, bool ignoreEmpty = true, + public CircleTilesEnumerator GetTilesIntersecting(EntityUid uid, MapGridComponent grid, Circle worldArea, bool ignoreEmpty = true, Predicate? predicate = null) { - var aabb = new Box2(worldArea.Position.X - worldArea.Radius, worldArea.Position.Y - worldArea.Radius, - worldArea.Position.X + worldArea.Radius, worldArea.Position.Y + worldArea.Radius); - var circleGridPos = new EntityCoordinates(uid, WorldToLocal(uid, grid, worldArea.Position)); + var localPosition = WorldToLocal(uid, grid, worldArea.Position); + var aabb = new Box2(localPosition.X - worldArea.Radius, localPosition.Y - worldArea.Radius, + localPosition.X + worldArea.Radius, localPosition.Y + worldArea.Radius); - foreach (var tile in GetTilesIntersecting(uid, grid, aabb, ignoreEmpty, predicate)) - { - var local = GridTileToLocal(uid, grid, tile.GridIndices); - - if (!local.TryDistance(EntityManager, _transform, circleGridPos, out var distance)) - { - continue; - } - - if (distance <= worldArea.Radius) - { - yield return tile; - } - } + var tileEnumerator = GetLocalTilesIntersecting(uid, grid, aabb, ignoreEmpty, predicate); + return new CircleTilesEnumerator(tileEnumerator, grid, localPosition, worldArea.Radius); } private bool TryGetTile(EntityUid uid, MapGridComponent grid, Vector2i indices, bool ignoreEmpty, [NotNullWhen(true)] out TileRef? tileRef, Predicate? predicate = null) @@ -1207,42 +1146,45 @@ public int AnchoredEntityCount(EntityUid uid, MapGridComponent grid, Vector2i po return chunk.GetSnapGrid((ushort)x, (ushort)y)?.Count ?? 0; // ? } - public IEnumerable GetAnchoredEntities(Entity grid, MapCoordinates coords) + public AnchoredEntitiesEnumerator GetAnchoredEntities(Entity grid, MapCoordinates coords) { return GetAnchoredEntities(grid.Owner, grid.Comp, coords); } - public IEnumerable GetAnchoredEntities(EntityUid uid, MapGridComponent grid, MapCoordinates coords) + public AnchoredEntitiesEnumerator GetAnchoredEntities(EntityUid uid, MapGridComponent grid, MapCoordinates coords) { return GetAnchoredEntities(uid, grid, TileIndicesFor(uid, grid, coords)); } - public IEnumerable GetAnchoredEntities(Entity grid, EntityCoordinates coords) + public AnchoredEntitiesEnumerator GetAnchoredEntities(Entity grid, EntityCoordinates coords) { return GetAnchoredEntities(grid.Owner, grid.Comp, coords); } - public IEnumerable GetAnchoredEntities(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) + public AnchoredEntitiesEnumerator GetAnchoredEntities(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) { return GetAnchoredEntities(uid, grid, TileIndicesFor(uid, grid, coords)); } - public IEnumerable GetAnchoredEntities(Entity grid, Vector2i pos) + public AnchoredEntitiesEnumerator GetAnchoredEntities(Entity grid, Vector2i pos) { return GetAnchoredEntities(grid.Owner, grid.Comp, pos); } - public IEnumerable GetAnchoredEntities(EntityUid uid, MapGridComponent grid, Vector2i pos) + public AnchoredEntitiesEnumerator GetAnchoredEntities(EntityUid uid, MapGridComponent grid, Vector2i pos) { // Because some content stuff checks neighboring tiles (which may not actually exist) we won't just // create an entire chunk for it. var gridChunkPos = GridTileToChunkIndices(uid, grid, pos); if (!grid.Chunks.TryGetValue(gridChunkPos, out var chunk)) - return Enumerable.Empty(); + return AnchoredEntitiesEnumerator.Empty; var chunkTile = chunk.GridTileToChunkTile(pos); - return chunk.GetSnapGridCell((ushort)chunkTile.X, (ushort)chunkTile.Y); + var snapgrid = chunk.GetSnapGrid((ushort)chunkTile.X, (ushort)chunkTile.Y); + return snapgrid == null + ? AnchoredEntitiesEnumerator.Empty + : new AnchoredEntitiesEnumerator(snapgrid.GetEnumerator()); } public void GetAnchoredEntities(Entity grid, Vector2i pos, List list) @@ -1257,61 +1199,30 @@ public void GetAnchoredEntities(Entity grid, Vector2i pos, Lis list.AddRange(anchored); } + [Obsolete("Use GetAnchoredEntities instead.")] public AnchoredEntitiesEnumerator GetAnchoredEntitiesEnumerator(EntityUid uid, MapGridComponent grid, Vector2i pos) { - var gridChunkPos = GridTileToChunkIndices(uid, grid, pos); - - if (!grid.Chunks.TryGetValue(gridChunkPos, out var chunk)) return AnchoredEntitiesEnumerator.Empty; - - var chunkTile = chunk.GridTileToChunkTile(pos); - var snapgrid = chunk.GetSnapGrid((ushort)chunkTile.X, (ushort)chunkTile.Y); - - return snapgrid == null - ? AnchoredEntitiesEnumerator.Empty - : new AnchoredEntitiesEnumerator(snapgrid.GetEnumerator()); + return GetAnchoredEntities(uid, grid, pos); } - public IEnumerable GetLocalAnchoredEntities(EntityUid uid, MapGridComponent grid, Box2 localAABB) + public AnchoredEntitiesInTilesEnumerator GetLocalAnchoredEntities(EntityUid uid, MapGridComponent grid, Box2 localAABB) { var enumerator = new TilesEnumerator(this, true, null, uid, grid, localAABB); - - while (enumerator.MoveNext(out var tileRef)) - { - var anchoredEnumerator = GetAnchoredEntitiesEnumerator(uid, grid, tileRef.GridIndices); - - while (anchoredEnumerator.MoveNext(out var ent)) - { - yield return ent.Value; - } - } + return new AnchoredEntitiesInTilesEnumerator(this, uid, grid, enumerator); } - public IEnumerable GetAnchoredEntities(EntityUid uid, MapGridComponent grid, Box2 worldAABB) + public AnchoredEntitiesInTilesEnumerator GetAnchoredEntities(EntityUid uid, MapGridComponent grid, Box2 worldAABB) { var invWorldMatrix = _transform.GetInvWorldMatrix(uid); var localAABB = invWorldMatrix.TransformBox(worldAABB); var enumerator = new TilesEnumerator(this, true, null, uid, grid, localAABB); - - while (enumerator.MoveNext(out var tileRef)) - { - var anchoredEnumerator = GetAnchoredEntitiesEnumerator(uid, grid, tileRef.GridIndices); - - while (anchoredEnumerator.MoveNext(out var ent)) - { - yield return ent.Value; - } - } + return new AnchoredEntitiesInTilesEnumerator(this, uid, grid, enumerator); } - public IEnumerable GetAnchoredEntities(EntityUid uid, MapGridComponent grid, Box2Rotated worldBounds) + public AnchoredEntitiesInTilesEnumerator GetAnchoredEntities(EntityUid uid, MapGridComponent grid, Box2Rotated worldBounds) { - foreach (var tile in GetTilesIntersecting(uid, grid, worldBounds)) - { - foreach (var ent in GetAnchoredEntities(uid, grid, tile.GridIndices)) - { - yield return ent; - } - } + var tiles = GetTilesIntersecting(uid, grid, worldBounds); + return new AnchoredEntitiesInTilesEnumerator(this, uid, grid, tiles); } public Vector2i TileIndicesFor(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) @@ -1410,19 +1321,19 @@ private bool TryChunkAndOffsetForTile(EntityUid uid, MapGridComponent grid, Vect return true; } - public IEnumerable GetInDir(EntityUid uid, MapGridComponent grid, EntityCoordinates position, Direction dir) + public AnchoredEntitiesEnumerator GetInDir(EntityUid uid, MapGridComponent grid, EntityCoordinates position, Direction dir) { var pos = GetDirection(TileIndicesFor(uid, grid, position), dir); return GetAnchoredEntities(uid, grid, pos); } - public IEnumerable GetOffset(EntityUid uid, MapGridComponent grid, EntityCoordinates coords, Vector2i offset) + public AnchoredEntitiesEnumerator GetOffset(EntityUid uid, MapGridComponent grid, EntityCoordinates coords, Vector2i offset) { var pos = TileIndicesFor(uid, grid, coords) + offset; return GetAnchoredEntities(uid, grid, pos); } - public IEnumerable GetLocal(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) + public AnchoredEntitiesEnumerator GetLocal(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) { return GetAnchoredEntities(uid, grid, TileIndicesFor(uid, grid, coords)); } @@ -1432,35 +1343,220 @@ public EntityCoordinates DirectionToGrid(EntityUid uid, MapGridComponent grid, E return GridTileToLocal(uid, grid, GetDirection(TileIndicesFor(uid, grid, coords), direction)); } - public IEnumerable GetCardinalNeighborCells(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) + public AnchoredEntitiesInTileOffsetsEnumerator GetCardinalNeighborCells(EntityUid uid, MapGridComponent grid, EntityCoordinates coords) { var position = TileIndicesFor(uid, grid, coords); - foreach (var cell in GetAnchoredEntities(uid, grid, position)) - yield return cell; - foreach (var cell in GetAnchoredEntities(uid, grid, position + new Vector2i(0, 1))) - yield return cell; - foreach (var cell in GetAnchoredEntities(uid, grid, position + new Vector2i(0, -1))) - yield return cell; - foreach (var cell in GetAnchoredEntities(uid, grid, position + new Vector2i(1, 0))) - yield return cell; - foreach (var cell in GetAnchoredEntities(uid, grid, position + new Vector2i(-1, 0))) - yield return cell; + return AnchoredEntitiesInTileOffsetsEnumerator.Cardinal(this, uid, grid, position); } - public IEnumerable GetCellsInSquareArea(EntityUid uid, MapGridComponent grid, EntityCoordinates coords, int n) + public AnchoredEntitiesInTileOffsetsEnumerator GetCellsInSquareArea(EntityUid uid, MapGridComponent grid, EntityCoordinates coords, int n) { var position = TileIndicesFor(uid, grid, coords); + return AnchoredEntitiesInTileOffsetsEnumerator.Square(this, uid, grid, position, n); + } - for (var y = -n; y <= n; ++y) - for (var x = -n; x <= n; ++x) + public struct AnchoredEntitiesInTileOffsetsEnumerator : IEnumerable, IEnumerator + { + private readonly SharedMapSystem _map; + private readonly EntityUid _uid; + private readonly MapGridComponent _grid; + private readonly Vector2i _position; + private readonly int _n; + private readonly bool _cardinal; + private int _index; + private int _x; + private int _y; + private AnchoredEntitiesEnumerator _anchored; + private EntityUid _current; + + private AnchoredEntitiesInTileOffsetsEnumerator( + SharedMapSystem map, + EntityUid uid, + MapGridComponent grid, + Vector2i position, + int n, + bool cardinal) { - var enumerator = GetAnchoredEntitiesEnumerator(uid, grid, position + new Vector2i(x, y)); + _map = map; + _uid = uid; + _grid = grid; + _position = position; + _n = n; + _cardinal = cardinal; + _index = -1; + _x = -n - 1; + _y = -n; + _anchored = default; + _current = default; + } + + internal static AnchoredEntitiesInTileOffsetsEnumerator Cardinal( + SharedMapSystem map, + EntityUid uid, + MapGridComponent grid, + Vector2i position) + { + return new AnchoredEntitiesInTileOffsetsEnumerator(map, uid, grid, position, 0, true); + } + + internal static AnchoredEntitiesInTileOffsetsEnumerator Square( + SharedMapSystem map, + EntityUid uid, + MapGridComponent grid, + Vector2i position, + int n) + { + return new AnchoredEntitiesInTileOffsetsEnumerator(map, uid, grid, position, n, false); + } + + public readonly AnchoredEntitiesInTileOffsetsEnumerator GetEnumerator() => this; + + public readonly EntityUid Current => _current; - while (enumerator.MoveNext(out var cell)) + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (true) { - yield return cell.Value; + if (_anchored.MoveNext(out var current)) + { + _current = current.Value; + return true; + } + + if (!TryGetNextOffset(out var offset)) + return false; + + _anchored = _map.GetAnchoredEntities(_uid, _grid, _position + offset); } } + + private bool TryGetNextOffset(out Vector2i offset) + { + if (_cardinal) + { + _index++; + offset = _index switch + { + 0 => Vector2i.Zero, + 1 => new Vector2i(0, 1), + 2 => new Vector2i(0, -1), + 3 => new Vector2i(1, 0), + 4 => new Vector2i(-1, 0), + _ => default, + }; + + return _index < 5; + } + + _x++; + if (_x > _n) + { + _x = -_n; + _y++; + } + + if (_y > _n) + { + offset = default; + return false; + } + + offset = new Vector2i(_x, _y); + return true; + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public readonly void Dispose() + { + } + + public void Reset() + { + throw new NotSupportedException(); + } + } + + [Obsolete("Use AnchoredEntitiesInTileOffsetsEnumerator instead.")] + public struct CardinalNeighborCellsEnumerator + { + } + + [Obsolete("Use AnchoredEntitiesInTileOffsetsEnumerator instead.")] + public struct SquareAreaCellsEnumerator + { + } + + public struct AnchoredEntitiesInTilesEnumerator : IEnumerable, IEnumerator + { + private readonly SharedMapSystem _map; + private readonly EntityUid _uid; + private readonly MapGridComponent _grid; + private TilesEnumerator _tiles; + private AnchoredEntitiesEnumerator _anchored; + private EntityUid _current; + + internal AnchoredEntitiesInTilesEnumerator(SharedMapSystem map, EntityUid uid, MapGridComponent grid, TilesEnumerator tiles) + { + _map = map; + _uid = uid; + _grid = grid; + _tiles = tiles; + _anchored = default; + _current = default; + } + + public readonly AnchoredEntitiesInTilesEnumerator GetEnumerator() => this; + + public readonly EntityUid Current => _current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (true) + { + if (_anchored.MoveNext(out var current)) + { + _current = current.Value; + return true; + } + + if (!_tiles.MoveNext(out var tile)) + return false; + + _anchored = _map.GetAnchoredEntities(_uid, _grid, tile.GridIndices); + } + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public readonly void Dispose() + { + } + + public void Reset() + { + throw new NotSupportedException(); + } } #endregion @@ -1739,7 +1835,7 @@ internal void RaiseOnTileChanged(Entity entity, TileRef tileRe /// /// Iterates the local tiles of the specified data. /// - public struct TilesEnumerator + public struct TilesEnumerator : IEnumerable, IEnumerator { private readonly SharedMapSystem _mapSystem; @@ -1754,6 +1850,7 @@ public struct TilesEnumerator private int _x; private int _y; + private TileRef _current; public TilesEnumerator( SharedMapSystem mapSystem, @@ -1781,6 +1878,18 @@ public TilesEnumerator( _lowerY = gridTileLb.Y; _upperX = gridTileRt.X; _upperY = gridTileRt.Y; + _current = default; + } + + public readonly TilesEnumerator GetEnumerator() => this; + + public readonly TileRef Current => _current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + return MoveNext(out _); } public bool MoveNext(out TileRef tile) @@ -1815,6 +1924,7 @@ public bool MoveNext(out TileRef tile) if (_predicate == null || _predicate(tile)) { + _current = tile; return true; } } @@ -1824,11 +1934,91 @@ public bool MoveNext(out TileRef tile) if (_predicate == null || _predicate(tile)) { + _current = tile; return true; } } } } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public readonly void Dispose() + { + } + + public void Reset() + { + throw new NotSupportedException(); + } + } + + public struct CircleTilesEnumerator : IEnumerable, IEnumerator + { + private TilesEnumerator _tiles; + private readonly MapGridComponent _grid; + private readonly Vector2 _center; + private readonly float _radius; + private TileRef _current; + + internal CircleTilesEnumerator(TilesEnumerator tiles, MapGridComponent grid, Vector2 center, float radius) + { + _tiles = tiles; + _grid = grid; + _center = center; + _radius = radius; + _current = default; + } + + public readonly CircleTilesEnumerator GetEnumerator() => this; + + public readonly TileRef Current => _current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (_tiles.MoveNext(out var tile)) + { + var tileCenter = tile.GridIndices + _grid.TileSizeHalfVector; + var direction = tileCenter - _center; + + if (!direction.IsShorterThanOrEqualTo(_radius)) + continue; + + _current = tile; + return true; + } + + return false; + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public readonly void Dispose() + { + } + + public void Reset() + { + throw new NotSupportedException(); + } } } diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.GridChunk.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.GridChunk.cs index 11ee8243e..3b15e662c 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.GridChunk.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.GridChunk.cs @@ -16,7 +16,18 @@ public abstract partial class SharedMapSystem /// The new tile to insert. 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 oldTile, out var shapeChanged)) + return SetChunkTile(uid, grid, chunk, xIndex, yIndex, tile, out oldTile, out _); + } + + /// + /// Replaces a single tile inside of the chunk. + /// + /// The X tile index relative to the chunk. + /// The Y tile index relative to the chunk. + /// The new tile to insert. + internal bool SetChunkTile(EntityUid uid, MapGridComponent grid, MapChunk chunk, ushort xIndex, ushort yIndex, Tile tile, out Tile oldTile, out bool shapeChanged) + { + if (!chunk.TrySetTile(xIndex, yIndex, tile, out oldTile, out shapeChanged)) return false; var tileIndices = new Vector2i(xIndex, yIndex); diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs index 268247422..1f7fa74d1 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.Map.cs @@ -310,7 +310,7 @@ public void QueueDeleteMap(MapId mapId) QueueDel(uid); } - public IEnumerable GetAllMapIds() + public Dictionary.KeyCollection GetAllMapIds() { return Maps.Keys; } diff --git a/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs b/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs index 9564f3b84..5cff92e0f 100644 --- a/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs +++ b/Robust.Shared/GameObjects/Systems/SharedMapSystem.cs @@ -163,10 +163,12 @@ public GridRemovalEvent(EntityUid uid) public sealed class GridInitializeEvent : EntityEventArgs { public EntityUid EntityUid { get; } + public MapGridComponent Grid { get; } - public GridInitializeEvent(EntityUid uid) + public GridInitializeEvent(EntityUid uid, MapGridComponent grid) { EntityUid = uid; + Grid = grid; } } #pragma warning restore CS0618 diff --git a/Robust.Shared/GameStates/ChunkEntitySystem.cs b/Robust.Shared/GameStates/ChunkEntitySystem.cs index f891af3f0..858633261 100644 --- a/Robust.Shared/GameStates/ChunkEntitySystem.cs +++ b/Robust.Shared/GameStates/ChunkEntitySystem.cs @@ -31,6 +31,7 @@ public abstract partial class ChunkEntitySystem : EntitySystem [Dependency] private EntityQuery _metaQuery; [Dependency] private EntityQuery _mapQuery; [Dependency] private EntityQuery _gridQuery; + [Dependency] private EntityQuery _xformQuery; [Dependency] private EntityQuery _containerQuery; [Dependency] private MetaDataSystem _metaData = default!; @@ -51,6 +52,8 @@ public override void Initialize() SubscribeLocalEvent(OnChunkTerminating); SubscribeLocalEvent(OnChunkHandleState); SubscribeLocalEvent(OnContainerMapInit); + SubscribeLocalEvent(OnRootPaused); + SubscribeLocalEvent(OnRootUnpaused); SubscribeLocalEvent(OnBeforeSerialization); SubscribeLocalEvent(OnMapRemoved); SubscribeLocalEvent(OnGridRemoved); @@ -231,6 +234,16 @@ private void OnContainerMapInit(Entity ent, ref MapInit _tempUids.Clear(); } + private void OnRootPaused(Entity ent, ref EntityPausedEvent args) + { + SyncRootChunks(ent.Comp); + } + + private void OnRootUnpaused(Entity ent, ref EntityUnpausedEvent args) + { + SyncRootChunks(ent.Comp); + } + private void OnMapRemoved(MapRemovedEvent ev) { DeleteRootChunks(ev.Uid); @@ -306,7 +319,26 @@ private void SyncChunkToRoot(Entity chu EntityManager.RunMapInit(chunk.Owner, chunk.Comp2); } - _metaData.SetEntityPaused(chunk.Owner, rootMeta.EntityPaused, chunk.Comp2); + _metaData.SetEntityPaused(chunk.Owner, IsRootPaused(chunk.Comp1.Root, rootMeta), chunk.Comp2); + } + + private bool IsRootPaused(EntityUid root, MetaDataComponent rootMeta) + { + if (rootMeta.EntityPaused) + return true; + + if (_mapQuery.TryComp(root, out var map)) + return map.MapPaused || !map.MapInitialized; + + if (!_gridQuery.HasComp(root) || + !_xformQuery.TryComp(root, out var xform) || + xform.MapUid is not { } mapUid || + !_mapQuery.TryComp(mapUid, out map)) + { + return false; + } + + return map.MapPaused || !map.MapInitialized; } private void AddRootChunks(ChunkContainerComponent container, HashSet entities) diff --git a/Robust.Shared/GameStates/ComponentStateEvents.cs b/Robust.Shared/GameStates/ComponentStateEvents.cs index 1ad861281..6052bc2a4 100644 --- a/Robust.Shared/GameStates/ComponentStateEvents.cs +++ b/Robust.Shared/GameStates/ComponentStateEvents.cs @@ -45,6 +45,11 @@ public struct ComponentGetState /// public readonly ICommonSession? Player; + /// + /// Whether or not this state should be saved to replays, even if null. + /// + public bool ExcludeReplays; + public ComponentGetState(ICommonSession? player, GameTick fromTick) { Player = player; diff --git a/Robust.Shared/IoC/DependencyCollection.cs b/Robust.Shared/IoC/DependencyCollection.cs index 57ae6068b..8e53bf386 100644 --- a/Robust.Shared/IoC/DependencyCollection.cs +++ b/Robust.Shared/IoC/DependencyCollection.cs @@ -47,6 +47,12 @@ private delegate T DependencyFactoryDelegateInternal( /// private FrozenDictionary _services = FrozenDictionary.Empty; + /// + /// Array of all service implementations. + /// Indexed by + /// + private object[] _servicesArray = Array.Empty(); + /// /// Dictionary that maps the types passed to to their implementation /// for any types registered through . @@ -66,7 +72,7 @@ private delegate T DependencyFactoryDelegateInternal( private readonly ConcurrentDictionary> _baseGenericLazyFactories = new(); - private readonly object _serviceBuildLock = new(); + private readonly Lock _serviceBuildLock = new(); // End fields for building new services. @@ -296,7 +302,7 @@ private void CheckRegisterInterface(Type interfaceType, Type implementationType, { lock (_serviceBuildLock) { - if (!_resolveTypes.ContainsKey(interfaceType)) + if (!_resolveTypes.TryGetValue(interfaceType, out var type)) return; if (!overwrite) @@ -305,7 +311,7 @@ private void CheckRegisterInterface(Type interfaceType, Type implementationType, ( string.Format( "Attempted to register already registered interface {0}. New implementation: {1}, Old implementation: {2}", - interfaceType, implementationType, _resolveTypes[interfaceType] + interfaceType, implementationType, type )); } @@ -354,6 +360,7 @@ public void Clear() _services = FrozenDictionary.Empty; _lazyServices.Clear(); + _servicesArray = []; lock (_serviceBuildLock) { @@ -372,7 +379,48 @@ public void Clear() [System.Diagnostics.Contracts.Pure] public T Resolve() { - return (T)ResolveType(typeof(T)); + if (typeof(T) == typeof(IDependencyCollection)) + { + if (TryResolveType(typeof(T), out var collection)) + return (T) collection; + + return (T) (IDependencyCollection) this; + } + + var index = DependencyType.Index; + if (index < _servicesArray.Length && + _servicesArray.TryGetValue(index, out var service) && + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + service != null) + { + return (T) service; + } + + var resolved = (T) ResolveType(typeof(T)); + lock (_serviceBuildLock) + { + // Re-check after we obtain the lock that this is still relevant + // We don't want to accidentally down-size it in a thread race. + EnsureServicesArrayCapacity(index); + + // This might be a lazy-generated service, such as EntityQuery + // In that case, we index it now + _servicesArray[index] = resolved; + } + + return resolved; + } + + public T ResolveInject(Type owningType) + { + try + { + return Resolve(); + } + catch (UnregisteredTypeException) + { + throw new UnregisteredDependencyException(owningType, typeof(T)); + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -438,6 +486,13 @@ public void BuildGraph() var injectList = new List(); var newDeps = _services.ToDictionary(); + var reverse = new Dictionary(); + + foreach (var serviceType in _services.Keys) + { + if (_resolveTypes.TryGetValue(serviceType, out var implementationType)) + reverse.TryAdd(implementationType, serviceType); + } // First we build every type we have registered but isn't yet built. // This allows us to run this after the content assembly has been loaded. @@ -449,12 +504,7 @@ public void BuildGraph() // Find a potential dupe by checking other registered types that have already been instantiated that have the same instance type. // Can't catch ourselves because we're not instantiated. // Ones that aren't yet instantiated are about to be and will find us instead. - var (type, _) = - _resolveTypes.FirstOrDefault(p => newDeps.ContainsKey(p.Key) && p.Value == value)!; - - // Interface key can't be null so since KeyValuePair<> is a struct, - // this effectively checks whether we found something. - if (type != null) + if (reverse.TryGetValue(value, out var type)) { // We have something with the same instance type, use that. newDeps[key] = newDeps[type]; @@ -464,8 +514,9 @@ public void BuildGraph() try { // Yay for delegate covariance - object instance = _resolveFactories[value].Invoke(newDeps); + var instance = _resolveFactories[value].Invoke(newDeps); newDeps[key] = instance; + reverse[value] = key; injectList.Add(instance); } catch (TargetInvocationException e) @@ -481,6 +532,41 @@ public void BuildGraph() // Atomically set the new dict of services. _services = newDeps.ToFrozenDictionary(); + // Need to account for dependency collections that might not have all services + _servicesArray = new object[Math.Max(newDeps.Count, DependencyType.Index + 1)]; + + foreach (var (type, inst) in _services) + { + var index = DependencyType.GetIndex(type); + EnsureServicesArrayCapacity(index); + + _servicesArray[index] = inst; + } + + // Index parent collections for their services, recursively + var parent = _parentCollection as DependencyCollection; + while (parent != null) + { + for (var i = 0; i < parent._servicesArray.Length; i++) + { + var parentService = parent._servicesArray[i]; + + // We don't want to overwrite our services with null if the parent doesn't have them + // We also don't want to overwite our services with the parent's + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (parentService != null) + { + EnsureServicesArrayCapacity(i); + + if (_servicesArray[i] == null) + _servicesArray[i] = parentService; + } + // ReSharper restore ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + } + + parent = parent._parentCollection as DependencyCollection; + } + // Graph built, go over ones that need injection. foreach (var implementation in injectList) { @@ -494,6 +580,15 @@ public void BuildGraph() } } + private void EnsureServicesArrayCapacity(int index) + { + if (index < _servicesArray.Length) + return; + + var newLength = Math.Max(index + 1, Math.Max(4, _servicesArray.Length * 2)); + Array.Resize(ref _servicesArray, newLength); + } + /// public void InjectDependencies(object obj, bool oneOff = false) { @@ -521,12 +616,12 @@ public void InjectDependencies(object obj, bool oneOff = false) var (@delegate, hasDependencies, services) = injector; - if (services?.Length == 0) + if (!hasDependencies && services?.Length == 0) return; if (hasDependencies) { - ((IHasDependencies)obj).Inject(services); + ((IHasDependencies)obj).Inject(this); } // If @delegate is null then the type has no dependencies. @@ -543,8 +638,8 @@ private object ResolveForInjection(Type owningType, Type fieldType, FrozenDictio return dep; } - // A hard-coded special case so the DependencyCollection can inject itself. - // This is not put into the services so it can be overridden if needed. + // IDependencyCollection resolves to this collection by default, + // while allowing an explicitly registered service to override it. if (fieldType == typeof(IDependencyCollection)) { return this; @@ -578,9 +673,7 @@ private void InjectImmediateHasDependencies(object obj) if (obj is not IHasDependencies hasDependencies) return; - var types = hasDependencies.GetDependencyTypes(); - var services = ResolveServicesArray(obj.GetType(), types); - hasDependencies.Inject(services); + hasDependencies.Inject(this); } private CachedInjector CacheInjector(object obj, Type type) @@ -630,8 +723,8 @@ private CachedInjector CacheInjector(object obj, Type type) // Not using Resolve() because we're literally building it right now. if (!TryResolveType(field.FieldType, out var service)) { - // A hard-coded special case so the DependencyCollection can inject itself. - // This is not put into the services so it can be overridden if needed. + // IDependencyCollection resolves to this collection by default, + // while allowing an explicitly registered service to override it. if (field.FieldType == typeof(IDependencyCollection)) { service = this; @@ -666,14 +759,12 @@ private CachedInjector CacheInjector(object obj, Type type) private CachedInjector CacheInjectorHasDependencies(object obj, Type type) { DebugTools.Assert(type == obj.GetType()); + var cached = obj is IHasDependencies + ? new CachedInjector(null, true, null) + : default; - if (obj is not IHasDependencies hasDeps) - return new CachedInjector(null, true, []); - - var types = hasDeps.GetDependencyTypes(); - var services = ResolveServicesArray(type, types); - - return new CachedInjector(null, true, services); + _injectorCache.Add(type, cached); + return cached; } private object[] ResolveServicesArray(Type owningType, Type[] types) diff --git a/Robust.Shared/IoC/DependencyType.cs b/Robust.Shared/IoC/DependencyType.cs new file mode 100644 index 000000000..0b61b5a7d --- /dev/null +++ b/Robust.Shared/IoC/DependencyType.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; +using System.Threading; + +namespace Robust.Shared.IoC; + +internal static class DependencyType +{ + internal static int Index = -1; + + /// + /// Equivalent to , in cases where the type cannot be known at compile time. + /// + /// The type of the service. + /// The index for the given type of service. + internal static int GetIndex(Type type) + { + var depType = typeof(DependencyType<>).MakeGenericType(type); + var prop = depType.GetField("Index", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + return (int) prop.GetValue(null)!; + } +} + +/// +/// Stores the index that is used to index this service type in , +/// if it is present in that dependency collection. +/// The index is incremented in a thread-safe manner, referencing +/// Providing a different service type will give you a different index. +/// +/// The type of the service +// ReSharper disable once UnusedTypeParameter +internal static class DependencyType +{ + // ReSharper disable once StaticMemberInGenericType + internal static readonly int Index = Interlocked.Increment(ref DependencyType.Index); +} diff --git a/Robust.Shared/IoC/IDependencyCollection.cs b/Robust.Shared/IoC/IDependencyCollection.cs index af1d39297..b519f0a1e 100644 --- a/Robust.Shared/IoC/IDependencyCollection.cs +++ b/Robust.Shared/IoC/IDependencyCollection.cs @@ -159,6 +159,9 @@ void Register(Type interfaceType, Type implementation, DependencyFactoryDelegate [System.Diagnostics.Contracts.Pure] T Resolve(); + [Pure] + T ResolveInject(Type owningType); + /// void Resolve([NotNull] ref T? instance); diff --git a/Robust.Shared/IoC/IHasDependencies.cs b/Robust.Shared/IoC/IHasDependencies.cs index dddd729ff..f0852e013 100644 --- a/Robust.Shared/IoC/IHasDependencies.cs +++ b/Robust.Shared/IoC/IHasDependencies.cs @@ -18,19 +18,13 @@ namespace Robust.Shared.IoC; [NotContentImplementable] public interface IHasDependencies { - /// - /// Get an array of types that this object wants to have resolved and injected. - /// - Type[] GetDependencyTypes(); - /// /// Inject services into this type. /// /// - /// The list of services to inject. - /// This is the same length and order as the types returned by . + /// The list of services to inject, indexed by /// - void Inject(ReadOnlySpan instances); + void Inject(IDependencyCollection dependencies); } /// diff --git a/Robust.Shared/Map/Enumerators/AnchoredEntitiesEnumerator.cs b/Robust.Shared/Map/Enumerators/AnchoredEntitiesEnumerator.cs index 3175f7efe..5f5a09e72 100644 --- a/Robust.Shared/Map/Enumerators/AnchoredEntitiesEnumerator.cs +++ b/Robust.Shared/Map/Enumerators/AnchoredEntitiesEnumerator.cs @@ -1,32 +1,52 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Robust.Shared.GameObjects; namespace Robust.Shared.Map.Enumerators; -public struct AnchoredEntitiesEnumerator : IDisposable +public struct AnchoredEntitiesEnumerator : IEnumerable, IEnumerator { // ReSharper disable once CollectionNeverUpdated.Local private static readonly List Dummy = new(); public static readonly AnchoredEntitiesEnumerator Empty = new(Dummy.GetEnumerator()); private List.Enumerator _enumerator; + private readonly bool _valid; internal AnchoredEntitiesEnumerator(List.Enumerator enumerator) { _enumerator = enumerator; + _valid = true; + } + + public readonly AnchoredEntitiesEnumerator GetEnumerator() + { + return this; + } + + public readonly EntityUid Current => _enumerator.Current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + if (!_valid) + return false; + + return _enumerator.MoveNext(); } public bool MoveNext([NotNullWhen(true)] out EntityUid? uid) { - if (!_enumerator.MoveNext()) + if (!MoveNext()) { uid = null; return false; } - uid = _enumerator.Current; + uid = Current; return true; } @@ -34,4 +54,19 @@ public void Dispose() { _enumerator.Dispose(); } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Reset() + { + throw new NotSupportedException(); + } } diff --git a/Robust.Shared/Map/Enumerators/GridTileEnumerator.cs b/Robust.Shared/Map/Enumerators/GridTileEnumerator.cs index 76eedfff8..dcaa40091 100644 --- a/Robust.Shared/Map/Enumerators/GridTileEnumerator.cs +++ b/Robust.Shared/Map/Enumerators/GridTileEnumerator.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections; using System.Diagnostics.CodeAnalysis; using Robust.Shared.GameObjects; using Robust.Shared.Maths; @@ -9,13 +10,14 @@ namespace Robust.Shared.Map.Enumerators; /// /// Returns all tiles on a grid. /// -public struct GridTileEnumerator +public struct GridTileEnumerator : IEnumerable, IEnumerator { private readonly EntityUid _gridUid; private Dictionary.Enumerator _chunkEnumerator; private readonly ushort _chunkSize; private int _index; private readonly bool _ignoreEmpty; + private TileRef _current; internal GridTileEnumerator(EntityUid gridUid, Dictionary.Enumerator chunkEnumerator, ushort chunkSize, bool ignoreEmpty) { @@ -24,6 +26,18 @@ internal GridTileEnumerator(EntityUid gridUid, Dictionary.En _chunkSize = chunkSize; _index = _chunkSize * _chunkSize; _ignoreEmpty = ignoreEmpty; + _current = default; + } + + public readonly GridTileEnumerator GetEnumerator() => this; + + public readonly TileRef Current => _current; + + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + return MoveNext(out _); } public bool MoveNext([NotNullWhen(true)] out TileRef? tileRef) @@ -53,8 +67,28 @@ public bool MoveNext([NotNullWhen(true)] out TileRef? tileRef) var gridX = x + chunkOrigin.X * _chunkSize; var gridY = y + chunkOrigin.Y * _chunkSize; - tileRef = new TileRef(_gridUid, gridX, gridY, tile); + _current = new TileRef(_gridUid, gridX, gridY, tile); + tileRef = _current; return true; } } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + readonly IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public readonly void Dispose() + { + } + + public void Reset() + { + throw new System.NotSupportedException(); + } } diff --git a/Robust.Shared/Map/Events/RegenerateGridBoundsEvent.cs b/Robust.Shared/Map/Events/RegenerateGridBoundsEvent.cs index f7236b8ee..8bbe45c4a 100644 --- a/Robust.Shared/Map/Events/RegenerateGridBoundsEvent.cs +++ b/Robust.Shared/Map/Events/RegenerateGridBoundsEvent.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Robust.Shared.GameObjects; +using Robust.Shared.Map.Components; using Robust.Shared.Maths; namespace Robust.Shared.Map.Events; @@ -11,11 +12,4 @@ namespace Robust.Shared.Map.Events; /// Really this exists to get around test dependency creeping. /// [ByRefEvent] -internal readonly record struct RegenerateGridBoundsEvent(EntityUid Entity, Dictionary> ChunkRectangles, List RemovedChunks) -{ - public readonly EntityUid Entity = Entity; - - public readonly Dictionary> ChunkRectangles = ChunkRectangles; - - public readonly List RemovedChunks = RemovedChunks; -} +internal readonly record struct RegenerateGridBoundsEvent(EntityUid Entity, Dictionary> ChunkRectangles, List RemovedChunks, MapGridComponent? Grid = null); diff --git a/Robust.Shared/Network/Messages/MsgEntity.cs b/Robust.Shared/Network/Messages/MsgEntity.cs index add8278d2..c614d7365 100644 --- a/Robust.Shared/Network/Messages/MsgEntity.cs +++ b/Robust.Shared/Network/Messages/MsgEntity.cs @@ -18,8 +18,6 @@ public sealed class MsgEntity : NetMessage public EntityMessageType Type { get; set; } public EntityEventArgs SystemMessage { get; set; } - public EntityUid EntityUid { get; set; } - public uint NetId { get; set; } public uint Sequence { get; set; } public GameTick SourceTick { get; set; } diff --git a/Robust.Shared/Network/Messages/MsgPlacement.cs b/Robust.Shared/Network/Messages/MsgPlacement.cs index b5b8e0e4d..093d2852f 100644 --- a/Robust.Shared/Network/Messages/MsgPlacement.cs +++ b/Robust.Shared/Network/Messages/MsgPlacement.cs @@ -33,6 +33,7 @@ public sealed class MsgPlacement : NetMessage public string ObjType { get; set; } public string AlignOption { get; set; } public Vector2 RectSize { get; set; } + public float RectRotation { get; set; } /// /// Used to determine tile sprite mirroring @@ -71,6 +72,7 @@ public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer case PlacementManagerMessage.RequestRectRemove: NetCoordinates = buffer.ReadNetCoordinates(); RectSize = buffer.ReadVector2(); + RectRotation = buffer.ReadFloat(); break; } } @@ -107,6 +109,7 @@ public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer case PlacementManagerMessage.RequestRectRemove: buffer.Write(NetCoordinates); buffer.Write(RectSize); + buffer.Write(RectRotation); break; } } diff --git a/Robust.Shared/Physics/Collision/Shapes/PolygonShape.cs b/Robust.Shared/Physics/Collision/Shapes/PolygonShape.cs index 3f42381ac..b31d0a926 100644 --- a/Robust.Shared/Physics/Collision/Shapes/PolygonShape.cs +++ b/Robust.Shared/Physics/Collision/Shapes/PolygonShape.cs @@ -31,6 +31,7 @@ using Robust.Shared.Physics.Systems; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; using Robust.Shared.Utility; using Robust.Shared.ViewVariables; @@ -45,7 +46,7 @@ public sealed partial class PolygonShape : IPhysShape, ISerializationHooks, IEqu [ViewVariables] public int VertexCount => Vertices.Length; - [DataField("vertices"), + [DataField("vertices", customTypeSerializer: typeof(PhysicsHullSerializer)), Access(typeof(SharedPhysicsSystem), Friend = AccessPermissions.ReadWriteExecute, Other = AccessPermissions.Read)] public Vector2[] Vertices = Array.Empty(); diff --git a/Robust.Shared/Physics/PhysicsHull.cs b/Robust.Shared/Physics/PhysicsHull.cs index aa66d6daa..1ab37c0eb 100644 --- a/Robust.Shared/Physics/PhysicsHull.cs +++ b/Robust.Shared/Physics/PhysicsHull.cs @@ -8,6 +8,6 @@ public struct PhysicsHull public static Span ComputePoints(ReadOnlySpan points, int count) { var hull = InternalPhysicsHull.ComputeHull(points, count); - return hull.Count == 0 ? Span.Empty : hull.Points.ToArray(); + return hull.Count == 0 ? Span.Empty : hull.Points[..hull.Count].ToArray(); } } diff --git a/Robust.Shared/Physics/Shapes/Polygon.cs b/Robust.Shared/Physics/Shapes/Polygon.cs index e78e98614..182bd1bdf 100644 --- a/Robust.Shared/Physics/Shapes/Polygon.cs +++ b/Robust.Shared/Physics/Shapes/Polygon.cs @@ -113,10 +113,10 @@ public Polygon(Box2Rotated bounds) internal Polygon(ReadOnlySpan vertices, ReadOnlySpan normals, Vector2 centroid, byte count) { Unsafe.SkipInit(out this); - vertices[..VertexCount].CopyTo(_vertices.AsSpan); - normals[..VertexCount].CopyTo(_normals.AsSpan); Centroid = centroid; VertexCount = count; + vertices[..VertexCount].CopyTo(_vertices.AsSpan); + normals[..VertexCount].CopyTo(_normals.AsSpan); Radius = 0f; } @@ -131,12 +131,11 @@ public Polygon(Vector2[] vertices) return; } - VertexCount = (byte) vertices.Length; + VertexCount = (byte) hull.Count; var vertSpan = _vertices.AsSpan; - vertices.AsSpan().CopyTo(vertSpan); Set(hull); - Centroid = ComputeCentroid(vertSpan); + Centroid = ComputeCentroid(vertSpan[..VertexCount]); } public static explicit operator Polygon(PolygonShape polyShape) diff --git a/Robust.Shared/Prototypes/EntityPrototype.cs b/Robust.Shared/Prototypes/EntityPrototype.cs index 45309ff97..0e9df499e 100644 --- a/Robust.Shared/Prototypes/EntityPrototype.cs +++ b/Robust.Shared/Prototypes/EntityPrototype.cs @@ -336,7 +336,7 @@ public override string ToString() return $"EntityPrototype({ID})"; } - public sealed class ComponentRegistryEntry + public readonly struct ComponentRegistryEntry { public IComponent Component { get; } @@ -408,7 +408,7 @@ public ComponentRegistry(Dictionary? LoadedData; + + void ReloadPrototypesOrThrow( + Dictionary> modified, + Dictionary>? removed = null); } /// diff --git a/Robust.Shared/Prototypes/PrototypeManager.Intern.cs b/Robust.Shared/Prototypes/PrototypeManager.Intern.cs new file mode 100644 index 000000000..fa6c715b6 --- /dev/null +++ b/Robust.Shared/Prototypes/PrototypeManager.Intern.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Timing; + +namespace Robust.Shared.Prototypes +{ + public abstract partial class PrototypeManager + { + // Component mappings are immutable after prototype loading finishes. Keeping one component entry for every + // distinct mapping substantially reduces the memory needed by EntityPrototype instances, especially for + // components inherited unchanged by many children. + // TODO: Allow other parented prototype types to opt into this cache + private static readonly ComponentMappingComparer ComponentMappingNodeComparer = new(); + + private FrozenDictionary _entityComponentCache = + FrozenDictionary.Empty; + + /// + /// Store entity prototype component entries by their resolved YAML mapping. + /// + /// Components are copied before they are added to an entity, so sharing shouldn't mutate these. + /// The engine doesn't have an API to actually expose them as readonly but we just trust. + /// + private void RebuildEntityComponentCache() + { + var stopwatch = RStopwatch.StartNew(); + if (!_kinds.TryGetValue(typeof(EntityPrototype), out var entityKind)) + { + _entityComponentCache = FrozenDictionary.Empty; + Sawmill?.Info($"Rebuilding empty entity prototype component cache took {stopwatch.Elapsed.TotalMilliseconds:f2}ms"); + return; + } + + // We re-use the cache across reloads so old unchanged components can still get re-used. + // Anything not re-used gets GC'd when the new cache gets set. + // Realistically we don't even need to keep the dictionary but it's using a tiny amount of memory ATM compared + // to the non-interned approach. + var cache = new Dictionary(ComponentMappingNodeComparer); + + foreach (var (id, mapping) in entityKind.Results) + { + if (!entityKind.Instances.TryGetValue(id, out var instance) + || instance is not EntityPrototype prototype + || !mapping.TryGet("components", out var componentMappings)) + { + continue; + } + + foreach (var componentNode in componentMappings) + { + if (componentNode is not MappingDataNode componentMapping + || !componentMapping.TryGet("type", out var typeNode) + || !prototype.Components.TryGetValue(typeNode.Value, out var component)) + { + continue; + } + + if (!cache.TryGetValue(componentMapping, out var canonical) + && !_entityComponentCache.TryGetValue(componentMapping, out canonical)) + { + canonical = component; + } + + cache.TryAdd(componentMapping, canonical); + prototype.Components[typeNode.Value] = canonical; + } + } + + _entityComponentCache = cache.ToFrozenDictionary(ComponentMappingNodeComparer); + Sawmill?.Info($"Rebuilding entity prototype component cache took {stopwatch.Elapsed.TotalMilliseconds:f2}ms " + + $"({cache.Count} unique component mappings)"); + } + + private sealed class ComponentMappingComparer : IEqualityComparer + { + public bool Equals(MappingDataNode? x, MappingDataNode? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x == null || y == null || x.Count != y.Count || x.Tag != y.Tag) + return false; + + foreach (var (key, value) in x) + { + if (!y.TryGet(key, out var other) || !DataNodesEqual(value, other)) + return false; + } + + return true; + } + + public int GetHashCode(MappingDataNode node) + => node.GetCanonicalHashCode(); + + private static bool DataNodesEqual(DataNode x, DataNode y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x.GetType() != y.GetType() || x.Tag != y.Tag || x.IsNull != y.IsNull) + return false; + + return x switch + { + ValueDataNode value => value.Value == ((ValueDataNode) y).Value, + SequenceDataNode sequence => SequenceEqual(sequence, (SequenceDataNode) y), + MappingDataNode mapping => MappingEqual(mapping, (MappingDataNode) y), + _ => false + }; + } + + private static bool SequenceEqual(SequenceDataNode x, SequenceDataNode y) + { + if (x.Count != y.Count) + return false; + + for (var i = 0; i < x.Count; i++) + { + if (!DataNodesEqual(x[i], y[i])) + return false; + } + + return true; + } + + private static bool MappingEqual(MappingDataNode x, MappingDataNode y) + { + if (x.Count != y.Count) + return false; + + foreach (var (key, value) in x) + { + if (!y.TryGet(key, out var other) || !DataNodesEqual(value, other)) + return false; + } + + return true; + } + + } + } +} diff --git a/Robust.Shared/Prototypes/PrototypeManager.YamlLoad.cs b/Robust.Shared/Prototypes/PrototypeManager.YamlLoad.cs index e92ab3a92..a8e156393 100644 --- a/Robust.Shared/Prototypes/PrototypeManager.YamlLoad.cs +++ b/Robust.Shared/Prototypes/PrototypeManager.YamlLoad.cs @@ -316,6 +316,9 @@ public void RemoveString(string prototypes) } Freeze(modified); + + if (modified.Any(x => x.Type == typeof(EntityPrototype))) + RebuildEntityComponentCache(); } public void AbstractFile(ResPath path) diff --git a/Robust.Shared/Prototypes/PrototypeManager.cs b/Robust.Shared/Prototypes/PrototypeManager.cs index 1b97bc2f7..4afbb4085 100644 --- a/Robust.Shared/Prototypes/PrototypeManager.cs +++ b/Robust.Shared/Prototypes/PrototypeManager.cs @@ -295,6 +295,7 @@ public void Clear() { _kindNames.Clear(); _kinds = FrozenDictionary.Empty; + _entityComponentCache = FrozenDictionary.Empty; } /// @@ -347,6 +348,21 @@ protected void ReloadPrototypes(IEnumerable filePaths) public void ReloadPrototypes( Dictionary> modified, Dictionary>? removed = null) + { + ReloadPrototypes(modified, removed, false); + } + + void IPrototypeManagerInternal.ReloadPrototypesOrThrow( + Dictionary> modified, + Dictionary>? removed) + { + ReloadPrototypes(modified, removed, true); + } + + private void ReloadPrototypes( + Dictionary> modified, + Dictionary>? removed, + bool throwOnFailure) { var prototypeTypeOrder = modified.Keys.ToList(); prototypeTypeOrder.Sort(SortPrototypesByPriority); @@ -355,6 +371,7 @@ public void ReloadPrototypes( var modifiedKinds = new HashSet(); var toProcess = new HashSet(); var processQueue = new Queue(); + var validationContext = throwOnFailure ? new YamlValidationContext(_serializationManager) : null; foreach (var kind in prototypeTypeOrder) { @@ -445,7 +462,15 @@ void AddToQueue(string id) toProcess.Remove(id); - var prototype = TryReadPrototype(kind, id, kindData.Results[id], SerializationHookContext.DontSkipHooks); + if (validationContext != null) + ValidatePrototype(kind, id, kindData.Results[id], validationContext); + + var prototype = TryReadPrototype( + kind, + id, + kindData.Results[id], + SerializationHookContext.DontSkipHooks, + throwOnFailure); if (prototype == null) continue; @@ -463,6 +488,9 @@ void AddToQueue(string id) Freeze(modifiedKinds); + if (modifiedKinds.Any(x => x.Type == typeof(EntityPrototype))) + RebuildEntityComponentCache(); + if (modifiedKinds.Any(x => x.Type == typeof(EntityPrototype) || x.Type == typeof(EntityCategoryPrototype))) UpdateCategories(); @@ -475,6 +503,31 @@ void AddToQueue(string id) _entMan.EventBus.RaiseEvent(EventSource.Local, ev); } + private void ValidatePrototype( + Type kind, + string id, + MappingDataNode mapping, + YamlValidationContext context) + { + var validationMapping = mapping; + if (mapping.Has("type")) + { + // Runtime-loaded mappings still include "type"; field validation already knows the prototype kind. + validationMapping = mapping.Copy(); + validationMapping.Remove("type"); + } + + var errors = _serializationManager.ValidateNode(kind, validationMapping, context) + .GetErrors() + .ToArray(); + + if (errors.Length == 0) + return; + + var errorText = string.Join("\n", errors.Select(x => x.ErrorReason)); + throw new PrototypeLoadException($"Validation failed for {kind}({id})\n{errorText}"); + } + private void Freeze(IEnumerable kinds) { var st = RStopwatch.StartNew(); @@ -519,6 +572,7 @@ public void ResolveResults() InstantiateKinds(kinds, inheritanceTasks); } + RebuildEntityComponentCache(); UpdateCategories(); } @@ -606,7 +660,8 @@ private void InstantiatePrototypes( Type kind, string id, MappingDataNode mapping, - SerializationHookContext hookCtx) + SerializationHookContext hookCtx, + bool throwOnReadFailure = false) { if (mapping.TryGet(AbstractDataFieldAttribute.Name, out var abstractNode) && abstractNode.AsBool()) @@ -618,6 +673,9 @@ private void InstantiatePrototypes( } catch (Exception e) { + if (throwOnReadFailure) + throw new PrototypeLoadException($"Failed reading {kind}({id})", e); + Sawmill.Error($"Reading {kind}({id}) threw the following exception: {e}"); return null; } @@ -1032,7 +1090,7 @@ private void RegisterKind(Type kind, Dictionary kinds) { throw new InvalidImplementationException(kind, typeof(IPrototype), - $"Duplicate prototype type ID: {attribute.Type}. Current: {existing}"); + $"Duplicate prototype type ID: {name}. Current: {existing}"); } var foundIdAttribute = false; @@ -1200,8 +1258,7 @@ public IReadOnlyDictionary GetPrototypeData(EntityProto continue; } - var copy = componentMapping.Copy(); - copy.Remove("type"); + var copy = componentMapping.CopyNoType(); _tempMappingData[type.Value] = copy; } } diff --git a/Robust.Shared/Reflection/IReflectionManager.cs b/Robust.Shared/Reflection/IReflectionManager.cs index d2519b1b7..33f6692f6 100644 --- a/Robust.Shared/Reflection/IReflectionManager.cs +++ b/Robust.Shared/Reflection/IReflectionManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Reflection; using Robust.Shared.IoC; @@ -122,5 +123,29 @@ public interface IReflectionManager IEnumerable FindAllTypes(); void Initialize(); + + /// + /// Returns whether the given has at least one attribute + /// of type . + /// This includes inherited attributes if the specified attribute type + /// is marked as being inherited using . + /// + /// The type to check for attributes on. + /// The type of attribute to look for. + /// + bool IsAttributeDefined(Type type, Type attribute); + + /// + /// Returns all types discoverable by + /// which have at least one attribute of type . + /// This includes inherited attributes if the specified attribute type + /// is marked as being inherited using . + /// + /// The type of the attribute. + /// + /// Returns a set of the types with the given attribute. + /// Includes inherited attributes if the attribute type is inherited. + /// + ImmutableHashSet FindTypesWithAttributeSet(); } } diff --git a/Robust.Shared/Reflection/ReflectionManager.cs b/Robust.Shared/Reflection/ReflectionManager.cs index ac866a881..0d7fedf56 100644 --- a/Robust.Shared/Reflection/ReflectionManager.cs +++ b/Robust.Shared/Reflection/ReflectionManager.cs @@ -1,13 +1,12 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; -using System.Threading; using Robust.Shared.IoC; using Robust.Shared.Log; -using Robust.Shared.Serialization; using Robust.Shared.Utility; using Robust.Shared.ViewVariables; @@ -32,25 +31,30 @@ public abstract partial class ReflectionManager : IReflectionManager [ViewVariables] public IReadOnlyList Assemblies => assemblies; - private readonly Dictionary<(Type baseType, string typeName), Type?> _yamlTypeTagCache = new(); + private readonly ConcurrentDictionary<(Type baseType, string typeName), Type?> _yamlTypeTagCache = new(); private readonly Dictionary _looseTypeCache = new(); - private readonly Dictionary _enumCache = new(); - private readonly Dictionary _reverseEnumCache = new(); + private readonly ConcurrentDictionary _enumCache = new(); + private readonly ConcurrentDictionary _reverseEnumCache = new(); - private readonly ReaderWriterLockSlim _enumCacheLock = new(); - private readonly ReaderWriterLockSlim _yamlTypeTagCacheLock = new(); + private ImmutableArray _getAllTypesCache = ImmutableArray.Empty; - private readonly List _getAllTypesCache = new(); - private readonly Dictionary<(Type BaseType, bool Inclusive), Type[]> _getAllChildrenCache = new(); - private ISawmill _sawmill = default!; + private ImmutableDictionary> _inheritanceCache = + ImmutableDictionary>.Empty; + + private ImmutableDictionary> _attributeCache = + ImmutableDictionary>.Empty; - private readonly List _childrenCache = new(); + private ImmutableDictionary> _allEnumCache = + ImmutableDictionary>.Empty; + + private ISawmill _sawmill = default!; public void Initialize() { _sawmill = _logMan.GetSawmill("Reflection"); + EnsureGetAllTypesCache(); } /// @@ -64,31 +68,22 @@ public IEnumerable GetAllChildren(Type baseType, bool inclusive = false) { EnsureGetAllTypesCache(); - var key = (baseType, inclusive); - if (_getAllChildrenCache.TryGetValue(key, out var cached)) - return cached; + if (inclusive) + yield return baseType; - _childrenCache.Clear(); + if (!_inheritanceCache.TryGetValue(baseType, out var inheritors)) + yield break; - foreach (var type in _getAllTypesCache) + foreach (var inheritor in inheritors) { - if (!baseType.IsAssignableFrom(type) || type.IsAbstract) - continue; - - if (baseType == type && !inclusive) - continue; - - _childrenCache.Add(type); + if (!inheritor.IsAbstract) + yield return inheritor; } - - cached = _childrenCache.ToArray(); - _getAllChildrenCache.Add(key, cached); - return cached; } - private void EnsureGetAllTypesCache() + internal void EnsureGetAllTypesCache() { - if (_getAllTypesCache.Count != 0) + if (_getAllTypesCache.Length != 0) return; var totalLength = 0; @@ -101,7 +96,10 @@ private void EnsureGetAllTypesCache() totalLength += types.Length; } - _getAllTypesCache.Capacity = totalLength; + var typesCache = ImmutableArray.CreateBuilder(totalLength); + var inheritanceCache = ImmutableDictionary.CreateBuilder List, HashSet Set)>(); + var attributeCache = ImmutableDictionary.CreateBuilder>(); + var enumCache = ImmutableDictionary.CreateBuilder>(); foreach (var typeSet in typeSets) { @@ -112,9 +110,105 @@ private void EnsureGetAllTypesCache() if (!(attribute?.Discoverable ?? ReflectAttribute.DEFAULT_DISCOVERABLE)) continue; - _getAllTypesCache.Add(type); + typesCache.Add(type); + + var baseType = type.BaseType; + foreach (var @interface in type.GetInterfaces()) + { + if (!inheritanceCache.TryGetValue(@interface, out var interfaces)) + { + interfaces = ([], []); + inheritanceCache[@interface] = interfaces; + } + + if (interfaces.Set.Add(type)) + interfaces.List.Add(type); + } + + while (baseType != null) + { + if (!inheritanceCache.TryGetValue(baseType, out var subTypes)) + { + subTypes = ([], []); + inheritanceCache[baseType] = subTypes; + } + + if (subTypes.Set.Add(type)) + subTypes.List.Add(type); + + foreach (var @interface in baseType.GetInterfaces()) + { + if (!inheritanceCache.TryGetValue(@interface, out var interfaces)) + { + interfaces = ([], []); + inheritanceCache[@interface] = interfaces; + } + + if (interfaces.Set.Add(type)) + interfaces.List.Add(type); + } + + baseType = baseType.BaseType; + } + + foreach (var typeAttribute in type.CustomAttributes) + { + if (!attributeCache.TryGetValue(typeAttribute.AttributeType, out var attributes)) + { + attributes = []; + attributeCache[typeAttribute.AttributeType] = attributes; + } + + attributes.Add(type); + } + + if (type.IsEnum) + { + var fullName = type.FullName!; + var types = enumCache.GetOrNew(fullName); + types.Add(type); + + types = enumCache.GetOrNew(type.Name); + types.Add(type); + + var declaringType = type.DeclaringType; + var lastIndexOf = fullName.LastIndexOf('.'); + while (declaringType != null && lastIndexOf != -1) + { + types = enumCache.GetOrNew(fullName[(lastIndexOf + 1)..]); + types.Add(type); + + declaringType = declaringType.DeclaringType; + lastIndexOf = fullName.LastIndexOf('.', lastIndexOf - 1, lastIndexOf - 1); + } + } + } + } + + var toAdd = new HashSet(); + foreach (var (attributeType, types) in attributeCache) + { + if (attributeType.GetCustomAttribute() is not { Inherited: true }) + { + continue; } + + toAdd.Clear(); + foreach (var type in types) + { + if (inheritanceCache.TryGetValue(type, out var inheritors)) + toAdd.UnionWith(inheritors.Set); + } + + types.UnionWith(toAdd); } + + _getAllTypesCache = typesCache.ToImmutable(); + _inheritanceCache = inheritanceCache + .ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.List.ToImmutableArray()); + _attributeCache = attributeCache + .ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableHashSet()); + _allEnumCache = enumCache.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()); } public void LoadAssemblies(params Assembly[] args) => LoadAssemblies(args.AsEnumerable()); @@ -126,8 +220,9 @@ public void LoadAssemblies(IEnumerable assemblies) throw new InvalidOperationException("Attempted to load the same assembly multiple times!"); this.assemblies.AddRange(assembliesArray); - _getAllTypesCache.Clear(); - _getAllChildrenCache.Clear(); + _getAllTypesCache = ImmutableArray.Empty; + _inheritanceCache = ImmutableDictionary>.Empty; + _allEnumCache = ImmutableDictionary>.Empty; OnAssemblyAdded?.Invoke(this, new ReflectionUpdateEventArgs(this)); } @@ -227,7 +322,7 @@ public IEnumerable FindTypesWithAttribute() where T : Attribute public IEnumerable FindTypesWithAttribute(Type attributeType) { EnsureGetAllTypesCache(); - return _getAllTypesCache.Where(type => Attribute.IsDefined(type, attributeType)); + return _attributeCache.GetValueOrDefault(attributeType) ?? Enumerable.Empty(); } public IEnumerable FindAllTypes() @@ -239,44 +334,35 @@ public IEnumerable FindAllTypes() /// public string GetEnumReference(Enum @enum) { - using (_enumCacheLock.ReadGuard()) - { - if (_reverseEnumCache.TryGetValue(@enum, out var reference)) - return reference; - } - - using (_enumCacheLock.WriteGuard()) - { - if (_reverseEnumCache.TryGetValue(@enum, out var reference)) - return reference; - - // if there is more than one enum with the same basic name, the reference may need to be the fully qualified name. - // but if possible we want to avoid that and use a shorter string. - - var fullName = @enum.GetType().FullName!; - var dotIndex = fullName.LastIndexOf('.'); - if (dotIndex > 0 && dotIndex != fullName.Length) + return _reverseEnumCache.GetOrAdd(@enum, + _ => { - var name = fullName.Substring(dotIndex + 1); - reference = $"enum.{name}.{@enum}"; + // if there is more than one enum with the same basic name, the reference may need to be the fully qualified name. + // but if possible we want to avoid that and use a shorter string. - if (_enumCache.TryAdd(reference, @enum)) + string reference; + var fullName = @enum.GetType().FullName!; + var dotIndex = fullName.LastIndexOf('.'); + if (dotIndex > 0 && dotIndex != fullName.Length) { - _reverseEnumCache.Add(@enum, reference); - return reference; + var name = fullName.Substring(dotIndex + 1); + reference = $"enum.{name}.{@enum}"; + + if (_enumCache.TryAdd(reference, @enum)) + return reference; } - } - // If that failed, just use the full name. - reference = $"enum.{fullName}.{@enum}"; - _reverseEnumCache.Add(@enum, reference); - _enumCache.Add(reference, @enum); - return reference; - } + // If that failed, just use the full name. + reference = $"enum.{fullName}.{@enum}"; + _enumCache.TryAdd(reference, @enum); + return reference; + }); } /// - public bool TryParseEnumReference(string reference, [NotNullWhen(true)] out Enum? @enum, + public bool TryParseEnumReference( + string reference, + [NotNullWhen(true)] out Enum? @enum, bool shouldThrow = true) { if (!reference.StartsWith("enum.")) @@ -285,51 +371,52 @@ public bool TryParseEnumReference(string reference, [NotNullWhen(true)] out Enum return false; } - using (_enumCacheLock.ReadGuard()) - { - if (_enumCache.TryGetValue(reference, out @enum)) - return true; - } + @enum = _enumCache.GetOrAdd(reference, + r => + { + var cropped = r.AsSpan(5); - using var _ = _enumCacheLock.WriteGuard(); - if (_enumCache.TryGetValue(reference, out @enum)) - return true; + // Doesn't exist, add it. + var dotIndex = cropped.LastIndexOf('.'); + var typeName = cropped[..dotIndex]; - var cropped = reference.Substring(5); + var firstDot = typeName.IndexOf('.'); + if (firstDot != -1) + typeName = typeName[(firstDot + 1)..]; - // Doesn't exist, add it. - var dotIndex = cropped.LastIndexOf('.'); - var typeName = cropped.Substring(0, dotIndex); + var value = cropped[(dotIndex + 1)..]; - var value = cropped.Substring(dotIndex + 1); + if (!_allEnumCache.TryGetValue(typeName.ToString(), out var enums)) + return null; - foreach (var assembly in assemblies) - { - foreach (var type in assembly.DefinedTypes) - { - if (!type.IsEnum || !TypeNameMatchesEnumReference(type.FullName!, typeName)) + foreach (var @enum in enums) { - continue; - } + if (!TypeNameMatchesEnumReference(@enum.FullName!, typeName)) + continue; - @enum = (Enum)Enum.Parse(type, value); - if (!_reverseEnumCache.TryAdd(@enum, reference)) - { - _sawmill.Warning($"Conflicting enum references encountered. Enum: {@enum}. Existing: {_reverseEnumCache[@enum]}. New: {reference}"); + var e = (Enum)Enum.Parse(@enum, value); + if (!_reverseEnumCache.TryAdd(e, r) && + r != _reverseEnumCache[e]) + { + _sawmill.Warning( + $"Conflicting enum references encountered. Enum: {e}. Existing: {_reverseEnumCache[e]}. New: {r}"); + } + + return e; } - _enumCache.Add(reference, @enum); - return true; - } - } - if (shouldThrow) + return null; + }); + + if (@enum == null && shouldThrow) throw new ArgumentException($"Could not resolve enum reference: {reference}."); - return false; + + return @enum != null; } - private static bool TypeNameMatchesEnumReference(string fullName, string typeName) + private static bool TypeNameMatchesEnumReference(ReadOnlySpan fullName, ReadOnlySpan typeName) { - if (fullName.Equals(typeName)) + if (fullName.SequenceEqual(typeName)) return true; if (fullName.Length <= typeName.Length) @@ -338,59 +425,54 @@ private static bool TypeNameMatchesEnumReference(string fullName, string typeNam var prefixIndex = fullName.Length - typeName.Length - 1; var separator = fullName[prefixIndex]; - return (separator == '.' || separator == '+') - && fullName.AsSpan(prefixIndex + 1).SequenceEqual(typeName); + return separator is '.' or '+' + && fullName[(prefixIndex + 1)..].SequenceEqual(typeName); } public Type? YamlTypeTagLookup(Type baseType, string typeName) { - using (_yamlTypeTagCacheLock.ReadGuard()) - { - if (_yamlTypeTagCache.TryGetValue((baseType, typeName), out var type)) - return type; - } - - using (_yamlTypeTagCacheLock.WriteGuard()) - { - if (_yamlTypeTagCache.TryGetValue((baseType, typeName), out var type)) - return type; - Type? found = null; - foreach (var derivedType in GetAllChildren(baseType)) + return _yamlTypeTagCache.GetOrAdd((baseType, typeName), + _ => { - if (!derivedType.IsPublic) + Type? found = null; + foreach (var derivedType in GetAllChildren(baseType)) { - continue; - } + if (!derivedType.IsPublic) + { + continue; + } - if (derivedType.Name == typeName) - { - found = derivedType; - break; + if (derivedType.Name == typeName) + { + found = derivedType; + break; + } } - var serializedAttribute = derivedType.GetCustomAttribute(); - - if (serializedAttribute != null && - serializedAttribute.SerializeName == typeName) + // Fallback + if (found == null) { - found = derivedType; - break; + TryLooseGetType(typeName, out found); + + // If we may have gotten the type but it's still abstract then don't return it. + if (found == null || found.IsAbstract || !found.IsAssignableTo(baseType)) + found = null; } - } - // Fallback - if (found == null) - { - TryLooseGetType(typeName, out found); + return found; + }); + } - // If we may have gotten the type but it's still abstract then don't return it. - if (found == null || found.IsAbstract || !found.IsAssignableTo(baseType)) - found = null; - } + public bool IsAttributeDefined(Type type, Type attribute) + { + return _attributeCache.TryGetValue(attribute, out var attributes) && + attributes.Contains(type); + } - _yamlTypeTagCache.Add((baseType, typeName), found); - return found; - } + public ImmutableHashSet FindTypesWithAttributeSet() + { + EnsureGetAllTypesCache(); + return _attributeCache.GetValueOrDefault(typeof(T)) ?? ImmutableHashSet.Empty; } } } diff --git a/Robust.Shared/Replays/IReplayDataProvider.cs b/Robust.Shared/Replays/IReplayDataProvider.cs new file mode 100644 index 000000000..1c2da8ae9 --- /dev/null +++ b/Robust.Shared/Replays/IReplayDataProvider.cs @@ -0,0 +1,31 @@ +using System; +using Robust.Shared.GameStates; + +namespace Robust.Shared.Replays; + +/// +/// Provides access to the per-tick game states and messages of a replay. +/// +/// +/// This abstraction exists so that the playback code does not need to keep the entire replay +/// (all s and s) resident in memory at once. A +/// large replay can deserialize to tens of gigabytes; the client provides a windowed implementation +/// (BufferedReplayDataProvider) that only keeps a handful of data blocks loaded at a time. +/// +public interface IReplayDataProvider : IDisposable +{ + /// + /// The total number of ticks (states/messages) in the replay. + /// + int Count { get; } + + /// + /// Get the game state for a given tick index. May trigger a (synchronous) load from disk. + /// + GameState GetState(int index); + + /// + /// Get the networked messages for a given tick index. May trigger a (synchronous) load from disk. + /// + ReplayMessage GetMessages(int index); +} diff --git a/Robust.Shared/Replays/ReplayData.cs b/Robust.Shared/Replays/ReplayData.cs index 6546c284d..397061747 100644 --- a/Robust.Shared/Replays/ReplayData.cs +++ b/Robust.Shared/Replays/ReplayData.cs @@ -16,17 +16,28 @@ namespace Robust.Shared.Replays; /// /// This class contains data read from some replay recording. /// -public sealed class ReplayData +public sealed class ReplayData : IDisposable { /// - /// List of game states for each tick. + /// Provides the per-tick game states and messages. Backed by a windowed loader so that the whole + /// replay does not have to stay resident in memory (see BufferedReplayDataProvider). /// - public readonly List States; + private readonly IReplayDataProvider _provider; /// - /// List of all networked messages and variables that were sent each tick. + /// The total number of ticks (states/messages) in this recording. /// - public readonly List Messages; + public int Count => _provider.Count; + + /// + /// Get the game state for a given tick index. May trigger a synchronous load from disk. + /// + public GameState GetState(int index) => _provider.GetState(index); + + /// + /// Get the networked messages for a given tick index. May trigger a synchronous load from disk. + /// + public ReplayMessage GetMessages(int index) => _provider.GetMessages(index); /// /// Replay recording time for each corresponding entry in . Starts at 0. @@ -42,6 +53,12 @@ public sealed class ReplayData /// public readonly GameTick TickOffset; + /// + /// The end tick () of the last state in this recording. Cached at load + /// time so the UI does not have to fetch the final (out-of-window) data block every frame. + /// + public readonly GameTick LastTick; + /// /// The sever's time when the recording was started. /// @@ -69,9 +86,9 @@ public sealed class ReplayData public GameTick LastApplied { get; internal set; } public GameTick CurTick => new GameTick((uint) CurrentIndex + TickOffset.Value); - public GameState CurState => States[CurrentIndex]; - public GameState? NextState => CurrentIndex + 1 < States.Count ? States[CurrentIndex + 1] : null; - public ReplayMessage CurMessages => Messages[CurrentIndex]; + public GameState CurState => _provider.GetState(CurrentIndex); + public GameState? NextState => CurrentIndex + 1 < Count ? _provider.GetState(CurrentIndex + 1) : null; + public ReplayMessage CurMessages => _provider.GetMessages(CurrentIndex); public TimeSpan CurrentReplayTime => ReplayTime[CurrentIndex]; @@ -90,10 +107,10 @@ public sealed class ReplayData /// public ReplayMessage? InitialMessages; - public ReplayData(List states, - List messages, + public ReplayData(IReplayDataProvider provider, TimeSpan[] replayTime, GameTick tickOffset, + GameTick lastTick, TimeSpan startTime, TimeSpan? duration, CheckpointState[] checkpointStates, @@ -101,10 +118,10 @@ public ReplayData(List states, bool clientSideRecording, MappingDataNode yamlData) { - States = states; - Messages = messages; + _provider = provider; ReplayTime = replayTime; TickOffset = tickOffset; + LastTick = lastTick; StartTime = startTime; Duration = duration; Checkpoints = checkpointStates; @@ -118,6 +135,14 @@ public ReplayData(List states, Recorder = new NetUserId(guid); } } + + /// + /// Releases the underlying data provider (and the replay file handle it owns). + /// + public void Dispose() + { + _provider.Dispose(); + } } /// diff --git a/Robust.Shared/Serialization/ISerializationGenerated.cs b/Robust.Shared/Serialization/ISerializationGenerated.cs index ebc7dbe43..7cb0fd3d2 100644 --- a/Robust.Shared/Serialization/ISerializationGenerated.cs +++ b/Robust.Shared/Serialization/ISerializationGenerated.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using Robust.Shared.GameObjects; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Manager.Definition; -using Robust.Shared.Serialization.Markdown; using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Serialization.Markdown.Validation; @@ -11,6 +11,7 @@ namespace Robust.Shared.Serialization; +[NotContentImplementable] public interface ISerializationGenerated : ISerializationGenerated { /// @@ -51,6 +52,18 @@ static virtual void Read( throw new NotImplementedException(); } + /// + [Obsolete("Use ISerializationManager.Read instead")] + void ReadComp( + ref Component target, + MappingDataNode mappingDataNode, + ISerializationManager serialization, + SerializationHookContext hookCtx, + ISerializationContext? context) + { + throw new NotImplementedException(); + } + /// [Obsolete("Use ISerializationManager.Write instead")] static virtual void Write( @@ -92,6 +105,7 @@ static virtual void GetFieldDefinitions(T? instance, List f } } +[NotContentImplementable] public interface ISerializationGenerated { /// diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.Copying.cs b/Robust.Shared/Serialization/Manager/SerializationManager.Copying.cs index 4302934d2..9f25665df 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.Copying.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.Copying.cs @@ -277,7 +277,7 @@ private bool ShouldReturnSource(Type type) return type.IsPrimitive || type.IsEnum || type == typeof(string) || - _copyByRefRegistrations.ContainsKey(type); + _copyByRefRegistrations.Contains(type); } private bool CopyToInternal( @@ -372,7 +372,7 @@ private T CreateCopyInternal(T source, SerializationHookContext hookCtx, ISer var generated = Unsafe.As>(source); var target = generated.Instantiate(); generated.Copy(ref target, this, hookCtx, context); - RunAfterHook(target, hookCtx); + TryRunAfterHook(target, hookCtx); return target; } else @@ -474,7 +474,7 @@ public void CopyTo( var generated = Unsafe.As>(source); target ??= generated.Instantiate(); generated.Copy(ref target, this, hookCtx, context); - RunAfterHook(target, hookCtx); + TryRunAfterHook(target, hookCtx); return; } @@ -489,7 +489,7 @@ public void CopyTo( target = CreateCopy(source, hookCtx, context); } - RunAfterHook(target, hookCtx); + TryRunAfterHook(target, hookCtx); } public void CopyTo(ITypeCopier copier, T source, ref T target, ISerializationContext? context = null, @@ -534,7 +534,7 @@ public void CopyTo( } copier.CopyTo(this, source, ref target, DependencyCollection, hookCtx, context); - RunAfterHook(target, hookCtx); + TryRunAfterHook(target, hookCtx); } public void CopyTo(T source, ref T target, ISerializationContext? context = null, bool skipHook = false, bool notNullableOverride = false) @@ -607,13 +607,13 @@ public T CreateCopy( var generated = Unsafe.As>(source); var target = generated.Instantiate(); generated.Copy(ref target, this, hookCtx, context); - RunAfterHook(target, hookCtx); + TryRunAfterHook(target, hookCtx); return target; } var res = GetOrCreateCreateCopyGenericDelegate()(source, hookCtx, context); - RunAfterHook(res, hookCtx); + TryRunAfterHook(res, hookCtx); return res; } @@ -643,7 +643,7 @@ public T CreateCopy( } var res = copyCreator.CreateCopy(this, source, DependencyCollection, hookCtx, context); - RunAfterHook(res, hookCtx); + TryRunAfterHook(res, hookCtx); return res; } diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.Reading.cs b/Robust.Shared/Serialization/Manager/SerializationManager.Reading.cs index c187ff7e4..fb8bfdfa9 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.Reading.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.Reading.cs @@ -193,6 +193,7 @@ public T ReadStructDefinition( { var baseType = typeof(T); var nullable = baseType.IsNullable(); + var isArray = ReadTypeMetadata.IsArray; T val = default!; @@ -321,7 +322,7 @@ void RegularRead() if (!hasSerializer) { - if (baseType.IsArray) + if (isArray) { val = node switch { @@ -381,7 +382,7 @@ void RegularRead() throw new ArgumentException($"No mapping or value node provided for type {baseType}."); } - RunAfterHook(val, hookCtx); + TryRunAfterHook(val, hookCtx); } } } @@ -581,7 +582,8 @@ public T Read( } var baseType = typeof(T); - if (baseType.IsEnum || baseType.IsArray || + if (baseType.IsEnum || + ReadTypeMetadata.IsArray || (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(Nullable<>))) { return ((ReadGenericDelegate)_readGenericDelegates.GetOrAdd((typeof(T), node.GetType()!, notNullableOverride), @@ -771,7 +773,7 @@ void RegularRead() throw new ArgumentException($"No mapping or value node provided for type {baseType}."); } - RunAfterHook(val, hookCtx); + TryRunAfterHook(val, hookCtx); } } } @@ -992,7 +994,7 @@ void RegularRead() throw new ArgumentException($"No mapping or value node provided for type {baseType}."); } - RunAfterHook(val, hookCtx); + TryRunAfterHook(val, hookCtx); } } } @@ -1427,7 +1429,7 @@ private TValue ReadGenericValue( throw new ArgumentException($"No mapping node provided for type {type} at line: {node.Start.Line}"); } - RunAfterHook(instance, hookCtx); + TryRunAfterHook(instance, hookCtx); return instance; } @@ -1447,7 +1449,7 @@ private TValue ReadGenericMapping( definition.Populate(ref instance, node, this, hookCtx, context); - RunAfterHook(instance, hookCtx); + TryRunAfterHook(instance, hookCtx); return instance; } @@ -1456,5 +1458,16 @@ private TValue ReadNoSerializer(DataNode node) { throw new ArgumentException($"No type serializer or data definition found for type {typeof(TValue)} with node type {node.GetType()} when reading"); } + + private static class ReadTypeMetadata + { + // ReSharper disable once StaticMemberInGenericType + public static bool IsArray; + + static ReadTypeMetadata() + { + IsArray = typeof(T).IsArray; + } + } } } diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.SerializerProvider.cs b/Robust.Shared/Serialization/Manager/SerializationManager.SerializerProvider.cs index e5a0d8cbf..f297ac255 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.SerializerProvider.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.SerializerProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; @@ -10,6 +11,9 @@ using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Serialization.Manager.Exceptions; using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Value; using Robust.Shared.Serialization.TypeSerializers.Interfaces; using Robust.Shared.Utility; @@ -31,6 +35,28 @@ public sealed partial class SerializationManager typeof(ITypeWriter<>) }.ToImmutableArray(); + private static readonly ImmutableArray Nodes = new[] + { + typeof(MappingDataNode), + typeof(SequenceDataNode), + typeof(ValueDataNode), + }.ToImmutableArray(); + + /// + /// + /// + private const int ReaderIndex = 0; + + /// + /// + /// + private const int InheritanceHandlerIndex = 1; + + /// + /// + /// + private const int ValidatorIndex = 2; + /// /// /// @@ -41,12 +67,42 @@ public sealed partial class SerializationManager /// private const int CopierIndex = 4; + /// + /// + /// + private const int WriterIndex = 5; + + /// + /// + /// + private const int MappingIndex = 0; + + /// + /// + /// + private const int SequenceIndex = 1; + + /// + /// + /// + private const int ValueIndex = 2; + private SerializerProvider _regularSerializerProvider = default!; private ISawmill _serializerSawmill = default!; private void InitializeTypeSerializers(IEnumerable typeSerializers) { + DebugTools.AssertEqual(ReaderIndex, SerializerInterfaces.IndexOf(typeof(ITypeReader<,>))); + DebugTools.AssertEqual(InheritanceHandlerIndex, SerializerInterfaces.IndexOf(typeof(ITypeInheritanceHandler<,>))); + DebugTools.AssertEqual(ValidatorIndex, SerializerInterfaces.IndexOf(typeof(ITypeValidator<,>))); + DebugTools.AssertEqual(CopyCreatorIndex, SerializerInterfaces.IndexOf(typeof(ITypeCopyCreator<>))); + DebugTools.AssertEqual(CopierIndex, SerializerInterfaces.IndexOf(typeof(ITypeCopier<>))); + + DebugTools.AssertEqual(MappingIndex, Nodes.IndexOf(typeof(MappingDataNode))); + DebugTools.AssertEqual(SequenceIndex, Nodes.IndexOf(typeof(SequenceDataNode))); + DebugTools.AssertEqual(ValueIndex, Nodes.IndexOf(typeof(ValueDataNode))); + _regularSerializerProvider = new(this, typeSerializers); } @@ -62,6 +118,10 @@ private object CreateSerializer(Type type) ser.SerMan = this; ser.Log = _serializerSawmill; } + + if (result is IPostInjectInit postInject) + postInject.PostInject(); + return result; } @@ -102,7 +162,7 @@ public bool TryCustomCopy(T source, ref T target, SerializationHookContext ho public sealed class SerializerProvider { - private SerializationManager _ser; + private readonly SerializationManager _ser; public SerializerProvider(ISerializationManager ser, IEnumerable typeSerializers) : this(ser) { @@ -122,8 +182,9 @@ public SerializerProvider(ISerializationManager ser) } } - private Dictionary> _typeNodeSerializers = new(); - private Dictionary> _typeSerializers = new(); + private (object? Regular, object? Generic, bool Init)[] _typeNodeSerializersArray = []; + private readonly ConcurrentDictionary> _typeNodeSerializers = new(); + private readonly ConcurrentDictionary> _typeSerializers = new(); // TODO make this a 1d array containing the 6 interfaces /// @@ -131,15 +192,15 @@ public SerializerProvider(ISerializationManager ser) /// that they serialize. /// for the first index. /// - private (object? Regular, object? Generic)[]?[] _typeSerializersArray = new (object? Regular, object? Generic)[]?[] { }; + private (object? Regular, object? Generic)[]?[] _typeSerializersArray = []; - private Dictionary> _genericTypeNodeSerializers = new(); - private Dictionary> _genericTypeSerializers = new(); + private readonly ConcurrentDictionary> _genericTypeNodeSerializers = new(); + private readonly ConcurrentDictionary> _genericTypeSerializers = new(); - private List _typeNodeInterfaces = new(); - private List _typeInterfaces = new(); + private readonly List _typeNodeInterfaces = new(); + private readonly List _typeInterfaces = new(); - private readonly object _lock = new(); + private readonly Lock _lock = new(); #region GetSerializerMethods @@ -148,11 +209,51 @@ public bool TryGetTypeNodeSerializer([NotNullWhen(true where TNode : DataNode { serializer = default; - if (!TryGetTypeNodeSerializer(typeof(TInterface).GetGenericTypeDefinition(), typeof(TType), typeof(TNode), out var rawSerializer)) + object? rawSerializer; + var index = TypeSerializerType.Index; + if (index < _typeNodeSerializersArray.Length) + { + ref var serializers = ref _typeNodeSerializersArray[index]; + if (serializers.Init) + { + if (serializers.Regular != null) + { + serializer = (TInterface) serializers.Regular; + return true; + } + + if (serializers.Generic != null) + { + serializer = (TInterface) serializers.Generic; + return true; + } + + return false; + } + + if (TryGetTypeNodeSerializer(typeof(TInterface).GetGenericTypeDefinition(), + typeof(TType), + typeof(TNode), + out rawSerializer)) + { + serializer = (TInterface) rawSerializer; + return true; + } + + serializers.Init = true; return false; + } - serializer = (TInterface)rawSerializer; - return true; + if (TryGetTypeNodeSerializer(typeof(TInterface).GetGenericTypeDefinition(), + typeof(TType), + typeof(TNode), + out rawSerializer)) + { + serializer = (TInterface) rawSerializer; + return true; + } + + return false; } internal bool TryGetTypeNodeSerializerArray([NotNullWhen(true)] out TInterface? serializer) @@ -169,30 +270,28 @@ internal bool TryGetTypeNodeSerializerArray([NotNullWh public bool TryGetTypeNodeSerializer(Type interfaceType, Type objectType, Type nodeType, [NotNullWhen(true)] out object? serializer) { - lock (_lock) - { - if (_typeNodeSerializers.TryGetValue(interfaceType, out var typeNodeSerializers) && - typeNodeSerializers.TryGetValue((objectType, nodeType), out serializer)) - return true; + if (_typeNodeSerializers.TryGetValue(interfaceType, out var typeNodeSerializers) && + typeNodeSerializers.TryGetValue((objectType, nodeType), out serializer)) + return true; - if (_genericTypeNodeSerializers.TryGetValue(interfaceType, out var genericTypeNodeSerializers) && - objectType.IsGenericType) + if (_genericTypeNodeSerializers.TryGetValue(interfaceType, out var genericTypeNodeSerializers) && + objectType.IsGenericType) + { + var typeDef = objectType.GetGenericTypeDefinition(); + foreach (var (key, val) in genericTypeNodeSerializers) { - var typeDef = objectType.GetGenericTypeDefinition(); - foreach (var (key, val) in genericTypeNodeSerializers) - { - if (typeDef.HasSameMetadataDefinitionAs(key.ObjectType) && nodeType == key.NodeType) - { - var serializerType = val.MakeGenericType(objectType.GetGenericArguments()); - serializer = RegisterSerializer(serializerType)!; - return true; - } - } - } + if (!typeDef.HasSameMetadataDefinitionAs(key.ObjectType) || nodeType != key.NodeType) + continue; - serializer = null; - return false; + var serializerType = val.MakeGenericType(objectType.GetGenericArguments()); + serializer = RegisterSerializer(serializerType)!; + RegisterIndexedNodeSerializer(interfaceType, objectType, key.NodeType, serializer, false); + return true; + } } + + serializer = null; + return false; } public TInterface GetTypeNodeSerializer() @@ -226,31 +325,34 @@ public bool TryGetTypeSerializer([NotNullWhen(true)] out TInt public bool TryGetTypeSerializer(Type interfaceType, Type objectType, [NotNullWhen(true)] out object? serializer) { - lock (_lock) - { - if (_typeSerializers.TryGetValue(interfaceType, out var typeSerializers) && - typeSerializers.TryGetValue(objectType, out serializer)) - return true; + if (_typeSerializers.TryGetValue(interfaceType, out var typeSerializers) && + typeSerializers.TryGetValue(objectType, out serializer)) + return true; - if (_genericTypeSerializers.TryGetValue(interfaceType, out var genericTypeSerializers) && - objectType.IsGenericType) + if (_genericTypeSerializers.TryGetValue(interfaceType, out var genericTypeSerializers) && + objectType.IsGenericType) + { + var typeDef = objectType.GetGenericTypeDefinition(); + foreach (var (key, val) in genericTypeSerializers) { - var typeDef = objectType.GetGenericTypeDefinition(); - foreach (var (key, val) in genericTypeSerializers) - { - if (typeDef.HasSameMetadataDefinitionAs(key)) - { - var serializerType = val.MakeGenericType(objectType.GetGenericArguments()); - serializer = RegisterSerializer(serializerType)!; - RegisterIndexedSerializer(objectType, SerializerInterfaces.IndexOf(interfaceType), serializer, false); - return true; - } - } - } + if (!typeDef.HasSameMetadataDefinitionAs(key)) + continue; + + var serializerType = val.MakeGenericType(objectType.GetGenericArguments()); + serializer = RegisterSerializer(serializerType)!; + RegisterIndexedSerializer( + objectType, + SerializerInterfaces.IndexOf(interfaceType), + serializer, + false + ); - serializer = null; - return false; + return true; + } } + + serializer = null; + return false; } internal bool TryGetCopierOrCreator(out ITypeCopier? copier, out ITypeCopyCreator? copyCreator) @@ -311,95 +413,100 @@ public object GetTypeSerializer(Type interfaceType, Type objectType) private object RegisterSerializer(Type type, object obj) { - lock (_lock) + foreach (var @interface in type.GetInterfaces()) { - foreach (var @interface in type.GetInterfaces()) - { - if (!@interface.IsGenericType) continue; + if (!@interface.IsGenericType) continue; - for (var i = 0; i < _typeInterfaces.Count; i++) - { - var typeInterface = _typeInterfaces[i]; - if (@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) - { - var arguments = @interface.GetGenericArguments(); - if (arguments.Length != 1) - throw new InvalidGenericParameterCountException(); - _typeSerializers.GetOrNew(typeInterface).Add(arguments[0], obj); - RegisterIndexedSerializer(arguments[0], SerializerInterfaces.IndexOf(typeInterface), obj, true); - } - } - - foreach (var typeInterface in _typeNodeInterfaces) - { - if (@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) - { - var arguments = @interface.GetGenericArguments(); - if (arguments.Length != 2) - throw new InvalidGenericParameterCountException(); - _typeNodeSerializers.GetOrNew(typeInterface).Add((arguments[0], arguments[1]), obj); - } - } + foreach (var typeInterface in _typeInterfaces) + { + if (!@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) + continue; + + var arguments = @interface.GetGenericArguments(); + if (arguments.Length != 1) + throw new InvalidGenericParameterCountException(); + + _typeSerializers.GetOrNew(typeInterface).TryAdd(arguments[0], obj); + RegisterIndexedSerializer( + arguments[0], + SerializerInterfaces.IndexOf(typeInterface), + obj, + true + ); } - return obj; + foreach (var typeInterface in _typeNodeInterfaces) + { + if (!@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) + continue; + + var arguments = @interface.GetGenericArguments(); + if (arguments.Length != 2) + throw new InvalidGenericParameterCountException(); + + _typeNodeSerializers.GetOrAdd(typeInterface, _ => new()) + .TryAdd((arguments[0], arguments[1]), obj); + RegisterIndexedNodeSerializer( + typeInterface, + arguments[0], + arguments[1], + obj, + true + ); + } } + + return obj; } public T? RegisterSerializer() => (T?)RegisterSerializer(typeof(T)); public object? RegisterSerializer(Type type) { - lock (_lock) + if (!type.IsGenericTypeDefinition) + return RegisterSerializer(type, _ser.CreateSerializer(type)); + + var typeArguments = type.GetGenericArguments(); + foreach (var @interface in type.GetInterfaces()) { - if (type.IsGenericTypeDefinition) + foreach (var typeInterface in _typeInterfaces) { - var typeArguments = type.GetGenericArguments(); - foreach (var @interface in type.GetInterfaces()) + if (!@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) + continue; + + var arguments = @interface.GetGenericArguments(); + if (arguments.Length != 1) + throw new InvalidGenericParameterCountException(); + var objArguments = arguments[0].GetGenericArguments(); + for (var i = 0; i < typeArguments.Length; i++) { - foreach (var typeInterface in _typeInterfaces) - { - if (@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) - { - var arguments = @interface.GetGenericArguments(); - if (arguments.Length != 1) - throw new InvalidGenericParameterCountException(); - var objArguments = arguments[0].GetGenericArguments(); - for (int i = 0; i < typeArguments.Length; i++) - { - if (typeArguments[i] != objArguments[i]) - throw new GenericParameterMismatchException(); - } - - _genericTypeSerializers.GetOrNew(typeInterface).Add(arguments[0], type); - } - } - - foreach (var typeInterface in _typeNodeInterfaces) - { - if (@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) - { - var arguments = @interface.GetGenericArguments(); - if (arguments.Length != 2) - throw new InvalidGenericParameterCountException(); - var objArguments = arguments[0].GetGenericArguments(); - for (int i = 0; i < typeArguments.Length; i++) - { - if (typeArguments[i] != objArguments[i]) - throw new GenericParameterMismatchException(); - } - - _genericTypeNodeSerializers.GetOrNew(typeInterface) - .Add((arguments[0], arguments[1]), type); - } - } + if (typeArguments[i] != objArguments[i]) + throw new GenericParameterMismatchException(); } - return null; + _genericTypeSerializers.GetOrNew(typeInterface).TryAdd(arguments[0], type); } - return RegisterSerializer(type, _ser.CreateSerializer(type)); + foreach (var typeInterface in _typeNodeInterfaces) + { + if (!@interface.GetGenericTypeDefinition().HasSameMetadataDefinitionAs(typeInterface)) + continue; + + var arguments = @interface.GetGenericArguments(); + if (arguments.Length != 2) + throw new InvalidGenericParameterCountException(); + var objArguments = arguments[0].GetGenericArguments(); + for (var i = 0; i < typeArguments.Length; i++) + { + if (typeArguments[i] != objArguments[i]) + throw new GenericParameterMismatchException(); + } + + _genericTypeNodeSerializers.GetOrNew(typeInterface).TryAdd((arguments[0], arguments[1]), type); + } } + + return null; } //todo paul serv3 is there a better way than comparing names here? @@ -420,7 +527,7 @@ private void RegisterSerializerInterface(Type type) if (genericInterface.HasSameMetadataDefinitionAs(genericTypeNode)) { var genericInterfaceParams = genericInterface.GetGenericArguments(); - for (int i = 0; i < genericParams.Length; i++) + for (var i = 0; i < genericParams.Length; i++) { if (genericParams[i].Name != genericInterfaceParams[i].Name) throw new GenericParameterMismatchException(); @@ -431,7 +538,7 @@ private void RegisterSerializerInterface(Type type) else if (genericInterface.HasSameMetadataDefinitionAs(genericType)) { var genericInterfaceParams = genericInterface.GetGenericArguments(); - for (int i = 0; i < genericParams.Length; i++) + for (var i = 0; i < genericParams.Length; i++) { if (genericParams[i].Name != genericInterfaceParams[i].Name) throw new GenericParameterMismatchException(); @@ -447,9 +554,7 @@ private void RegisterIndexedSerializer(Type elementType, int interfaceIndex, obj { var id = SerializedType.GetId(elementType); if (id >= _typeSerializersArray.Length) - { Array.Resize(ref _typeSerializersArray, (id + 1) * 2); - } var array = _typeSerializersArray[id]; if (array == null) @@ -459,12 +564,26 @@ private void RegisterIndexedSerializer(Type elementType, int interfaceIndex, obj } if (regular) - { array[interfaceIndex].Regular = serializer; - } else - { array[interfaceIndex].Generic = serializer; + } + + private void RegisterIndexedNodeSerializer(Type interfaceIndex, Type elementType, Type nodeType, object serializer, bool regular) + { + lock (_lock) + { + var id = TypeSerializerType.GetId(interfaceIndex, elementType, nodeType); + if (id >= _typeNodeSerializersArray.Length) + Array.Resize(ref _typeNodeSerializersArray, (id + 1) * 2); + + ref var tuple = ref _typeNodeSerializersArray[id]; + if (regular) + tuple.Regular = serializer; + else + tuple.Generic = serializer; + + tuple.Init = true; } } @@ -474,7 +593,7 @@ private void RegisterIndexedSerializer(Type elementType, int interfaceIndex, obj private static class SerializedType { internal static int Id; - private static readonly object Lock = new(); + private static readonly Lock Lock = new(); internal static int GetId(Type type) { @@ -515,4 +634,32 @@ public TypeInformation(int id, bool returnSource, bool serializationGenerated) SerializationGenerated = serializationGenerated; } } + + internal static class TypeSerializerType + { + internal static int GetId(Type typeInterface, Type type, Type typeNode) + { + var interfaceIndex = SerializerInterfaces.IndexOf(typeInterface.GetGenericTypeDefinition()); + if (interfaceIndex == -1) + throw new ArgumentException($"Invalid type interface: {typeInterface}"); + + var nodeIndex = Nodes.IndexOf(typeNode); + if (nodeIndex == -1) + throw new ArgumentException($"Invalid node type: {typeInterface}"); + + return SerializedType.GetId(type) * + (SerializerInterfaces.Length + Nodes.Length) + + interfaceIndex + + nodeIndex; + } + } + + internal static class TypeSerializerType + { + // ReSharper disable once StaticMemberInGenericType + internal static readonly int Index = SerializedType.Information.Id * + (SerializerInterfaces.Length + Nodes.Length) + + SerializerInterfaces.IndexOf(typeof(TInterface).GetGenericTypeDefinition()) + + Nodes.IndexOf(typeof(TNode)); + } } diff --git a/Robust.Shared/Serialization/Manager/SerializationManager.cs b/Robust.Shared/Serialization/Manager/SerializationManager.cs index a09bbd5e4..151805d55 100644 --- a/Robust.Shared/Serialization/Manager/SerializationManager.cs +++ b/Robust.Shared/Serialization/Manager/SerializationManager.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -33,8 +34,7 @@ public sealed partial class SerializationManager : ISerializationManager private readonly ConcurrentDictionary _dataDefinitions = new(); - // Always has a dummy value of 0 for any types that should be copied by ref - private readonly ConcurrentDictionary _copyByRefRegistrations = new(); + private ImmutableHashSet _copyByRefRegistrations = ImmutableHashSet.Empty; [field: Dependency] public IDependencyCollection DependencyCollection { get; } = default!; @@ -62,15 +62,14 @@ public void Initialize() typeof(bool) ])); - var flagsTypes = new ConcurrentBag(); - var constantsTypes = new ConcurrentBag(); - var typeSerializers = new ConcurrentBag(); - var meansDataDef = new ConcurrentBag(); - var meansDataRecord = new ConcurrentBag(); - var implicitDataDef = new ConcurrentBag(); - var implicitDataRecord = new ConcurrentBag(); - - CollectAttributedTypes(flagsTypes, constantsTypes, typeSerializers, meansDataDef, meansDataRecord, implicitDataDef, implicitDataRecord); + var flagsTypes = _reflectionManager.FindTypesWithAttribute(); + var constantsTypes = _reflectionManager.FindTypesWithAttribute(); + var typeSerializers = _reflectionManager.FindTypesWithAttribute(); + var meansDataDef = _reflectionManager.FindTypesWithAttribute(); + var meansDataRecord = _reflectionManager.FindTypesWithAttribute(); + var implicitDataDef = _reflectionManager.FindTypesWithAttribute(); + var implicitDataRecord = _reflectionManager.FindTypesWithAttribute(); + _copyByRefRegistrations = _reflectionManager.FindTypesWithAttributeSet(); InitializeFlagsAndConstants(flagsTypes, constantsTypes); InitializeTypeSerializers(typeSerializers); @@ -117,11 +116,38 @@ IEnumerable GetImplicitTypes(Type type) Parallel.ForEach(_reflectionManager.FindAllTypes(), type => { - if (meansDataDef.Any(type.IsDefined)) + var meansDef = false; + foreach (var meansAttr in meansDataDef) + { + if (!_reflectionManager.IsAttributeDefined(type, meansAttr)) + continue; + + meansDef = true; + break; + } + + if (meansDef) registrations.Add(type); - if (type.IsDefined(typeof(DataRecordAttribute)) || meansDataRecord.Any(type.IsDefined)) + if (_reflectionManager.IsAttributeDefined(type, typeof(DataRecordAttribute))) + { records[type] = 0; + } + else + { + var meansRecord = false; + foreach (var meansAttr in meansDataRecord) + { + if (!_reflectionManager.IsAttributeDefined(type, meansAttr)) + continue; + + meansRecord = true; + break; + } + + if (meansRecord) + records[type] = 0; + } }); var sawmill = Logger.GetSawmill(LogCategory); @@ -137,7 +163,9 @@ IEnumerable GetImplicitTypes(Type type) } var isRecord = records.ContainsKey(type); - if (!type.IsValueType && !isRecord && !type.HasParameterlessConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + if (!type.IsValueType && + !isRecord && + !type.HasParameterlessConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { // If someone attempts to save or load an entity that uses this DataDefinition, this will lead to errors. sawmill.Warning( @@ -216,7 +244,7 @@ IEnumerable GetImplicitTypes(Type type) } } - _copyByRefRegistrations[typeof(Type)] = 0; + _copyByRefRegistrations = _copyByRefRegistrations.Add(typeof(Type)); _initialized = true; _initializing = false; @@ -251,44 +279,6 @@ private bool ValidateIsSerializable(Type type, FrozenSet forbidden) return true; } - private void CollectAttributedTypes( - ConcurrentBag flagsTypes, - ConcurrentBag constantsTypes, - ConcurrentBag typeSerializers, - ConcurrentBag meansDataDef, - ConcurrentBag meansDataRecord, - ConcurrentBag implicitDataDef, - ConcurrentBag implicitDataRecord) - { - // IsDefined is extremely slow. Great. - Parallel.ForEach(_reflectionManager.FindAllTypes(), type => - { - if (type.IsDefined(typeof(FlagsForAttribute), false)) - flagsTypes.Add(type); - - if (type.IsDefined(typeof(ConstantsForAttribute), false)) - constantsTypes.Add(type); - - if (type.IsDefined(typeof(TypeSerializerAttribute))) - typeSerializers.Add(type); - - if (type.IsDefined(typeof(MeansDataDefinitionAttribute))) - meansDataDef.Add(type); - - if (type.IsDefined(typeof(MeansDataRecordAttribute))) - meansDataRecord.Add(type); - - if (type.IsDefined(typeof(ImplicitDataDefinitionForInheritorsAttribute), true)) - implicitDataDef.Add(type); - - if (type.IsDefined(typeof(ImplicitDataRecordAttribute), true)) - implicitDataRecord.Add(type); - - if (type.IsDefined(typeof(CopyByRefAttribute))) - _copyByRefRegistrations[type] = 0; - }); - } - private DataDefinition CreateDataDefinition(Type t, bool isRecord) { return (DataDefinition)typeof(DataDefinition<>).MakeGenericType(t) @@ -371,17 +361,17 @@ private Type ResolveConcreteType(Type baseType, string typeName) } #pragma warning disable CS0618 - private static void RunAfterHook(TValue instance, SerializationHookContext ctx) + internal static void TryRunAfterHook(TValue instance, SerializationHookContext ctx) { + if (ctx.SkipHooks) + return; + if (instance is ISerializationHooks hooks) - RunAfterHookGenerated(hooks, ctx); + ForceRunAfterHookGenerated(hooks, ctx); } - private static void RunAfterHookGenerated(TValue instance, SerializationHookContext ctx) where TValue : ISerializationHooks + private static void ForceRunAfterHookGenerated(TValue instance, SerializationHookContext ctx) where TValue : ISerializationHooks { - if (ctx.SkipHooks) - return; - DebugTools.Assert(!typeof(TValue).IsValueType, "ISerializationHooks must only be used on reference types"); if (ctx.DeferQueue != null) diff --git a/Robust.Shared/Serialization/Markdown/DataNode.cs b/Robust.Shared/Serialization/Markdown/DataNode.cs index feb616018..6474c3112 100644 --- a/Robust.Shared/Serialization/Markdown/DataNode.cs +++ b/Robust.Shared/Serialization/Markdown/DataNode.cs @@ -21,6 +21,11 @@ public DataNode(NodeMark start, NodeMark end) public abstract bool IsEmpty { get; } public virtual bool IsNull { get; init; } = false; + internal virtual int GetCanonicalHashCode() + { + return 0; + } + public abstract DataNode Copy(); /// diff --git a/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs b/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs index ff8ddbe22..cfc73a9f2 100644 --- a/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs +++ b/Robust.Shared/Serialization/Markdown/Mapping/MappingDataNode.cs @@ -266,6 +266,25 @@ public override MappingDataNode Copy() return newMapping; } + internal MappingDataNode CopyNoType() + { + var newMapping = new MappingDataNode(_children.Count) + { + Tag = Tag, + Start = Start, + End = End + }; + + foreach (var (key, val) in _list) + { + if (key != "type") + newMapping.Add(key, val.Copy()); + } + + newMapping._keyNodes = _keyNodes; + return newMapping; + } + /// /// Variant of that doesn't clone the keys or values. /// @@ -393,6 +412,17 @@ public override int GetHashCode() return code.ToHashCode(); } + internal override int GetCanonicalHashCode() + { + var entriesHash = 0; + foreach (var (key, value) in _list) + { + entriesHash ^= HashCode.Combine(StringComparer.Ordinal.GetHashCode(key), value.GetCanonicalHashCode()); + } + + return HashCode.Combine(typeof(MappingDataNode), Tag, Count, entriesHash); + } + IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); diff --git a/Robust.Shared/Serialization/Markdown/Sequence/SequenceDataNode.cs b/Robust.Shared/Serialization/Markdown/Sequence/SequenceDataNode.cs index 471b223a3..9e6ef22fd 100644 --- a/Robust.Shared/Serialization/Markdown/Sequence/SequenceDataNode.cs +++ b/Robust.Shared/Serialization/Markdown/Sequence/SequenceDataNode.cs @@ -147,6 +147,19 @@ public override int GetHashCode() return code.ToHashCode(); } + internal override int GetCanonicalHashCode() + { + var hash = new HashCode(); + hash.Add(typeof(SequenceDataNode)); + hash.Add(Tag); + foreach (var child in _nodes) + { + hash.Add(child.GetCanonicalHashCode()); + } + + return hash.ToHashCode(); + } + IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); diff --git a/Robust.Shared/Serialization/Markdown/Value/ValueDataNode.cs b/Robust.Shared/Serialization/Markdown/Value/ValueDataNode.cs index 092ba53bc..c184b6d63 100644 --- a/Robust.Shared/Serialization/Markdown/Value/ValueDataNode.cs +++ b/Robust.Shared/Serialization/Markdown/Value/ValueDataNode.cs @@ -93,6 +93,11 @@ public override int GetHashCode() return Value.GetHashCode(); } + internal override int GetCanonicalHashCode() + { + return HashCode.Combine(typeof(ValueDataNode), Tag, Value, IsNull); + } + public override string ToString() { return Value; diff --git a/Robust.Shared/Serialization/RobustSerializer.cs b/Robust.Shared/Serialization/RobustSerializer.cs index bb50495f8..531fe40fa 100644 --- a/Robust.Shared/Serialization/RobustSerializer.cs +++ b/Robust.Shared/Serialization/RobustSerializer.cs @@ -193,21 +193,6 @@ public bool CanSerialize(Type type) if (assigned.TryGetValue(serializedTypeName, out var resolved)) return resolved; - var types = _reflectionManager.GetAllChildren(assignableType); - foreach (var type in types) - { - var serializedAttribute = type.GetCustomAttribute(); - - if(serializedAttribute is null) - continue; - - if (serializedAttribute.SerializeName == serializedTypeName) - { - assigned[serializedTypeName] = type; - return type; - } - } - assigned[serializedTypeName] = null; return null; } diff --git a/Robust.Shared/Serialization/SerializedTypeAttribute.cs b/Robust.Shared/Serialization/SerializedTypeAttribute.cs deleted file mode 100644 index dbfcf1ad8..000000000 --- a/Robust.Shared/Serialization/SerializedTypeAttribute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace Robust.Shared.Serialization -{ - [AttributeUsage(AttributeTargets.Class, Inherited = false)] - public sealed class SerializedTypeAttribute : Attribute - { - /// - /// Name of this type in serialized files. - /// - public string SerializeName { get; } - - public SerializedTypeAttribute(string serializeName) - { - SerializeName = serializeName; - } - } -} diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/ComponentRegistrySerializer.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/ComponentRegistrySerializer.cs index 32e2c32a8..0c85376fa 100644 --- a/Robust.Shared/Serialization/TypeSerializers/Implementations/ComponentRegistrySerializer.cs +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/ComponentRegistrySerializer.cs @@ -18,10 +18,14 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations { [TypeSerializer] - public sealed partial class ComponentRegistrySerializer : BaseTypeSerializer, ITypeSerializer, ITypeInheritanceHandler, ITypeCopier + public sealed partial class ComponentRegistrySerializer : BaseTypeSerializer, ITypeSerializer, ITypeInheritanceHandler, ITypeCopier, + IPostInjectInit { + [Dependency] private IDynamicTypeFactory _dynamicTypeFactory = default!; [Dependency] private IComponentFactory _factory = default!; + private IDynamicTypeFactoryInternal _dynamicTypeFactoryInternal = default!; + public ComponentRegistry Read(ISerializationManager serializationManager, SequenceDataNode node, IDependencyCollection dependencies, @@ -36,7 +40,7 @@ public ComponentRegistry Read(ISerializationManager serializationManager, foreach (var sequenceEntry in node.Sequence) { var componentMapping = (MappingDataNode)sequenceEntry; - string compType = ((ValueDataNode) componentMapping.Get("type")).Value; + var compType = ((ValueDataNode) componentMapping.Get("type")).Value; // See if type exists to detect errors. switch (_factory.GetComponentAvailability(compType)) { @@ -51,16 +55,10 @@ public ComponentRegistry Read(ISerializationManager serializationManager, continue; } - // Has this type already been added? - if (components.ContainsKey(compType)) - { - Log.Error($"Component of type '{compType}' defined twice in prototype!"); - continue; - } - var registration = _factory.GetRegistration(compType); var compIdx = registration.Idx; + // Has this type already been added? if (referenceTypes[..refIdx].Contains(compIdx)) { throw new InvalidOperationException( @@ -69,13 +67,15 @@ public ComponentRegistry Read(ISerializationManager serializationManager, referenceTypes[refIdx++] = compIdx; - var copy = componentMapping.Copy()!; - copy.Remove("type"); - - var read = (IComponent)serializationManager.Read(registration.Type, copy, hookCtx, context)!; + var comp = (Component) _dynamicTypeFactoryInternal.CreateInstanceUnchecked(registration.Type, inject: false); +#pragma warning disable CS0618 // Type or member is obsolete + comp = comp.Instantiate(); +#pragma warning restore CS0618 // Type or member is obsolete + comp.ReadComp(ref comp, componentMapping, serializationManager, hookCtx, context); + SerializationManager.TryRunAfterHook(comp, hookCtx); // The full YAML mapping is already retained by PrototypeManager. - components[compType] = new ComponentRegistryEntry(read); + components[compType] = new ComponentRegistryEntry(comp); } return components; @@ -132,9 +132,7 @@ public ValidationNode Validate(ISerializationManager serializationManager, referenceTypes[refIdx++] = compIdx; - var copy = componentMapping.Copy(); - copy.Remove("type"); - + var copy = componentMapping.CopyNoType(); list.Add(serializationManager.ValidateNode(registration.Type, copy, context)); } @@ -228,5 +226,10 @@ private Dictionary ToTypeIndexedDictionary(SequenceD return dict; } + + void IPostInjectInit.PostInject() + { + _dynamicTypeFactoryInternal = (IDynamicTypeFactoryInternal) _dynamicTypeFactory; + } } } diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/AbstractDictionarySerializer.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/AbstractDictionarySerializer.cs index 2a1142166..2ee7e66f0 100644 --- a/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/AbstractDictionarySerializer.cs +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/AbstractDictionarySerializer.cs @@ -42,7 +42,9 @@ public Dictionary Read(ISerializationManager serializationManager, foreach (var (key, valueNode) in node.Children) { var type = serializationManager.ReflectionManager.YamlTypeTagLookup(typeof(TValue), key)!; - var value = (TValue) serializationManager.Read(type, valueNode, hookCtx, context, notNullableOverride:true)!; + var copy = valueNode.Copy(); + copy.Tag = $"!type:{key}"; + var value = serializationManager.Read(copy, hookCtx, context, notNullableOverride: true); dict.Add(type, value); } diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/PhysicsHullSerializer.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/PhysicsHullSerializer.cs new file mode 100644 index 000000000..4ee1266ba --- /dev/null +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/Custom/PhysicsHullSerializer.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Robust.Shared.IoC; +using Robust.Shared.Physics; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Validation; +using Robust.Shared.Serialization.TypeSerializers.Interfaces; + +namespace Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +public sealed class PhysicsHullSerializer : ITypeSerializer +{ + public Vector2[] Read( + ISerializationManager serializationManager, + SequenceDataNode node, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context = null, + ISerializationManager.InstantiationDelegate? instanceProvider = null) + { + var vertices = ReadVertices(serializationManager, node, hookCtx, context); + var hull = PhysicsHull.ComputePoints(vertices, vertices.Length); + if (hull.Length < 3) + throw new InvalidMappingException($"Physics hull requires 3-{PhysicsConstants.MaxPolygonVertices} non-collinear vertices."); + + return hull.ToArray(); + } + + public ValidationNode Validate( + ISerializationManager serializationManager, + SequenceDataNode node, + IDependencyCollection dependencies, + ISerializationContext? context = null) + { + if (node.Count is < 3 or > PhysicsConstants.MaxPolygonVertices) + return new ErrorNode(node, $"Physics hull requires 3-{PhysicsConstants.MaxPolygonVertices} vertices."); + + var validations = new List(node.Count); + foreach (var dataNode in node) + { + validations.Add(serializationManager.ValidateNode(dataNode, context)); + } + + if (validations.Exists(validation => !validation.Valid)) + return new ValidatedSequenceNode(validations); + + var vertices = ReadVertices( + serializationManager, + node, + SerializationHookContext.ForSkipHooks(false), + context); + + if (PhysicsHull.ComputePoints(vertices, vertices.Length).Length < 3) + return new ErrorNode(node, "Physics hull vertices must form a non-collinear convex polygon."); + + return new ValidatedSequenceNode(validations); + } + + public DataNode Write( + ISerializationManager serializationManager, + Vector2[] value, + IDependencyCollection dependencies, + bool alwaysWrite = false, + ISerializationContext? context = null) + { + var hull = PhysicsHull.ComputePoints(value, value.Length); + var sequence = new SequenceDataNode(hull.Length); + foreach (var vertex in hull) + { + sequence.Add(serializationManager.WriteValue(vertex, alwaysWrite, context)); + } + + return sequence; + } + + private static Vector2[] ReadVertices( + ISerializationManager serializationManager, + SequenceDataNode node, + SerializationHookContext hookCtx, + ISerializationContext? context) + { + var vertices = new Vector2[node.Count]; + for (var i = 0; i < node.Count; i++) + { + vertices[i] = serializationManager.Read(node[i], hookCtx, context); + } + + return vertices; + } +} diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/DictionarySerializer.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/DictionarySerializer.cs index 927199c81..131806aec 100644 --- a/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/DictionarySerializer.cs +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/DictionarySerializer.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; -using System.Linq; using Robust.Shared.IoC; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Manager.Attributes; @@ -78,7 +77,7 @@ private MappingDataNode InterfaceWrite( bool alwaysWrite = false, ISerializationContext? context = null) { - var mappingNode = new MappingDataNode(); + var mappingNode = new MappingDataNode(value.Count); foreach (var (key, val) in value) { // TODO SERIALIZATION @@ -123,7 +122,7 @@ public DataNode Write(ISerializationManager serializationManager, IReadOnlyDicti bool alwaysWrite = false, ISerializationContext? context = null) { - return InterfaceWrite(serializationManager, value.ToDictionary(k => k.Key, v => v.Value), alwaysWrite, context); + return InterfaceWrite(serializationManager, value, alwaysWrite, context); } #endregion @@ -134,7 +133,7 @@ public Dictionary Read(ISerializationManager serializationManager, MappingDataNode node, IDependencyCollection dependencies, SerializationHookContext hookCtx, ISerializationContext? context, ISerializationManager.InstantiationDelegate>? instanceProvider) { - var dict = instanceProvider != null ? instanceProvider() : new Dictionary(); + var dict = instanceProvider != null ? instanceProvider() : new Dictionary(node.Children.Count); var keyNode = new ValueDataNode(); foreach (var (key, value) in node.Children) @@ -184,7 +183,7 @@ IReadOnlyDictionary ITypeReader, $"Provided value to a Read-call for a {nameof(IReadOnlyDictionary)}. Ignoring..."); } - var dict = new Dictionary(); + var dict = new Dictionary(node.Children.Count); var keyNode = new ValueDataNode(); foreach (var (key, value) in node.Children) diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/HashSetSerializer.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/HashSetSerializer.cs index bd171cdc7..012a4c441 100644 --- a/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/HashSetSerializer.cs +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/HashSetSerializer.cs @@ -31,7 +31,7 @@ HashSet ITypeReader, SequenceDataNode>.Read(ISerializationManager ISerializationContext? context, ISerializationManager.InstantiationDelegate>? instanceProvider) { - var set = instanceProvider != null ? instanceProvider() : new HashSet(); + var set = instanceProvider != null ? instanceProvider() : new HashSet(node.Sequence.Count); foreach (var dataNode in node.Sequence) { @@ -124,20 +124,30 @@ public DataNode Write(ISerializationManager serializationManager, ImmutableHashS bool alwaysWrite = false, ISerializationContext? context = null) { - return Write(serializationManager, value.ToHashSet(), dependencies, alwaysWrite, context); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); } public DataNode Write(ISerializationManager serializationManager, FrozenSet value, IDependencyCollection dependencies, bool alwaysWrite = false, ISerializationContext? context = null) { - return Write(serializationManager, value.ToHashSet(), dependencies, alwaysWrite, context); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); } public DataNode Write(ISerializationManager serializationManager, HashSet value, IDependencyCollection dependencies, bool alwaysWrite = false, ISerializationContext? context = null) { - var sequence = new SequenceDataNode(); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); + } + + private static DataNode WriteInternal( + ISerializationManager serializationManager, + IEnumerable value, + int count, + bool alwaysWrite = false, + ISerializationContext? context = null) + { + var sequence = new SequenceDataNode(count); foreach (var elem in value) { diff --git a/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/ListSerializers.cs b/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/ListSerializers.cs index f227b2e2a..f669a06d5 100644 --- a/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/ListSerializers.cs +++ b/Robust.Shared/Serialization/TypeSerializers/Implementations/Generic/ListSerializers.cs @@ -23,10 +23,10 @@ public sealed class ListSerializers : ITypeCopyCreator>, ITypeCopyCreator> { - private DataNode WriteInternal(ISerializationManager serializationManager, IEnumerable value, bool alwaysWrite = false, + private DataNode WriteInternal(ISerializationManager serializationManager, IEnumerable value, int? count = null, bool alwaysWrite = false, ISerializationContext? context = null) { - var sequence = new SequenceDataNode(); + var sequence = count is { } capacity ? new SequenceDataNode(capacity) : new SequenceDataNode(); foreach (var elem in value) { @@ -41,14 +41,14 @@ public DataNode Write(ISerializationManager serializationManager, ImmutableList< bool alwaysWrite = false, ISerializationContext? context = null) { - return WriteInternal(serializationManager, value, alwaysWrite, context); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); } public DataNode Write(ISerializationManager serializationManager, List value, IDependencyCollection dependencies, bool alwaysWrite = false, ISerializationContext? context = null) { - return WriteInternal(serializationManager, value, alwaysWrite, context); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); } public DataNode Write(ISerializationManager serializationManager, IReadOnlyCollection value, @@ -56,7 +56,7 @@ public DataNode Write(ISerializationManager serializationManager, IReadOnlyColle bool alwaysWrite = false, ISerializationContext? context = null) { - return WriteInternal(serializationManager, value, alwaysWrite, context); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); } public DataNode Write(ISerializationManager serializationManager, IReadOnlyList value, @@ -64,7 +64,7 @@ public DataNode Write(ISerializationManager serializationManager, IReadOnlyList< bool alwaysWrite = false, ISerializationContext? context = null) { - return WriteInternal(serializationManager, value, alwaysWrite, context); + return WriteInternal(serializationManager, value, value.Count, alwaysWrite, context); } List ITypeReader, SequenceDataNode>.Read(ISerializationManager serializationManager, @@ -73,7 +73,7 @@ List ITypeReader, SequenceDataNode>.Read(ISerializationManager serial SerializationHookContext hookCtx, ISerializationContext? context, ISerializationManager.InstantiationDelegate>? instanceProvider) { - var list = instanceProvider != null ? instanceProvider() : new List(); + var list = instanceProvider != null ? instanceProvider() : new List(node.Sequence.Count); foreach (var dataNode in node.Sequence) { @@ -132,7 +132,7 @@ IReadOnlyList ITypeReader, SequenceDataNode>.Read( Log.Warning($"Provided value to a Read-call for a {nameof(IReadOnlySet)}. Ignoring..."); } - var list = new List(); + var list = new List(node.Sequence.Count); foreach (var dataNode in node.Sequence) { @@ -153,7 +153,7 @@ IReadOnlyCollection ITypeReader, SequenceDataNode>.Rea Log.Warning($"Provided value to a Read-call for a {nameof(IReadOnlyCollection)}. Ignoring..."); } - var list = new List(); + var list = new List(node.Sequence.Count); foreach (var dataNode in node.Sequence) { diff --git a/Robust.Shared/Upload/SharedPrototypeLoadManager.cs b/Robust.Shared/Upload/SharedPrototypeLoadManager.cs index a32b6cecc..68fd6eed4 100644 --- a/Robust.Shared/Upload/SharedPrototypeLoadManager.cs +++ b/Robust.Shared/Upload/SharedPrototypeLoadManager.cs @@ -16,7 +16,7 @@ namespace Robust.Shared.Upload; public abstract partial class SharedPrototypeLoadManager : IGamePrototypeLoadManager { [Dependency] private IReplayRecordingManager _replay = default!; - [Dependency] private IPrototypeManager _prototypeManager = default!; + [Dependency] private IPrototypeManagerInternal _prototypeManager = default!; [Dependency] private ILocalizationManager _localizationManager = default!; [Dependency] protected INetManager NetManager = default!; @@ -36,19 +36,50 @@ public virtual void Initialize() protected virtual void LoadPrototypeData(GamePrototypeLoadMessage message) { - var data = message.PrototypeData; - - // TODO validate yaml before loading? + TryLoadPrototypeData(message.PrototypeData); + } - var changed = new Dictionary>(); - _prototypeManager.LoadString(data, true, changed); - _prototypeManager.ReloadPrototypes(changed); - _localizationManager.ReloadLocalizations(); + protected bool TryLoadPrototypeData(string data) + { + try + { + LoadPrototypeData(data); + } + catch (Exception e) + { + _sawmill.Error($"Failed to load prototype data. Dropping upload.\n{e}"); + // LoadString can leave partial prototype data behind before failing. + TryReloadLoadedPrototypeData(); + return false; + } // Add to replay recording after we have loaded the file, in case it contains bad yaml that throws exceptions. LoadedPrototypes.Add(data); _replay.RecordReplayMessage(new ReplayPrototypeUploadMsg { PrototypeData = data }); _sawmill.Info("Loaded adminbus prototype data."); + return true; + } + + private void LoadPrototypeData(string data) + { + var changed = new Dictionary>(); + _prototypeManager.LoadString(data, true, changed); + _prototypeManager.ReloadPrototypesOrThrow(changed); + _localizationManager.ReloadLocalizations(); + } + + private void TryReloadLoadedPrototypeData() + { + try + { + _prototypeManager.Reset(); + if (LoadedPrototypes.Count != 0) + LoadPrototypeData(string.Join("\n\n", LoadedPrototypes)); + } + catch (Exception e) + { + _sawmill.Error($"Failed to reload accepted prototype data.\n{e}"); + } } private void OnStartReplayRecording(MappingDataNode metadata, List events) diff --git a/Robust.Shared/Utility/CollectionHelpers.cs b/Robust.Shared/Utility/CollectionHelpers.cs new file mode 100644 index 000000000..de5795380 --- /dev/null +++ b/Robust.Shared/Utility/CollectionHelpers.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace Robust.Shared.Utility; + +public static class CollectionHelpers +{ + public static bool ContainsDuplicates(IReadOnlyList list) + { + var comparer = EqualityComparer.Default; + for (var i = 0; i < list.Count; i++) + { + for (var j = i + 1; j < list.Count; j++) + { + if (comparer.Equals(list[i], list[j])) + return true; + } + } + + return false; + } +} diff --git a/Robust.UnitTesting/Pool/TestPair.cs b/Robust.UnitTesting/Pool/TestPair.cs index 989099a72..830ccc825 100644 --- a/Robust.UnitTesting/Pool/TestPair.cs +++ b/Robust.UnitTesting/Pool/TestPair.cs @@ -79,8 +79,12 @@ public async Task Init( ClientLogHandler.ActivateContext(testOut); ServerLogHandler.ActivateContext(testOut); - Client = await GenerateClient(); - Server = await GenerateServer(); + + // Need a new task so it doesn't wait until the first await (after the constructor) to run asynchronously + var tasks = await Task.WhenAll(new[] {Task.Run(GenerateClientCast), Task.Run(GenerateServerCast)}); + Client = (TClient) tasks[0]; + Server = (TServer) tasks[1]; + ActivateContext(testOut); await ApplySettings(settings); @@ -115,6 +119,16 @@ protected virtual Task Initialize() protected abstract Task GenerateClient(); protected abstract Task GenerateServer(); + protected async Task GenerateClientCast() + { + return await GenerateClient(); + } + + protected async Task GenerateServerCast() + { + return await GenerateServer(); + } + public void Kill() { State = PairState.Dead;