From a2764a00bf58f5900a864d7d3790590833fd63e5 Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Mon, 18 Sep 2023 00:00:00 +0000 Subject: [PATCH] com.unity.netcode@1.1.0-exp.1 ## [1.1.0-exp.1] - 2023-09-18 ### Added * source generator can now be configure to enable/disable logs, report timings. It also possible to set the minimal log level (by default is now Error). * new public template specs and generator documentation * Added convenience methods for getting the clientworld / serverworld (or thin client list) added to ClientServerBootstrap * Additional analytics events. Multiplayer tools fields, prediction switching counters, tick rate configuration. * New method on the `PredictedFixedStepSimulationSystemGroup` class to initialise the rate as a multiple of a base tick rate. * `Packet Fuzz %` is now configurable via the Network Simulator. It's a security tool that should not be enabled during normal testing. It's purpose is to test against malicious MitM attacks, which aim to take down your server via triggering exceptions during packet deserialization. Thus, all deserialization code should be written with safeguards and tolerances, ensuring your logic will fail gracefully. * CopyInputToCommandBufferSystemGroup group, that contains all the system that copy IInputCommandData to the underlying ICommand buffer. This let you now target this group with the guarantee that all inputs are not copied after it run. * CopyCommandBufferToInputSystemGroup group, that contains all the system that copy ICommandData to their IInputCommandData representation. It runs first in the prediction loop and you can easily target it to do logic before or after the input are updated. * GhostSpawnClassificationSystemGroup, that is aimed to contains all your classification systems in one place. * Error messages to some missing `NetworkDriver.Begin/EndSend` locations. * defining `ENABLE_UNITY_RPC_REGISTRATION_LOGGING` will now log information about registered RPCs during netcode startup * We now automatically detect `Application.runInBackground` being set to false during multiplayer gameplay (a common error), and give advice via a suppressible error log as to why it should be enabled. * We introduced the new InputBufferData buffer, that is used as underlying container for all IInputComponentData. * conditional compilation for some public interfaces in DefaultDriverBuilder to exclude the use of RegisterServer methods for WebGL build (they can't listen). It is possible to do everything manually, but the helper methods are not present anymore. * new method for creating a NetworkDriver using WebSocketNetworkInterface. * Added two new values to the `NetworkStreamDisconnectReason` enum: `AuthenticationFailure` and `ProtocolError`. The former is returned when the transport is configured to use DTLS or TLS and it fails to establish a secure session. The latter is returned for low-level unexpected transport errors (e.g. malformed packets in a TCP stream). ### Changed * relaxed public field condition for variants. When declaring a ghost component variations, the variant fields are not required to be public. This make the type pretty much unusable for any other purpose but declaring the type serialisation. * Increased the ThinClient cap on `MultiplayerPlayModePreferences.MaxNumThinClients` from 32 to 1k, to facilitate some amount of in-editor testing of high-player-counts. * NetcodeTestWorld updates the worlds in the same way the package does: first server, then all clients worlds. * When Dedicated Server package is installed, the PlayMode Type value is overridden by the active Multiplayer Role. ### Deprecated * The public `PredictedFixedStepSimulationGroup.TimeStep`. You should always use the `PredictedFixedStepSimulationGroup.ConfigureTimeStep` to setup the rate of the `PredictedFixedStepSimulationSystemGroup.`. * the IInputBufferData interface (internal for code-gen use but public) has been deprecated and will be removed in the 1.2 release. ### Fixed * incorrect code generated serialization and calculated ChangeMask bits for component and buffers when the GhostFieldAttribute.Composite flag is set to true (in some cases). * wrong check for typename in GhostComponentVariation * missing region in unquantized float template, causing errors when used for interpolated field. * improper check when the ClientServerSetting asset is saved, causing worker process not seeing the changes in the settings. * The server world was not setting the correct rate to the group, if that was not done as part of the bootstrap. * Exception thrown when the NetDbg tools is connecting to either the editor or player. * Renamed (and marginally improved) the "Multiplayer PlayMode Tools" Window to the "PlayMode Tools" Window, to disambiguate it from "[MPPM] Multiplayer Play Mode" (an Engine feature). * Attempting to access internals of Netcode for Entities (e.g. via Assembly Definition References) would cause compiler errors due to `MonoPInvokeCallbackAttribute` being ambiguous between AOT and Unity.Entities. * Packet dump logging exception when using relevancy, despawns, and packet dumps enabled. Also fixed performance overhead (as it was erroneously logging stack traces). * An issue with variant hash calculation in release build, causing ghost prefab hash being different in between development/editor and release build. * GhostUpdateSystem.RestoreFromBackup does not always invalidate/bump the chunk version for a component, but only if the chunk as changed since the last time the restore occurred. * Issue in TryGetHashElseZero, that was using the ComponentType.GetDebugName to calculate the variant hash, leading incorrect results in a release player build * A `NetworkDriver.BeginSend` error causing an infinite loop in the `RpcSystem`. * Deprecated Analytics API when using 2023.2 or newer. * compilation issue when using 2023.2, caused by an ambiguous symbol (define in both Editor and in Entities.Editor assembly) * Errant netcode systems no longer show up in the DefaultWorld: `PhysicsWorldHistory`, `SwitchPredictionSmoothingPhysicsOrderingSystem`, `SwitchPredictionSmoothingSystem`, `GhostPresentationGameObjectTransformSystem`, `GhostPresentationGameObjectSystem`, and `SetLocalPlayerGraphicsColorsSystem`. * Previous was hard to retrieve the generated buffer for a given IInputComponentData. Now is easy as doing something like InputBufferData. * Compilation error when building for WebGL * SnapshotDataLookupCache not created in the correct order, causing custom classification system using the SnapshotBufferHelper to throw exceptions, because the cache was not initialised. * A replicated `[GhostEnabledBit]` flag component would throw an `ArgumentException` when added to a Prespawned Ghost due to `ArchetypeChunk.GetDynamicComponentDataArrayReinterpret`. --- .buginfo | 4 + .footignore | 1 + CHANGELOG.md | 168 +++++- Documentation~/TableOfContents.md | 2 + Documentation~/client-server-worlds.md | 101 +++- Documentation~/debugging.md | 11 +- Documentation~/ghost-snapshots.md | 26 +- Documentation~/ghost-spawning.md | 49 +- Documentation~/network-connection.md | 9 +- Documentation~/networked-cube.md | 182 +++++- Documentation~/optimizations.md | 2 +- Documentation~/playmode-tool.md | 15 +- Documentation~/prediction.md | 2 +- Documentation~/sourcegenerators.md | 122 ++++ Documentation~/whats-new.md | 2 +- Editor/Authoring/GhostComponentAnalytics.cs | 360 ++++++++---- Editor/Authoring/HierarchyDrawers.cs | 2 +- .../BoundingBoxDebugGhostDrawerSystem.cs | 6 +- Editor/MultiplayerPlayModeWindow.cs | 253 +++++---- Editor/SourceGeneratorSettings.cs | 42 ++ Editor/SourceGeneratorSettings.cs.meta | 3 + Editor/Templates/CommandDataSerializer.cs | 67 ++- Editor/Templates/GhostComponentSerializer.cs | 26 +- Editor/Templates/InputSynchronization.cs | 103 +--- Editor/Templates/RpcCommandSerializer.cs | 3 +- Editor/Unity.NetCode.Editor.asmdef | 11 +- .../GhostConfigurationAnalyticsData.cs | 4 + Runtime/Authoring/DefaultVariantSystemBase.cs | 12 +- Runtime/Authoring/GhostVariantsUtility.cs | 65 ++- .../Hybrid/PreSpawnedGhostsBakingSystem.cs | 12 +- .../Unity.NetCode.Authoring.Hybrid.asmdef | 12 +- .../ClientInitializationSystemGroup.cs | 2 - .../ClientPresentationSystemGroup.cs | 2 - .../ClientServerBootstrap.cs | 179 +++--- .../ClientServerWorld/ClientServerTickRate.cs | 126 ++++- .../ClientSimulationSystemGroup.cs | 10 +- .../ServerInitializationSystemGroup.cs | 2 - .../ServerSimulationSystemGroup.cs | 18 +- Runtime/Command/CommandSendSystem.cs | 29 +- Runtime/Command/CommandTarget.cs | 6 +- Runtime/Command/ICommandData.cs | 15 + Runtime/Command/IInputComponentData.cs | 260 ++------- Runtime/Command/InputCommandSystems.cs | 223 ++++++++ Runtime/Command/InputCommandSystems.cs.meta | 3 + .../Connection/DefaultDriverConstructor.cs | 227 ++++++-- Runtime/Connection/NetworkId.cs | 6 +- .../Connection/NetworkIdDebugColorUtility.cs | 4 +- .../Connection/NetworkSimulatorSettings.cs | 13 +- .../NetworkStreamConnectionComponent.cs | 4 + .../Connection/NetworkStreamReceiveSystem.cs | 63 +-- Runtime/Connection/NetworkTimeSystem.cs | 4 - .../WarnAboutApplicationRunInBackground.cs | 58 ++ ...arnAboutApplicationRunInBackground.cs.meta | 3 + Runtime/Debug/DebugGhostDrawer.cs | 42 +- Runtime/Debug/NetDebug.cs | 59 +- Runtime/Debug/NetDebugSystem.cs | 7 +- Runtime/Hybrid/GhostPresentationGameObject.cs | 7 +- Runtime/Hybrid/Unity.NetCode.Hybrid.asmdef | 1 - .../Unity.NetCode.Physics.Hybrid.asmdef | 3 +- Runtime/Physics/PhysicsWorldHistory.cs | 1 + .../Physics/PredictedPhysicsSystemGroup.cs | 20 +- Runtime/Rpc/RpcSystem.cs | 127 +++-- .../MultiplayerPlayModePreferences.cs | 105 +++- Runtime/Simulator/SimulatorPreset.cs | 46 +- .../DefaultTranslationSmoothingAction.cs | 3 +- .../Snapshot/GhostChunkSerializationState.cs | 2 +- Runtime/Snapshot/GhostChunkSerializer.cs | 26 +- Runtime/Snapshot/GhostCollectionComponent.cs | 6 +- Runtime/Snapshot/GhostCollectionSystem.cs | 9 +- Runtime/Snapshot/GhostComponentSerializer.cs | 15 +- ...omponentSerializerCollectionSystemGroup.cs | 16 +- Runtime/Snapshot/GhostDistanceImportance.cs | 3 +- Runtime/Snapshot/GhostImportance.cs | 3 +- .../Snapshot/GhostPredictionDebugSystem.cs | 2 +- .../Snapshot/GhostPredictionHistorySystem.cs | 139 +++-- .../GhostPredictionSmoothingSystem.cs | 2 +- .../GhostPredictionSwitchingQueues.cs | 31 ++ .../Snapshot/GhostPredictionSystemGroup.cs | 128 +++-- Runtime/Snapshot/GhostReceiveSystem.cs | 14 +- Runtime/Snapshot/GhostSendSystem.cs | 92 +-- Runtime/Snapshot/GhostSerializationHelper.cs | 1 + .../GhostSpawnClassificationSystem.cs | 25 +- Runtime/Snapshot/GhostSpawnSystem.cs | 6 +- Runtime/Snapshot/GhostUpdateSystem.cs | 461 +++++++++------- .../Prespawn/AutoTrackPrespawnSection.cs | 8 +- .../ClientPopulatePrespawnedGhostsSystem.cs | 15 +- .../ClientTrackLoadedPrespawnSections.cs | 8 +- .../Snapshot/Prespawn/PrespawnComponents.cs | 2 +- .../PrespawnGhostInitializationSystem.cs | 14 +- .../Snapshot/Prespawn/PrespawnGhostJobs.cs | 10 +- Runtime/Snapshot/Prespawn/PrespawnHelper.cs | 8 +- .../ServerPopulatePrespawnedGhosts.cs | 13 +- .../ServerTrackLoadedPrespawnSections.cs | 10 +- .../SnapshotDataBufferComponentLookup.cs | 1 + .../SwitchPredictionSmoothingSystem.cs | 1 + .../NetCodeSourceGenerator.dll | Bin 281088 -> 275968 bytes .../NetCodeSourceGenerator.pdb | Bin 45056 -> 35592 bytes .../CodeGenerator/CodeGenerator.cs | 53 +- .../CodeGenerator/GhostCodeGen.cs | 2 +- .../Generators/DiagnosticReporter.cs | 16 + .../Generators/NetCodeSyntaxReceiver.cs | 3 + .../Generators/TemplateFileProvider.cs | 50 +- .../Helpers/Profiler.cs | 20 +- .../Helpers/RoslynExtensions.cs | 54 +- .../Helpers/SourceGeneratorHelpers.cs | 75 ++- .../IDiagnosticReporter.cs | 7 + .../Source~/Tests/SourceGeneratorTests.cs | 30 +- .../Source~/Tests/SyntaxReceiver_Tests.cs | 19 + Runtime/Stats/netdbg.js | 15 + Runtime/Unity.NetCode.asmdef | 17 +- Tests/Editor/BootstrapTests.cs | 6 +- Tests/Editor/ChangeFilterTests.cs | 425 ++++++++++++++ Tests/Editor/ChangeFilterTests.cs.meta | 11 + Tests/Editor/CommandBufferSerialization.cs | 8 +- Tests/Editor/CommandDataTests.cs | 20 +- Tests/Editor/ConnectionTests.cs | 112 +++- Tests/Editor/ExtrapolationTests.cs | 4 +- Tests/Editor/GameObjectConversionTest.cs | 2 +- Tests/Editor/GhostCollectionStreamingTests.cs | 6 +- Tests/Editor/GhostGenTestTypes.cs | 12 +- Tests/Editor/GhostGroupTests.cs | 8 +- ...GhostSerializationDataForEnableableBits.cs | 168 +++--- Tests/Editor/GhostSerializationTests.cs | 10 +- ...hostSerializationTestsForEnableableBits.cs | 522 ++++++++++++------ Tests/Editor/GhostSerializeBufferTests.cs | 26 +- Tests/Editor/GhostTypeTests.cs | 2 +- Tests/Editor/InputComponentDataTest.cs | 86 +-- Tests/Editor/InterpolationTests.cs | 8 +- Tests/Editor/InvalidUsageTests.cs | 4 +- Tests/Editor/LateJoinCompletionTests.cs | 6 +- Tests/Editor/MultiDriverTests.cs | 2 +- Tests/Editor/MultiEntityGhostTests.cs | 4 +- Tests/Editor/PartialSendTests.cs | 2 +- Tests/Editor/PerPrefabOverridesTests.cs | 8 +- Tests/Editor/PredictionSwitchTests.cs | 4 +- Tests/Editor/PredictionTests.cs | 107 ++-- .../Assets/Whitebox_Ground_1600x1600_A.prefab | 30 +- Tests/Editor/Prespawn/LateJoinOptTests.cs | 6 +- Tests/Editor/Prespawn/PreSpawnTests.cs | 158 +++++- Tests/Editor/RelevancyTests.cs | 4 +- Tests/Editor/RpcTestSystems.cs | 11 +- Tests/Editor/RpcTests.cs | 50 +- Tests/Editor/SendToOwnerTests.cs | 2 +- Tests/Editor/SnapshotDataBufferLookupTests.cs | 8 +- Tests/Editor/SpawnTests.cs | 6 +- Tests/Editor/StaticOptimizationTests.cs | 2 +- Tests/Editor/SubSceneLoadingTests.cs | 24 +- .../SubSceneLoadingTests_CustomAckFlow.cs | 2 +- Tests/Editor/TestEnterExitGame.cs | 2 +- Tests/Editor/WorldMigrationTests.cs | 6 +- Tests/Utils/Editor.meta | 3 + Tests/Utils/Editor/PlaymodeUtils.cs | 22 + Tests/Utils/Editor/PlaymodeUtils.cs.meta | 3 + Tests/Utils/LoggingForward.cs | 30 + Tests/Utils/LoggingForward.cs.meta | 11 + Tests/Utils/NetCodePrespawnAuthoring.cs | 50 ++ Tests/Utils/NetCodeScenarioUtils.cs | 5 +- Tests/Utils/NetCodeTestWorld.cs | 124 +++-- .../Utils/Proxies/GhostReceiveSystemProxy.cs | 1 + Tests/Utils/Proxies/GhostSendSystemProxy.cs | 2 - Tests/Utils/Proxies/GhostUpdateSystemProxy.cs | 3 +- Tests/Utils/SubSceneHelper.cs | 41 +- Tests/Utils/Unity.NetCode.TestsUtils.asmdef | 8 +- ValidationExceptions.json | 14 +- ValidationExceptions.json.meta | 2 +- package.json | 14 +- 166 files changed, 4670 insertions(+), 2334 deletions(-) create mode 100644 .buginfo create mode 100644 .footignore create mode 100644 Documentation~/sourcegenerators.md create mode 100644 Editor/SourceGeneratorSettings.cs create mode 100644 Editor/SourceGeneratorSettings.cs.meta create mode 100644 Runtime/Command/InputCommandSystems.cs create mode 100644 Runtime/Command/InputCommandSystems.cs.meta create mode 100644 Runtime/Connection/WarnAboutApplicationRunInBackground.cs create mode 100644 Runtime/Connection/WarnAboutApplicationRunInBackground.cs.meta create mode 100644 Tests/Editor/ChangeFilterTests.cs create mode 100644 Tests/Editor/ChangeFilterTests.cs.meta create mode 100644 Tests/Utils/Editor.meta create mode 100644 Tests/Utils/Editor/PlaymodeUtils.cs create mode 100644 Tests/Utils/Editor/PlaymodeUtils.cs.meta create mode 100644 Tests/Utils/LoggingForward.cs create mode 100644 Tests/Utils/LoggingForward.cs.meta diff --git a/.buginfo b/.buginfo new file mode 100644 index 0000000..6fa36b8 --- /dev/null +++ b/.buginfo @@ -0,0 +1,4 @@ +system: jira +server: jira.unity3d.com +project: ECSB +issuetype: Bug \ No newline at end of file diff --git a/.footignore b/.footignore new file mode 100644 index 0000000..9cf577b --- /dev/null +++ b/.footignore @@ -0,0 +1 @@ +ValidationExceptions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fee507b..3cbd769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## [1.1.0-exp.1] - 2023-09-18 + +### Added + +* source generator can now be configure to enable/disable logs, report timings. It also possible to set the minimal log level (by default is now Error). +* new public template specs and generator documentation +* Added convenience methods for getting the clientworld / serverworld (or thin client list) added to ClientServerBootstrap +* Additional analytics events. Multiplayer tools fields, prediction switching counters, tick rate configuration. +* New method on the `PredictedFixedStepSimulationSystemGroup` class to initialise the rate as a multiple of a base tick rate. +* `Packet Fuzz %` is now configurable via the Network Simulator. It's a security tool that should not be enabled during normal testing. It's purpose is to test against malicious MitM attacks, which aim to take down your server via triggering exceptions during packet deserialization. Thus, all deserialization code should be written with safeguards and tolerances, ensuring your logic will fail gracefully. +* CopyInputToCommandBufferSystemGroup group, that contains all the system that copy IInputCommandData to the underlying ICommand buffer. This let you now target this group with the guarantee that all inputs are not copied after it run. +* CopyCommandBufferToInputSystemGroup group, that contains all the system that copy ICommandData to their IInputCommandData representation. It runs first in the prediction loop and you can easily target it to do logic before or after the input are updated. +* GhostSpawnClassificationSystemGroup, that is aimed to contains all your classification systems in one place. +* Error messages to some missing `NetworkDriver.Begin/EndSend` locations. +* defining `ENABLE_UNITY_RPC_REGISTRATION_LOGGING` will now log information about registered RPCs during netcode startup +* We now automatically detect `Application.runInBackground` being set to false during multiplayer gameplay (a common error), and give advice via a suppressible error log as to why it should be enabled. +* We introduced the new InputBufferData buffer, that is used as underlying container for all IInputComponentData. +* conditional compilation for some public interfaces in DefaultDriverBuilder to exclude the use of RegisterServer methods for WebGL build (they can't listen). It is possible to do everything manually, but the helper methods are not present anymore. +* new method for creating a NetworkDriver using WebSocketNetworkInterface. +* Added two new values to the `NetworkStreamDisconnectReason` enum: `AuthenticationFailure` and `ProtocolError`. The former is returned when the transport is configured to use DTLS or TLS and it fails to establish a secure session. The latter is returned for low-level unexpected transport errors (e.g. malformed packets in a TCP stream). + +### Changed + +* relaxed public field condition for variants. When declaring a ghost component variations, the variant fields are not required to be public. This make the type pretty much unusable for any other purpose but declaring the type serialisation. +* Increased the ThinClient cap on `MultiplayerPlayModePreferences.MaxNumThinClients` from 32 to 1k, to facilitate some amount of in-editor testing of high-player-counts. +* NetcodeTestWorld updates the worlds in the same way the package does: first server, then all clients worlds. +* When Dedicated Server package is installed, the PlayMode Type value is overridden by the active Multiplayer Role. + +### Deprecated + +* The public `PredictedFixedStepSimulationGroup.TimeStep`. You should always use the `PredictedFixedStepSimulationGroup.ConfigureTimeStep` to setup the rate of the `PredictedFixedStepSimulationSystemGroup.`. +* the IInputBufferData interface (internal for code-gen use but public) has been deprecated and will be removed in the 1.2 release. + +### Fixed + +* incorrect code generated serialization and calculated ChangeMask bits for component and buffers when the GhostFieldAttribute.Composite flag is set to true (in some cases). +* wrong check for typename in GhostComponentVariation +* missing region in unquantized float template, causing errors when used for interpolated field. +* improper check when the ClientServerSetting asset is saved, causing worker process not seeing the changes in the settings. +* The server world was not setting the correct rate to the group, if that was not done as part of the bootstrap. +* Exception thrown when the NetDbg tools is connecting to either the editor or player. +* Renamed (and marginally improved) the "Multiplayer PlayMode Tools" Window to the "PlayMode Tools" Window, to disambiguate it from "[MPPM] Multiplayer Play Mode" (an Engine feature). +* Attempting to access internals of Netcode for Entities (e.g. via Assembly Definition References) would cause compiler errors due to `MonoPInvokeCallbackAttribute` being ambiguous between AOT and Unity.Entities. +* Packet dump logging exception when using relevancy, despawns, and packet dumps enabled. Also fixed performance overhead (as it was erroneously logging stack traces). +* An issue with variant hash calculation in release build, causing ghost prefab hash being different in between development/editor and release build. +* GhostUpdateSystem.RestoreFromBackup does not always invalidate/bump the chunk version for a component, but only if the chunk as changed since the last time the restore occurred. +* Issue in TryGetHashElseZero, that was using the ComponentType.GetDebugName to calculate the variant hash, leading incorrect results in a release player build +* A `NetworkDriver.BeginSend` error causing an infinite loop in the `RpcSystem`. +* Deprecated Analytics API when using 2023.2 or newer. +* compilation issue when using 2023.2, caused by an ambiguous symbol (define in both Editor and in Entities.Editor assembly) +* Errant netcode systems no longer show up in the DefaultWorld: `PhysicsWorldHistory`, `SwitchPredictionSmoothingPhysicsOrderingSystem`, `SwitchPredictionSmoothingSystem`, `GhostPresentationGameObjectTransformSystem`, `GhostPresentationGameObjectSystem`, and `SetLocalPlayerGraphicsColorsSystem`. +* Previous was hard to retrieve the generated buffer for a given IInputComponentData. Now is easy as doing something like InputBufferData. +* Compilation error when building for WebGL +* SnapshotDataLookupCache not created in the correct order, causing custom classification system using the SnapshotBufferHelper to throw exceptions, because the cache was not initialised. +* A replicated `[GhostEnabledBit]` flag component would throw an `ArgumentException` when added to a Prespawned Ghost due to `ArchetypeChunk.GetDynamicComponentDataArrayReinterpret`. + ## [1.0.17] - 2023-09-11 @@ -22,9 +78,6 @@ ### Changed * Updated com.unity.entities dependency to 1.0.14 - -### Removed - * Use of non required TempJob allocation and use Allocator.Temp instead. ### Fixed @@ -32,6 +85,8 @@ * Runtime EntityQuery leaks and reduce runtime memory pressure due to continuously allocating queries without disposing. * Reduced memory usage in Editor tests, by avoiding allocating queries continuously in hot paths. +### Security + ## [1.0.12] - 2023-06-19 @@ -39,8 +94,6 @@ * Updated com.unity.entities dependency to 1.0.11 ### Fixed -* `MultiplayerPlayModeWindow > Dump Packet Logs` now works more reliably, now works with NUnit tests, and dump files are named with more context. -* Fixed bug in `GhostSendSystem` that caused it to not replicate ghosts when enabling packet dumps. `GhostValuesAreSerialized_WithPacketDumpsEnabled` test added. ## [1.0.11] - 2023-06-02 @@ -112,28 +165,7 @@ ### Changed -* The following components have been renamed: -NetworkSnapshotAckComponent: NetworkSnapshotAck, -IncomingSnapshotDataStreamBufferComponent: IncomingSnapshotDataStreamBuffer, -IncomingRpcDataStreamBufferComponent: IncomingRpcDataStreamBuffer, -OutgoingRpcDataStreamBufferComponent: OutgoingRpcDataStreamBuffer, -IncomingCommandDataStreamBufferComponent: IncomingCommandDataStreamBuffer, -OutgoingCommandDataStreamBufferComponent: OutgoingCommandDataStreamBuffer, -NetworkIdComponent: NetworkId, -CommandTargetComponent: CommandTarget, -GhostComponent: GhostInstance, -GhostChildEntityComponent: GhostChildEntity, -GhostOwnerComponent: GhostOwner, -PredictedGhostComponent: PredictedGhost, -GhostTypeComponent: GhostType, -SharedGhostTypeComponent: GhostTypePartition, -GhostCleanupComponent: GhostCleanup, -GhostPrefabMetaDataComponent: GhostPrefabMetaData, -PredictedGhostSpawnRequestComponent: PredictedGhostSpawnRequest, -PendingSpawnPlaceholderComponent: PendingSpawnPlaceholder, -ReceiveRpcCommandRequestComponent: ReceiveRpcCommandRequest, -SendRpcCommandRequestComponent: SendRpcCommandRequest, -MetricsMonitorComponent: MetricsMonitor, +* the following components has been renamed: | Original Name | New Name | | ---------------| ---------------| |NetworkSnapshotAckComponent| NetworkSnapshotAck | |IncomingSnapshotDataStreamBufferComponent| IncomingSnapshotDataStreamBuffer | |IncomingRpcDataStreamBufferComponent| IncomingRpcDataStreamBuffer | |OutgoingRpcDataStreamBufferComponent| OutgoingRpcDataStreamBuffer | |IncomingCommandDataStreamBufferComponent| IncomingCommandDataStreamBuffer | |OutgoingCommandDataStreamBufferComponent|OutgoingCommandDataStreamBuffer| |NetworkIdComponent|NetworkId| |CommandTargetComponent|CommandTarget| |GhostComponent|GhostInstance| |GhostChildEntityComponent|GhostChildEntity| |GhostOwnerComponent|GhostOwner| |PredictedGhostComponent|PredictedGhost| |GhostTypeComponent|GhostType| |SharedGhostTypeComponent|GhostTypePartition| |GhostCleanupComponent|GhostCleanup| |GhostPrefabMetaDataComponent|GhostPrefabMetaData| |PredictedGhostSpawnRequestComponent|PredictedGhostSpawnRequest| |PendingSpawnPlaceholderComponent|PendingSpawnPlaceholder| |ReceiveRpcCommandRequestComponent|ReceiveRpcCommandRequest| |SendRpcCommandRequestComponent|SendRpcCommandRequest| |MetricsMonitorComponent|MetricsMonitor| ### Removed @@ -185,6 +217,87 @@ MetricsMonitorComponent: MetricsMonitor, * Fixed input component codegen issue when the type is nested in a parent class +## [1.0.0-pre.15] - 2022-11-16 + +### Added + +* A "Client & Server Bounding Boxes" debug drawer has been added to the package (at `Packages\com.unity.netcode\Editor\Drawers\BoundingBoxDebugGhostDrawerSystem.cs`), allowing you to view the absolute positions of where the client _thinks_ a Ghost is, vs where it _actually_ is on the server. This drawer can also be used to visualize relevancy logic (as you can see widgets for server ghosts that are "not relevant" for your client). Enable & disable it via the `Multiplayer PlayMode Tools Window`. +* FRONTEND_PLAYER_BUILD scripting define added to the NetCodeClientSetting project setting. +* New `GhostSpawnBufferInspectorHelper` and `GhostSpawnBufferComponentInspector` structs, that permit to read from the ghost spawn buffer any type of component. They can be used in spawn classification systems to help resolving predicted spawning requests. +* `GhostTypeComponent` explicit conversion to Hash128. +* Templates for serialising `double` type. +* A `TransformDefaultVariantSystem` that optionally setup the default variant to use for `LocalTransform`, (`Rotation`, `Position` for V1) if a user defined default is not provided. +* A `PhysicsDefaultVariantSystem` that optionally setup the default variant to use for `PhysicVelocity` if a user defined default is not provided. +* New GetLocalEndPoint API to NetworkStreamDriver. +* `GhostAuthoringInspectionComponent` now provides more information about default variant selection. + +### Changed + +* Updated com.unity.transport dependency to 2.0.0-exp.4 +* `SharedGhostTypeComponent` is also added to the client ghost prefab to split ghosts in different chunks. +* `GhostTypeComponent` equals/matches the prefab guid. +* Removed `CodeGenTypeMetaData`, and made internal changes to how `VariantType` structs are generated. We also renamed `VariantType` to `ComponentTypeSerializationStrategies` to better reflect their purpose, and to better distinguish them from the concept of "Variants". +* Support for replicating "enable bits" on `IEnableableComponent`s (i.e. "enableable components") via new attribute `GhostEnabledBitAttribute` (`[GhostEnabledBit]`), which can be added to the component struct. Note: If this attribute is **not** added, your enabled bits will not replicate (even on components marked with `[GhostField]`s). _This is a breaking change. Ensure all your "enableable components" with "ghost fields" on them now also have `[GhostEnabledBit]` on the struct declaration._ +* All `DefaultVariantSystemBase` are all grouped into the `DefaultVariantSystemGroup`. +* It is not necessary anymore to define a custom `DefaultGhostVariant` system if a `LocalTransform` (`Rotation` or `Position` for V1) or `PhysicsVelocity` variants are added to project (since a `default` selection is already provided by the package). +* Updated `com.unity.transport` dependency to 2.0.0-pre.2 + + +### Deprecated + +* `ProjectSettings / NetCodeClientTarget` was not actually saved to the ProjectSettings. Instead, it was saved to `EditorPrefs`, breaking build determinism across machines. Now that this has been fixed, your EditorPref has been clobbered, and `ClientSettings.NetCodeClientTarget` has been deprecated (in favour of `NetCodeClientSettings.instance.ClientTarget`). + +### Removed + +* Removing dependencies on `com.unity.jobs` package. + +### Fixed + +* Error in source generator when input buffer type was in default namespace. +* Always pass `SystemState` by `ref` to avoid `UnsafeList`s being reallocated in a copy, but not in the original. +* Use correct datatype for prespawned count in analytics. +* Use `EditorAnalytics` to verify whether it is enabled. +* Exception thrown by the hierarchy window if a scene entity does not have a SubScene component. +* Issue with the `GhostComponentSerializerRegistrationSystem` and the ghost metadata registration system trying accessing the `GhostComponentSerializerCollectionData` before it is created. +* A crash in the `GhostUpdateSystem`, `GhostPredictionHistorySystem` and others, when different ghost types (prefab) share/have the same archetype. +* A NetCodeSample project issue that was causing screen flickering and entities rendered multiple times when demos were launched from the Frontend scene. +* An issue in the `GhostSendSystem` that prevent the DataStream to be aborted when an exception is throw while serialising the entities. +* InvalidOperationException in the `GhostAuthoringInspectionComponent` when reverting a Variant back to the default. +* UI layout issues with the `GhostAuthoringInspectionComponent`. +* Hashing issue with `GhostAuthoringInspectionComponent.ComponentOverrides` during baking, where out-of-date hashes would still be baked into the `BlobAsset`. You now get an error, pointing you to the offending (i.e. out-of-date) Ghost prefab. +* `quaternion`s cannot be added as fields in `ICommandData` and/or `IInputComponentData`. A new region has been added to the code-generation templates for handling other, similar cases. +* Fixed hash generation when using `DontSerializeVariant` (or `ClientOnlyVariant`) on `DefaultVariantSystemBase.Rule`. They now use the constant hashes (`DontSerializeHash` and `ClientOnlyHash`). +* `NetDbg` will now correctly show long namespaces in the "Prediction Errors" section (also: improved readability). +* Removed CSS warning in package. +* A problem with baking and additional ghost entities that was removing `LocalTransform`, `WorldTransform` and `LocalToWorld` matrix. +* Mismatched ClientServerTickRate.SimulationTickRate and PredictedFixedStepSimulationSystemGroup.RateManager.Timestep will throw an error and will set the values to match each other. +* An issue with pre-spawned ghost baking when the baked entity has not LocalTransform (position/rotation for transform v1) component. +* "Ghost Distance Importance Scaling" is now working again. Ensure you read the updated documentation. +* Missing field write in `NetworkStreamListenSystem.OnCreate`, fixing Relay servers. +* Code-Generated Burst-compiled Serializer methods will now only compile inside worlds with `WorldFlag.GameClient` and `WorldFlag.GameServer` WorldFlags. This improves exit play-mode speeds (when Domain Reload is enabled), baking (in all cases), and recompilation speeds. +* Fixed an issue where multiple ghost types with the same archetype but difference data could sometime trigger errors about ghosts changing type. +* Improvements to the `GhostAuthoringInspectionComponent`, including removing the freeze when a baker creates lots of "Additional" entities, better display of Inputs, and fixed bug where the EntityGuid was not being saved (so modifying additional Entities is now supported). We now also detect (but don't destroy) broken ComponentOverrides, making it easier to switch from TRANSFORMS_V1 (for example). +* Fix a mistake where the relay sample will create a client driver rather than a server driver +* Fix logic for relay set up on the client. Making sure when calling DefaultDriverConstructor.RegisterClientDriver with relay settings that we skip this unless, requested playtype is client or clientandserver (if no server is found), the simulator is enabled, or on a client only build. +* Fixed `ArgumentException: ArchetypeChunk.GetDynamicComponentDataArrayReinterpret cannot be called on zero-sized IComponentData` in `GhostPredictionHistorySystem.PredictionBackupJob`. Added comprehensive test coverage for the `GhostPredictionHistorySystem` (via adding a predicted ghost version of the `GhostSerializationTestsForEnableableBits` tests). +* Fixed serialization of components on child entities in the case where `SentForChildEntities = true`. This fix may introduce a small performance regression in baking and netcode world initialization. +* `GhostUpdateSystem` now supports Change Filtering, so components on the client will now only be marked as changed _when they actually are changed_. We strongly recommend implementing change filtering when reading components containing `[GhostField]`s and `[GhostEnabledBit]`s on the client. +* Fixed input component codegen issue when the type is nested in a parent class +* Exposed NetworkTick value to Entity Inspector. +* Fixed code-gen error where `ICommandData.Tick` was not being replicated. +* Fixed code-gen GhostField error handling when dealing with properties on Buffers, Commands, and Components. +* Fixed code-gen exceptions for `Entity`s, `float`s, `double`s, `quaternions` and `ulong`s in specific conditions (unquantized, or in commands). Also improved exception reporting when trying to set an invalid `SmoothingAction` on `ICommandData`s. +* Code-gen now will not explode if you have very long field names (support upto 509 characters, from 61), and will not throw truncation errors if you have too many fields. +* Added error log reporting for ICommandDatas: + * If you attempt to serialize more than 1024 bytes for an individual ICommandData. + * If there are failed writes in the ICommandData batched send. +* ICommandData batches now support fragmentation, which means writing multiple ICommandData's will no longer silently fail to send. +* ICommandData now properly supports `floats`, `doubles`, `ulong`, and `Entity` types. +* Fixed various Variant selection issues. In particular, `PrefabType` rules defined in `GhostComponentAttribute` of the "Default Serializer" will now be propagated to all of its `DontSerializeVariant`s. +* Optimized string locale. +* Netcode settings assets could be modified and saved when asset modification was not allowed. + + ## [1.0.0-exp.8] - 2022-09-21 ### Added @@ -330,7 +443,6 @@ All the information in regards the current simulated tick MUST be retrieved from * Package Dependencies * `com.unity.entities` to version `0.51.1` - ## [0.51.0] - 2022-05-04 ### Changed diff --git a/Documentation~/TableOfContents.md b/Documentation~/TableOfContents.md index e4bd6b9..a7ea730 100644 --- a/Documentation~/TableOfContents.md +++ b/Documentation~/TableOfContents.md @@ -18,7 +18,9 @@ * [Physics](physics.md) * [Prediction](prediction.md) * [Optimizations](optimizations.md) + * [Source Generators](sourcegenerators.md) * [Debugging and Tools](debugging.md) * [Playmode-Tool](playmode-tool.md) * [Logging](logging.md) * [Metrics](metrics.md) + * [Generator Debugging](sourcegenerators.md#how-to-debug-generator-problems) diff --git a/Documentation~/client-server-worlds.md b/Documentation~/client-server-worlds.md index 8a277cf..c5e9907 100644 --- a/Documentation~/client-server-worlds.md +++ b/Documentation~/client-server-worlds.md @@ -48,17 +48,29 @@ In the example above, we declared that the `MySystem` system should **only** be ## Bootstrap When the Netcode for Entities package is added to your project, a new default [bootstrap](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerBootstrap.html) is added to the project. -The default bootstrap creates client server Worlds automatically at startup. -It populates them with the systems defined in the attributes you have set. This is useful when you are working in the Editor and you enter play-mode with your game scene opened. -But in a standalone game, or when you want to use some sort of frontend menu, you might want to delay the World creation, i.e you can use the same executable as both a client and server. +The default bootstrap creates the client and server Worlds automatically at startup: +```c# + public virtual bool Initialize(string defaultWorldName) + { + CreateDefaultClientServerWorlds(); + return true; + } +``` + +It populates them with the systems defined by the `[WorldSystemFilter(...)]` attributes you have set. This is useful when you are working in the Editor, and you enter play-mode with your game scene opened. +But in a standalone game - where you typically want to use some sort of frontend menu - you might want to delay the World creation, and/or choose which netcode worlds to spawn. -It it possible to create your own bootstrap class and customise your game flow by creating a class that extends `ClientServerBootstrap` and override the default `Initialize` method implementation. -You can re-use in your class mostly of the provided helper methods that can let you create `client`, `server`, `thin-client` and `local simulation` worlds. See for more details [ClientServerBootstrap methods](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerBootstrap.html). +E.g. Consider a "Hosting a Client Hosted Server" flow vs a "Connect as a client to a Dedicated Server via Matchmaking" flow. +In the former case, you want to add (and connect via IPC to) an in-proc server world. In the latter, you only want to create a Client world. -The following code example shows how to override the default bootstrap to prevent automatic creation of the client server worlds: +It it possible to create your own bootstrap class and customise your game flow. +Create a class that extends `ClientServerBootstrap` (e.g. `MyGameSpecificBootstrap`), and override the default `Initialize` method implementation. +In your derived class, you can mostly re-use the provided helper methods, which let you create `client`, `server`, `thin-client` and `local simulation` worlds. See for more details [ClientServerBootstrap methods](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerBootstrap.html). + +The following code example shows how to override the default bootstrap, to prevent automatic creation of the client server worlds: ```c# -public class ExampleBootstrap : ClientServerBootstrap +public class MyGameSpecificBootstrap : ClientServerBootstrap { public override bool Initialize(string defaultWorldName) { @@ -70,6 +82,29 @@ public class ExampleBootstrap : ClientServerBootstrap } ``` +Then, when you're ready to create the various netcode worlds, call: +```c# +void OnPlayButtonClicked() +{ + // Typically this: + var clientWorld = ClientServerBoostrap.CreateClientWorld(); + // And/Or this: + var serverWorld = ClientServerBoostrap.CreateServerWorld(); + + // And/Or something like this, for soak testing: + const int numThinClientWorldsForStressTest = 10; + for(int i = 0; i < numThinClientWorldsForStressTest; i++) + ClientServerBoostrap.CreateThinClientWorld(); + + // Or the following, which creates worlds smartly based on: + // - The Playmode Tool setting specified in the editor. + // - The current build type, if used in a player. + ClientServerBootstrap.CreateDefaultClientServerWorlds(); +} +``` + +We have NetcodeSamples showcasing how to manage scene and sub-scene loading with this World creation setup, as well as proper netcode world disposal (when leaving the gameplay loop). + ## Fixed and dynamic time-step When you use Netcode for Entities, the server always updates **at a fixed time-step**. The package also limits the maximum number of fixed-step iterations per frame, to make sure that the server does not end up in a state where it takes several seconds to simulate a single frame. @@ -155,12 +190,54 @@ public World MigrateWorld(World sourceWorld) ## Thin Clients -Thin clients are a tool to help test and debug in the editor by running simulated dummy clients with your normal client and server worlds. See the _Playmode Tools_ section above for how to configure them +Thin clients are a tool to help test and debug in the editor, by running simulated dummy clients alongside your normal client and server worlds. +See the _Playmode Tools_ section above for how to configure them. + +These clients are heavily stripped down, and should run as little logic as possible (so they don't put a heavy load on the CPU while testing). +Each thin client added adds a little bit of extra work to be computed each frame. + +Only systems which have explicitly been set up to run on thin client worlds will run, marked with the `WorldSystemFilterFlags.ThinClientSimulation` flag on the `WorldSystemFilter` attribute. +No rendering is done for thin client data, so they are invisible to the presentation. -These clients are heavily stripped down and should run as little logic as possible so they don't put a heavy load on the CPU while testing. Each thin client added adds a little bit of extra work to be computed each frame. +In some cases, you might need to check if your system logic should be running for thin clients, and then early out or cancel processing. +The `World.IsThinClient()` extension methods can be used in these cases. -Only systems which have explicitly been set up to run on thin client worlds will run, marked with the `WorldSystemFilterFlags.ThinClientSimulation` flag on the `WorldSystemFilter` attribute. No rendering is done for thin client data so they are invisible to the presentation. +### Thin Client Workflow Recommendations -In some cases like in `MonoBehaviour` scripts you might need to check if it's running on a thin client and then early out or cancel processing, the `World.IsThinClient()` can be used in those cases. +Thin clients can be used in a variety of ways to help test multiplayer games. We recommend the following: +1) Thin Clients allow you to quickly test client flows: Things like team assignment, spawn locations, leaderboards, UI etc. +2) Thin Clients created in built players, allowing stress and soak testing of your game servers. _E.g. You may wish to add a configuration option to automatically create N Thin Client worlds (alongside your normal client world). Have each thin client "follow the leader" and automatically attempt to join the same IP Address and Port as your main client world. Thus, you can use your existing UI flows (matchmaking, lobby, relay etc.) to get these thin clients into the stress test target server._ +3) Thin Clients controlled by a second input source. I.e. Multiplayer games often have complex PvP interactions, and therefore you often wish to have an AI perform a specific action while your client is interacting with it. _Examples: Crouch, go prone, jump, run diagonally backwards, reload, enable shield, activate ability etc. Hooking thin client controls up to keyboard commands allows you to test these situations without requiring a play-test (or a second dev)._ +You can also hookup thin clients to have mirrored inputs of the tester, with similarly good results. -Most commonly the only important work they need to do is generate random inputs for the server to process. These inputs usually need to be added to a manually created dummy entity as no ghost spawning is done on thin clients. Not even for it's own local ghost/player. +### Thin Client Samples +- [NetcodeSamples > HelloNetcode > ThinClient](https://github.com/Unity-Technologies/EntityComponentSystemSamples/tree/master/NetcodeSamples/Assets/Samples/HelloNetcode/2_Intermediate/06_ThinClients) +- [NetcodeSamples > Asteroids](https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/f22bb949b3865c68d5fc588a6e8d032096dc788a/NetcodeSamples/Assets/Samples/Asteroids/Client/Systems/InputSystem.cs#L66) + +### Setting up inputs for Thin Clients + +Thin Client do not work out of the box with `AutoCommandTarget`. +This is because `AutoCommandTarget` requires the same ghost to exist on both the client and the server. +But - because Thin Clients do not create ghosts - `AutoCommandTarget` does not have a client entity to hookup to. +Thus, you need to set up the `CommandTarget` component on the connection entity yourself. + +`IInputComponentData` is our newest input API. It automatically handles writing out inputs (from your input struct) directly to the replicated Dynamic Buffer. +Additionally: When we bake the ghost entity - and said entity contains an `IInputCommandData` composed struct - we automatically add an underlying `ICommandData` dynamic buffer to the entity. +However: Once again, this baking process is not available on Thin Clients, as Thin Clients do not create ghosts entities. + +`ICommandData` is also supported with Thin Clients ([details here](command-stream.md)), but note that you'll need to perform the same thin client hookup work (below) that you do with `IInputComponentData`. + +Therefore, to support sending input from a Thin Client, you must do the following: + +1) Create an entity containing your `IInputCommmandData` (or `ICommandData`) component, as well as the code-generated `YourNamespace.YouCommandNameInputBufferData` dynamic buffer. **This may appear to throw a missing assembly definition error in your IDE, but it will work.** +2) You need to setup the `CommandTarget` component to point to this entity. Therefore, in a `[WorldSystemFilter(WorldSystemFilterFlags.ThinClientSimulation)]` system: +```c# + var myDummyGhostCharacterControllerEntity = entityManager.CreateEntity(typeof(MyNamespace.MyInputComponent), typeof(InputBufferData)); + var myConnectionEntity = SystemAPI.GetSingletonEntity(); + entityManager.SetComponentData(myConnectionEntity, new CommandTarget { targetEntity = myDummyGhostCharacterControllerEntity }); // This tells the netcode package which entity it should be sending inputs for. +``` + +And on the server (where you spawn the actual character controller ghost for the thinClient, which will be replicated to all proper clients), you **_only_** need to setup the `CommandTarget` for Thin Clients (as presumably your player ghosts all use `AutoCommandTarget`. If you're **_not_** using `AutoCommandTarget`, you probably already perform this action for all clients already). +```c# + entityManager.SetComponentData(thinClientConnectionEntity, new CommandTarget { targetEntity = thinClientsCharacterControllerGhostEntity }); +``` diff --git a/Documentation~/debugging.md b/Documentation~/debugging.md index 1d3cd2a..2e81d46 100644 --- a/Documentation~/debugging.md +++ b/Documentation~/debugging.md @@ -1,7 +1,8 @@ # Debugging -| **Topic** | **Description** | -|:-------------------------------------| :----------------------- | -| **[PlayModeTool](playmode-tool.md)** | Logging in Netcode for Entities | -| **[Logging](logging.md)** | Logging in Netcode for Entities | -| **[Metrics](metrics.md)** | Metrics in Netcode for Entities | +| **Topic** | **Description** | +|:--------------------------------------------|:--------------------------------------| +| **[PlayModeTool](playmode-tool.md)** | Configure logging and packet dumps | +| **[Logging](logging.md)** | Logging in Netcode for Entities | +| **[Metrics](metrics.md)** | Metrics in Netcode for Entities | +| **[SourceGenerators](sourcegenerators.md)** | SourceGenerator debugging and logging | diff --git a/Documentation~/ghost-snapshots.md b/Documentation~/ghost-snapshots.md index 42776a5..4422fd8 100644 --- a/Documentation~/ghost-snapshots.md +++ b/Documentation~/ghost-snapshots.md @@ -89,11 +89,11 @@ For a component to support serialization, the following conditions must be met: * `GhostField` `MaxSmoothingDistance` allows you to disable interpolation when the values change more than the specified limit between two snapshots. This is useful for dealing with teleportation, for example. * Finally the `GhostField` has a `SubType` property which can be set to an integer value to use special serialization rules supplied for that specific field. ->![NOTE] Speaking of teleportation: To support _short range_ teleportation, you'd need some other replicated bit to distinguish a teleport from a move (lerp). +>[!NOTE] Speaking of teleportation: To support _short range_ teleportation, you'd need some other replicated bit to distinguish a teleport from a move (lerp). ## Authoring dynamic buffer serialization Dynamic buffers serialization is natively supported. **Unlike components, to replicate a buffer, all public fields must be marked with at `[GhostField]` attribute.** ->![NOTE] This restriction has been added to guarantee that: In the case where an element is added to the buffer, when it is replicated to the client, all fields on said element will have meaningful values. +>[!NOTE] This restriction has been added to guarantee that: In the case where an element is added to the buffer, when it is replicated to the client, all fields on said element will have meaningful values. > This restriction may be removed in the future (e.g. by instead, defaulting this undefined behaviour to `default(T)`). ```csharp @@ -210,8 +210,7 @@ A component can also set __SendToOwner__ in the __GhostComponentAttribute__ to s * __SendToNonOwner__ - the component is sent to all clients except the one who owns the ghost * __All__ - the component is sent to all clients. ->![NOTE] By setting either the `SendTypeOptimisation` and/or `SendToOwner` (to specify to which types of client(s) the component should be replicated to), will not -> affect the presence of the component on the prefab, nor modify the component on the clients which did not receive it. +>[!NOTE] By setting either the `SendTypeOptimisation` and/or `SendToOwner` (to specify to which types of client(s) the component should be replicated to), will not affect the presence of the component on the prefab, nor modify the component on the clients which did not receive it. --- @@ -223,15 +222,15 @@ In addition to the default out-of-the-box types you can also: Please check how to [use and write templates](ghost-types-templates.md#Defining additional templates) for more information on the topic. ->![NOTE] Writing templates is non-trivial. If it is possible to replicate the type simply by adding GhostFields, it's often easier to just do so. -> If you do not have access to a type, create a Variant instead (see section below). +>[!NOTE] Writing templates is non-trivial. If it is possible to replicate the type simply by adding GhostFields, it's often easier to just do so. If you do not have access to a type, create a Variant instead (see section below). ## Ghost Component Variants The [GhostComponentVariationAttribute](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.GhostComponentVariationAttribute.html) is special attribute tha can be used to declare at compile time a "replication schema" for a type, without the need to markup the fields in the original type, or the original type itself.
->![NOTE]This new declared type act as proxy from a code-generation perspective. Instead of using the original type, the code-generation system use the declared "variant" to generate a specific -> version of the serialization code. -> ![NOTE] **Ghost components variants for `IBufferElementData` are not fully supported.** + +>[!NOTE]This new declared type act as proxy from a code-generation perspective. Instead of using the original type, the code-generation system use the declared "variant" to generate a specific version of the serialization code. + +>[!NOTE] **Ghost components variants for `IBufferElementData` are not fully supported.** The `GhostComponentVariationAttribute` has some specific use-cases in mind: - Variants allow user-code (you) to declare serialization rules for a component that you don't have direct write access too (_i.e. components in a package or external assembly_). _Example: Making `Unity.Entities.LocalTransform` replicated._ @@ -257,7 +256,7 @@ The attribute constructor takes a few arguments: * The user-friendly `string variantName`, which will allow you to better interpret `GhostAuthoringInspectionComponent` UI. Then, for each field in the original struct (in this case, `LocalTransform`) that you wish to replicate, you should add a `GhostField` attribute like you usually do, and define the field identically to that of the base struct. ->~[NOTE] Only members that are present in the component type are allowed. Validation occurs at compile time, and exceptions are thrown in case this rule is not respected. +>[!NOTE] Only members that are present in the component type are allowed. Validation occurs at compile time, and exceptions are thrown in case this rule is not respected. An optional `GhostComponentAttribute` attribute can be added to the variant to further specify the component serialization properties. @@ -364,8 +363,7 @@ When present, the component can't be customized in the inspector, nor can a prog For example: The Netcode for Entities package requires the [GhostOwner](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.GhostOwner.html) to be added to all ghost types, sent for all ghost types, and serialized using the default variant. Thus, we add the `[DontSupportPrefabOverride]` attribute to it. ->![NOTE] Components on child entities are not serialised by default, thus by default when you look to `GhostAuthoringInspectionComponent` on a child GameObject you will -> see that the selected variant for the type is the `DontSerializeVariant`. +>[!NOTE] Components on child entities are not serialised by default, thus by default when you look to `GhostAuthoringInspectionComponent` on a child GameObject you will see that the selected variant for the type is the `DontSerializeVariant`. DontSerializeVariant @@ -380,5 +378,5 @@ To understand what is being put on the wire in the Netcode, you can use the snap To open the tool, go to menu: __Multiplayer > Open NetDbg__, and the tool opens in a browser window. It displays a vertical bar for each received snapshot, with a breakdown of the snapshot’s ghost types, size etc. To see more detailed information about the snapshot, click on one of the bars. -> [!NOTE] -> This tool is a prototype. In future versions of the package, it will integrate with the Unity Profiler so you can easily correlate network traffic with memory usage and CPU performance. + +>[!NOTE] This tool is a prototype. In future versions of the package, it will integrate with the Unity Profiler so you can easily correlate network traffic with memory usage and CPU performance. diff --git a/Documentation~/ghost-spawning.md b/Documentation~/ghost-spawning.md index 1511f34..a780d15 100644 --- a/Documentation~/ghost-spawning.md +++ b/Documentation~/ghost-spawning.md @@ -110,44 +110,45 @@ There are some restrictions for pre-spawned ghosts: - **It must be an instance of a ghost prefab**. - **It must be place in a sub-scene**. - **Pre-spawned ghosts in the same scene cannot have the exact same position and rotation**. -- **Pre-spawned ghosts MUST be put always put on the main scene section (section 0).** +- **Pre-spawned ghosts MUST always be placed on the main scene section (section 0).** - The ghost authoring component on the pre-spawned ghost cannot be configured differently than the ghost prefab source (that data is handled on a ghost type basis). ### How pre-spawned ghosts works -At baking time, each sub-scene assign a `PrespawnId` to the ghosts it contains in a deterministic manner. The ids are assigned by sorting the ghost by the mean of a deterministic hash that takes in account the `Rotation` and `Translation` of the entity. -For each sub-scene then, a combined hash that contains: -- the SceneGUID -- all the ghosts calculated hashes +At baking time, each sub-scene assigns a `PreSpawnedGhostIndex` to the ghosts it contains which are just unique IDs for the ghosts within the subscene they are in. The IDs are assigned by sorting the ghost by means of a deterministic hash that takes in account the `Rotation` and `Translation` of the entity. As well as the ghost type or prefab ID and the SceneGUID of the scene section. This is done because prespawned ghosts cannot be given unique ghost IDs at bake/build time. + +For each sub-scene then, a combined hash that contains all the ghosts calculated hashes is extracted and used to: -is extracted and used to : - group the pre-spawned ghost on a per-sub-scene basis by assigning a `SubSceneGhostComponentHash` shared component to all the ghosts in the scene -- add to the `SceneSection` a `SubSceneWithPrespawnGhosts` component, that will be used by the runtime to handle sub-scene with pre-spawned ghosts. +- add to the first `SceneSection` in the sub-scene a `SubSceneWithPrespawnGhosts` component, that will be used by the runtime to handle sub-scene with pre-spawned ghosts. -At runtime, when a sub-scene has been loaded, is processed by both client and server: -- For each pre-spawned ghost, a `Prespawn Baseline` is extracted and used to delta compress the ghost component when it is first sent (bandwidth optimisation) -- The server assign to sub-scene a unique `Ghost Id Range` that is used to assign distinct ghost-id to the pre-spawned ghosts based on their `PrespawnId`. -- The server will replicated to the client by using an internal ghost entity, the assigned id ranges for each sub-scene (identified by the hash assigned to the `SubSceneWithPrespawnGhosts` component) -- Once the client has loaded the sub-scene and received the ghost range, it will then: - - Assign to the pre-spawned ghosts the server authoritative ids - - Report to the server it is ready to stream the pre-spawned ghosts (via rpc) +At runtime, when a sub-scene has been loaded, it is processed by both client and server: -This has to be done after all sub-scene have finished loading and the connection (at least one for the server) has bee set as `in game` (when the `NetworkStreamInGame` component has been added to the network connection). +- For each pre-spawned ghost, a `Prespawn Baseline` is extracted and used to delta compress the ghost component when it is first sent (bandwidth optimization). +- The server assign to sub-scenes a unique `Ghost Id Range` that is used to assign distinct ghost-id to the pre-spawned ghosts based on their `PreSpawnedGhostIndex`. +- The server will replicated to the client by using an internal ghost entity, the assigned id ranges for each sub-scene (identified by the hash assigned to the `SubSceneWithPrespawnGhosts` component). +- Once the client has loaded the sub-scene and received the ghost range, it will then: + - Assign to the pre-spawned ghosts the server authoritative IDs. + - Report to the server it is ready to stream the pre-spawned ghosts (via RPC). >![NOTE] If prespawned ghosts are moved before going in game or in general before the baseline is calculated properly, data may be not replicated correctly (the snapshot delta compression will fail). -> Both server and client calculate a CRC of the baseline and this hash is validated when clients connect. A mismatch will cause a disconnection. This also the reason why the ghost are `Disabled`. +> Both server and client calculate a CRC of the baseline and this hash is validated when clients connect. A mismatch will cause a disconnection. This is also the reason why the ghost are `Disabled` when the scene is loaded. + +>![NOTE] All the prespawn ghost ID setup described here is done automatically so nothing special needs to be done in order to keep them in sync between client and server. + +For both client and server, when a sub-scene has been processed (ghost ID assigned) a `PrespawnsSceneInitialized` +internal component is added to the main `SceneSection`.
+The client automatically tracks when sub-scene with pre-spawned ghosts are loaded/unloaded and reports to the server to stop streaming pre-spawned ghosts associated with them. -For both client and server, when a sub-scene has been processed (ghost id assigned) a [PrespawnsSceneInitialized](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientTrackLoadedPrespawnSections.html) -component is added to the main `SceneSection`.
-The client automatically track when sub-scene with pre-spawned ghosts are loaded/unloaded and report to the server to stop streaming pre-spawned ghosts associated with them. +### Dynamic loading sub-scene with pre-spawned ghosts -#### Dynamic loading sub-scene with pre-spawned ghosts. -It is possible to load at runtime a sub-scene with pre-spawned ghosts while you are already `in-game`. The pre-spawned ghosts will be automatically handled and synchronised. It also possible to unload sub-scenes that +It is possible to load at runtime a sub-scene with pre-spawned ghosts while you are already `in-game`. The pre-spawned ghosts will be automatically handled and synchronized. It also possible to unload sub-scenes that contains pre-spawned ghosts on demand. Netcode for Entities will handle that automatically, and the server will stop reporting the pre-spawned ghosts for sections the client has unloaded. ->![NOTE] Pre-spawned ghost when baked become `Disabled` (the `Disable` tag is added to the entity at baking time). The entity is re-enabled after the scene is loaded and the serialisation baseline has been calculated. +>![NOTE] Pre-spawned ghost when baked become `Disabled` (the `Disable` tag is added to the entity at baking time). The entity is re-enabled after the scene is loaded and the serialization baseline has been calculated. -You can get more information about the pre-spawned ghost synchronization flow, by checkin the: +You can get more information about the pre-spawned ghost synchronization flow, by checking the API documentation: - [ClientPopulatePrespawnedGhostsSystem](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientPopulatePrespawnedGhostsSystem.html) -- [ServerPopulatePrespawnedGhostsSystem](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientPopulatePrespawnedGhostsSystem.html) - [ClientTrackLoadedPrespawnSections](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientTrackLoadedPrespawnSections.html) +- [ServerPopulatePrespawnedGhostsSystem](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ServerPopulatePrespawnedGhostsSystem.html) +- [ServerTrackLoadedPrespawnSections](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ServerTrackLoadedPrespawnSections.html) diff --git a/Documentation~/network-connection.md b/Documentation~/network-connection.md index fc93f32..ca7d3e3 100644 --- a/Documentation~/network-connection.md +++ b/Documentation~/network-connection.md @@ -42,6 +42,13 @@ There are different way to do it: - Automatically connect and listen by using the `AutoConnectPort` (and relative `DefaultConnectAddress`). - By creating a `NetworkStreamRequestConnect` and/or `NetworkStreamRequestListen` request in the client and/ot server world respectively. +> [!NOTE] +> Regardless of how you choose to connect to the server, we strongly recommend ensuring `Application.runInBackground` is `true` while connected. +> You can do so by a) setting `Application.runInBackground = true;` directly, or b) project-wide via "Project Settings > Player > Resolution and Presentation". +> If you don't, your multiplayer will stall (and likely disconnect) if and when the application loses focus (e.g. by the player tabbing out), as netcode will be unable to tick. +> The server should likely always have this enabled. +> We provide error warnings for both via `WarnAboutApplicationRunInBackground`. + ### Manually Listen/Connect To establish a connection, you must get the [NetworkStreamDriver](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.NetworkStreamDriver.html) singleton (present on both client and server worlds) and then call either `Connect` or `Listen` on it. @@ -62,7 +69,7 @@ public class AutoConnectBootstrap : ClientServerBootstrap { // This will enable auto connect. AutoConnectPort = 7979; - // Create the default client and server worlds, depending on build type in a player or the Multiplayer PlayMode Tools in the editor + // Create the default client and server worlds, depending on build type in a player or the PlayMode Tools in the editor CreateDefaultClientServerWorlds(); return true; } diff --git a/Documentation~/networked-cube.md b/Documentation~/networked-cube.md index b99cf09..0c44787 100644 --- a/Documentation~/networked-cube.md +++ b/Documentation~/networked-cube.md @@ -58,11 +58,29 @@ You communicate with Netcode for Entities by using `RPC`s. So to continue create Create a file called *GoInGame.cs* in your __Assets__ folder and add the following code to the file. ```c# +using UnityEngine; using Unity.Collections; using Unity.Entities; using Unity.NetCode; using Unity.Burst; +/// +/// This allows sending RPCs between a stand alone build and the editor for testing purposes in the event when you finish this example +/// you want to connect a server-client stand alone build to a client configured editor instance. +/// +[BurstCompile] +[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ThinClientSimulation)] +[UpdateInGroup(typeof(InitializationSystemGroup))] +[CreateAfter(typeof(RpcSystem))] +public partial struct SetRpcSystemDynamicAssemblyListSystem : ISystem +{ + public void OnCreate(ref SystemState state) + { + SystemAPI.GetSingletonRW().ValueRW.DynamicAssemblyList = true; + state.Enabled = false; + } +} + // RPC request from client to server for game to go "in game" and send snapshots / inputs public struct GoInGameRequest : IRpcCommand { @@ -167,8 +185,8 @@ public class CubeAuthoring : MonoBehaviour { public override void Bake(CubeAuthoring authoring) { - Cube component = default(Cube); - AddComponent(component); + var entity = GetEntity(TransformUsageFlags.Dynamic); + AddComponent(entity); } } } @@ -208,8 +226,9 @@ public class CubeSpawnerAuthoring : MonoBehaviour public override void Bake(CubeSpawnerAuthoring authoring) { CubeSpawner component = default(CubeSpawner); - component.Cube = GetEntity(authoring.Cube); - AddComponent(component); + component.Cube = GetEntity(authoring.Cube, TransformUsageFlags.Dynamic); + var entity = GetEntity(TransformUsageFlags.Dynamic); + AddComponent(entity, component); } } } @@ -222,16 +241,120 @@ public class CubeSpawnerAuthoring : MonoBehaviour ![Ghost Spawner settings](images/ghost-spawner.png)
_Ghost Spawner settings_ -### Spawning our prefab +## Spawning our prefab To spawn the prefab, you need to update the _GoInGame.cs_ file. If you recall from earlier, you must send a __GoInGame__ `RPC` when you are ready to tell the server to start synchronizing. You can update that code to actually spawn our cube as well. -```diff +### Update GoInGameClientSystem and GoInGameServerSystem +We want the `GoInGameClientSystem` and `GoInGameServerSystem` to only run on the entities that have `CubeSpawner` component data associated with them. In order to do this we will add a call to [`SystemState.RequireForUpdate`](https://docs.unity3d.com/Packages/com.unity.entities@1.0/api/Unity.Entities.SystemState.RequireForUpdate.html) in both systems' `OnCreate` method: + +```C# +state.RequireForUpdate(); +``` + +Your `GoInGameClientSystem.OnCreate` method should look like this now: + +```C# + [BurstCompile] + public void OnCreate(ref SystemState state) + { + // Run only on entities with a CubeSpawner component data + state.RequireForUpdate(); + + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll() + .WithNone(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + } +``` + +Your `GoInGameServerSystem.OnCreate` method should look like this now: + +```C# + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll() + .WithAll(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + networkIdFromEntity = state.GetComponentLookup(true); + } +``` +Additionally, for the `GoInGameServerSystem.OnUpdate` method we want to: +- Get the prefab to spawn + - As an added example, get the name of the prefab being spawned to add to the log message +- For each inbound `ReceiveRpcCommandRequest` message, we will instantiate an instance of the prefab. + - For each prefab instance we will set the `GhostOwner.NetworkId` value to the NetworkId of the requesting client. +- Finally we will add the newly instantiated instance to the `LinkedEntityGroup` so when the client disconnects the entity will be destroyed. + +Update your `GoInGameServerSystem.OnUpdate` method to this: + +```C# + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + // Get the prefab to instantiate + var prefab = SystemAPI.GetSingleton().Cube; + + // Ge the name of the prefab being instantiated + state.EntityManager.GetName(prefab, out var prefabName); + var worldName = new FixedString32Bytes(state.WorldUnmanaged.Name); + + var commandBuffer = new EntityCommandBuffer(Allocator.Temp); + networkIdFromEntity.Update(ref state); + + foreach (var (reqSrc, reqEntity) in SystemAPI.Query>().WithAll().WithEntityAccess()) + { + commandBuffer.AddComponent(reqSrc.ValueRO.SourceConnection); + // Get the NetworkId for the requesting client + var networkId = networkIdFromEntity[reqSrc.ValueRO.SourceConnection]; + + // Log information about the connection request that includes the client's assigned NetworkId and the name of the prefab spawned. + UnityEngine.Debug.Log($"'{worldName}' setting connection '{networkId.Value}' to in game, spawning a Ghost '{prefabName}' for them!"); + + // Instantiate the prefab + var player = commandBuffer.Instantiate(prefab); + // Associate the instantiated prefab with the connected client's assigned NetworkId + commandBuffer.SetComponent(player, new GhostOwner { NetworkId = networkId.Value}); + + // Add the player to the linked entity group so it is destroyed automatically on disconnect + commandBuffer.AppendToBuffer(reqSrc.ValueRO.SourceConnection, new LinkedEntityGroup{Value = player}); + commandBuffer.DestroyEntity(reqEntity); + } + commandBuffer.Playback(state.EntityManager); + } +``` + + +Your **GoInGame.cs** file should now look like this: + +```C# +using UnityEngine; using Unity.Collections; using Unity.Entities; using Unity.NetCode; using Unity.Burst; +/// +/// This allows sending RPCs between a stand alone build and the editor for testing purposes in the event when you finish this example +/// you want to connect a server-client stand alone build to a client configured editor instance. +/// +[BurstCompile] +[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ThinClientSimulation)] +[UpdateInGroup(typeof(InitializationSystemGroup))] +[CreateAfter(typeof(RpcSystem))] +public partial struct SetRpcSystemDynamicAssemblyListSystem : ISystem +{ + public void OnCreate(ref SystemState state) + { + SystemAPI.GetSingletonRW().ValueRW.DynamicAssemblyList = true; + state.Enabled = false; + } +} + +// RPC request from client to server for game to go "in game" and send snapshots / inputs public struct GoInGameRequest : IRpcCommand { } @@ -243,7 +366,9 @@ public partial struct GoInGameClientSystem : ISystem [BurstCompile] public void OnCreate(ref SystemState state) { -+ state.RequireForUpdate(); + // Run only on entities with a CubeSpawner component data + state.RequireForUpdate(); + var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll() .WithNone(); @@ -275,7 +400,7 @@ public partial struct GoInGameServerSystem : ISystem [BurstCompile] public void OnCreate(ref SystemState state) { -+ state.RequireForUpdate(); + state.RequireForUpdate(); var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll() .WithAll(); @@ -286,8 +411,11 @@ public partial struct GoInGameServerSystem : ISystem [BurstCompile] public void OnUpdate(ref SystemState state) { -+ var prefab = SystemAPI.GetSingleton().Cube; -+ state.EntityManager.GetName(prefab, out var prefabName); + // Get the prefab to instantiate + var prefab = SystemAPI.GetSingleton().Cube; + + // Ge the name of the prefab being instantiated + state.EntityManager.GetName(prefab, out var prefabName); var worldName = new FixedString32Bytes(state.WorldUnmanaged.Name); var commandBuffer = new EntityCommandBuffer(Allocator.Temp); @@ -296,16 +424,19 @@ public partial struct GoInGameServerSystem : ISystem foreach (var (reqSrc, reqEntity) in SystemAPI.Query>().WithAll().WithEntityAccess()) { commandBuffer.AddComponent(reqSrc.ValueRO.SourceConnection); + // Get the NetworkId for the requesting client var networkId = networkIdFromEntity[reqSrc.ValueRO.SourceConnection]; -- UnityEngine.Debug.Log($"'{worldName}' setting connection '{networkId.Value}' to in game"); -+ UnityEngine.Debug.Log($"'{worldName}' setting connection '{networkId.Value}' to in game, spawning a Ghost '{prefabName}' for them!"); + // Log information about the connection request that includes the client's assigned NetworkId and the name of the prefab spawned. + UnityEngine.Debug.Log($"'{worldName}' setting connection '{networkId.Value}' to in game, spawning a Ghost '{prefabName}' for them!"); -+ var player = commandBuffer.Instantiate(prefab); -+ commandBuffer.SetComponent(player, new GhostOwner { NetworkId = networkId.Value}); + // Instantiate the prefab + var player = commandBuffer.Instantiate(prefab); + // Associate the instantiated prefab with the connected client's assigned NetworkId + commandBuffer.SetComponent(player, new GhostOwner { NetworkId = networkId.Value}); -+ // Add the player to the linked entity group so it is destroyed automatically on disconnect -+ commandBuffer.AppendToBuffer(reqSrc.ValueRO.SourceConnection, new LinkedEntityGroup{Value = player}); + // Add the player to the linked entity group so it is destroyed automatically on disconnect + commandBuffer.AppendToBuffer(reqSrc.ValueRO.SourceConnection, new LinkedEntityGroup{Value = player}); commandBuffer.DestroyEntity(reqEntity); } commandBuffer.Playback(state.EntityManager); @@ -338,11 +469,12 @@ public struct CubeInput : IInputComponentData [DisallowMultipleComponent] public class CubeInputAuthoring : MonoBehaviour { - class Baking : Unity.Entities.Baker + class Baking : Baker { public override void Bake(CubeInputAuthoring authoring) { - AddComponent(); + var entity = GetEntity(TransformUsageFlags.Dynamic); + AddComponent(entity); } } } @@ -406,3 +538,17 @@ public partial struct CubeMovementSystem : ISystem ## Test the code Now you have set up your code, open __Multiplayer > PlayMode Tools__ and set the __PlayMode Type__ to __Client & Server__. Enter Play Mode, and the Cube spawns. Press the __Arrow__ keys to move the Cube around. + + +## Build Stand Alone Build & Connect an Editor-Based Client +Now that you have the server-client instance running in the editor, you might want to see what it would be like to test connecting another client. In order to do this follow these steps: +- Verify that your Project Settings --> Entities --> Build --> NetCode Client Target is set to *ClientAndServer*. +- Make a development build and run that stand alone build. +- Select the Multiplayer menu bar option and select the editor play mode tools window. + - Set the **PlayMode Type** to: Client + - Set the **Auto Connect Port** to: 7979 + - Optionally you can dock or close this window at this point. +- Enter into PlayMode + +You should now see on your server-client stand alone build the editor-based client's cube and be able to see both cubes move around! + diff --git a/Documentation~/optimizations.md b/Documentation~/optimizations.md index e889ff4..5dc8e8c 100644 --- a/Documentation~/optimizations.md +++ b/Documentation~/optimizations.md @@ -147,7 +147,7 @@ How? Via another user-definable component: `GhostConnectionPosition` can store t In Asteroids, this component is added to the connection entity when the (steroids-specific) `RpcLevelLoaded` RPC is invoked: ```c# [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { var rpcData = default(RpcLevelLoaded); diff --git a/Documentation~/playmode-tool.md b/Documentation~/playmode-tool.md index 7b2b662..134f777 100644 --- a/Documentation~/playmode-tool.md +++ b/Documentation~/playmode-tool.md @@ -26,13 +26,14 @@ Once the simulator is enabled, you can set the packet delay, drop either manuall You can also specify your own settings, by setting custom values in the `RTT Delay`, `RTT Jitter` `PacketDrop` fields (or `Packet Delay`, `Packet Jitter` for `Packet View`). -| **Property** | **Description** | -|--------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| __RTTDelay__ | Use this property to emulate round trip time. The property delay the incoming and outgoing packet (in ms) such that the sum of the delays equals the specified value. | -| __RTTJitter__ | Use this property to add a random value to the delay, which makes the delay a value between the delay you have set plus or minus the jitter value. For example, if you set __RTTDelay__ to 45 and __RTTJitter__ to 5, you will get a random value between 40 and 50. | -| __PacketDrop__ | Use this property to simulate bad connections where not all packets arrive. Specify a value (as a percentage) and Netcode discards that percentage of packets from the total it receives. For example, set the value to 5 and Netcode discards 5% of all incoming and outgoing packets. | -| __AutoConnectAddress (Client only)__ | Specify which server a client should connect to. This field only appears if you set __PlayMode Type__ to __Client__. The user code needs to read this value and connect because the connection flows are in user code. | -| __AutoConnectPort (Client only)__ | Override and/or specify which port to use for both listening (server) and connecting (client)| +| **Property** | **Description** | +|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| __RTT Delay (ms)__ | Use this property to emulate round trip time. The property delay the incoming and outgoing packet (in ms) such that the sum of the delays equals the specified value. | +| __RTT Jitter (ms)__ | Use this property to add (or subtract) a random value to the delay, which makes the delay a value between the delay you have set plus or minus the jitter value. For example, if you set __RTTDelay__ to 45 and __RTTJitter__ to 5, you will get a random value between 40 and 50. | +| __Packet Drop (%)__ | Use this property to simulate bad connections where not all packets arrive. Specify a value (as a percentage) and Netcode discards that percentage of packets from the total it receives. For example, set the value to 5 and Netcode discards 5% of all incoming and outgoing packets. | +| __Packet Fuzz (%)__ | Use this property to simulate security-related MitM attacks, where malicious clients will attempt to bring down your server (or other clients) by intentionally serializing bad data. | +| __Auto Connect Address (Client only)__ | Specify which server a client should connect to. This field only appears if you set __PlayMode Type__ to __Client__. The user code needs to read this value and connect because the connection flows are in user code. | +| __Auto Connect Port (Client only)__ | Override and/or specify which port to use for both listening (server) and connecting (client) | > [!NOTE] diff --git a/Documentation~/prediction.md b/Documentation~/prediction.md index cc0a55e..4410963 100644 --- a/Documentation~/prediction.md +++ b/Documentation~/prediction.md @@ -21,7 +21,7 @@ The basic flow on the client is: > [!NOTE] > This "rollback" and prediction re-simulation can become a **substantial** overhead to each frame. > Example: For a 300ms connection, expect ~22 frames of re-simulation. I.e. Physics, and all other systems in the `PredictedSimulationSystemGroup`, will tick ~22 times in a single frame. -> You can test this (via setting a high simulated ping in the `Multiplayer PlayMode Tools Window`). +> You can test this (via setting a high simulated ping in the `PlayMode Tools Window`). > See the [Optimizations](optimizations.md) page for further details. Because the prediction loop runs from the oldest tick applied to any entity, and some entities might already have newer data, **you must check whether each entity needs to be simulated or not**. There are two distinct ways to do this check: diff --git a/Documentation~/sourcegenerators.md b/Documentation~/sourcegenerators.md new file mode 100644 index 0000000..289a802 --- /dev/null +++ b/Documentation~/sourcegenerators.md @@ -0,0 +1,122 @@ +# Netcode for Entities Source Generators + +The Netcode for Entities package uses Roslyn SourceGenerator to automatically generate at compile time: +- All the serialization code for replicated Components and Buffers, ICommand, Rpc and IInputCommandData. +- All the necessary boiler template systems that handle Rpc and Commands handling +- Systems that copy to/from from IInputCommandData to the underlying ICommand buffer +- Other internal system (mostly used for registration of the replicated types). +- Extract all the information from the replicate types to avoid using reflection at runtime. + +The project is organized as follow: + +``` +Unity.NetCode +- Editor +- Runtime + -- SourceGenerators Labels + --- NetCodeGenerator.dll *SourceGenerator* + ---- Source~ (hidden, not handled by Unity) + ------ NetCodeSourceGenerator + ------- CodeGenerator + ------- Generators + ------- Helpers + ------ Tests + ------ SourceGenerators.sln +``` + +The NetCodeSourceGenerator.dll is generated from the Source~ folder and used by the Editor compilation pipeline to inject the generate code in each assemply definitions (and also Assembly-CSharp and similar). +> IMPORTANT +> +> The generator dll is quite special and as some specific requirements: +> 1) It MUST be not imported by Unity Editor or any platform (incompatibility) +> 2) In order to be detected by the compilation pipeline and used as generator it MUST be labelled with the 'SourceGenerator' label. + +The dll present in the package is alredy configured appropiately. However, in case after recompiling the dll, some of the settings are lost +you can either use the Editor, edit the meta file, or restore the previous meta file, to reset the settings. + +## Generator output +By default, the Netcode generators emits all the generated files in the `Temp/NetcodeGenerated` folder (accessible also from the MultiplayerMenu shortcut). +A sub-folder is created for each assembly for which serialization code as been generated. + +The generator also write all the info/debug logs inside the `Temp/NetcodeGenerated/sourcegenerator.log`. Errors and Warning are emitted also in the Editor console. + +## Configuring the files and logging generator behaviour +It is possible to configure the generator using the Roslyn Analyzer Config file. Unity 2022+ detect the presence of GlobalAnalyzerConfig assets, either global (root of the Assets folder) +or on per assembly definition level, similarly to the .buildrule files. + +In order to configure the options to pass to our generator, it is necessary to create a `Default.globalconfig` text file should be added to the `Assets` folder in your project.
+The file must contains a list of key/value pairs and must have the following format: + +``` +# you can write comment like this +is_global=true + +your_key=your value +your_key=your value +... +``` +More information about the format and the analyzer configuration be found in the [Global Analyzer Config](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files#global-analyzerconfig) microsoft documentation. + +The Netcode generators supports the following flags/keys: + +| Key | Value | Description | +|---------------------------------------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| unity.netcode.sourcegenerator.outputfolder | a valid relative string | Override the output folder where the generator flush logs and generated files. Should be relative to the project path. Default is Temp/NetCodeGenerated. | +| unity.netcode.sourcegenerator.write_files_to_disk | empty or 1 (enable). 0 (disable) | Enable/Disable the output generated files to output folder | +| unity.netcode.sourcegenerator.write_logs_to_disk | empty or 1 (enable). 0 (disable) | Enable/Disable writing the logs to the output folder. All logs are redirected to the Editor logs if disabled | +| unity.netcode.sourcegenerator.emit_timing | empty or 1 (enable). 0 (disable) | Logs timings information for each compiled assembly. | +| unity.netcode.sourcegenerator.logging_level | info, warning, error | Set the logging level to use. **Default is error**. | +| unity.netcode.sourcegenerator.attach_debugger | an optional assembly name | Stop the generator execution and wait for a debugger to be attached. If assembly name is non empty, the generator wait for the debugger only when the assembly is being processed. | + +## How to build the source generators +There are cases when you may need to recompile the generator that come with package. For example, to fix an issue or to extend them. + +The generator DLLs need to be compiled manually outside of the Unity using the .NET SDK 6.0 or higher: https://dotnet.microsoft.com/en-us/download/dotnet/6.0. +That can be done with dotnet from within the Packages\com.unity.netcode\Runtime\SourceGenerators\Source~ directory via command prompt. + +`dotnet publish -c Release` to compile a release build
+`dotnet publish -c Debug` to compile a debug build. (recommended for debugging) + +Additionally, they can be built/debugged using the provide **Packages/com.unity.netcode/Runtime/SourceGenerators/Source~/SourceGenerators.sln** solution. + +## How to debug generator problems + +Debugging source generators can a little hard at first. The generator execution is invoked by an external process and you need to attach the debugger to it in order to be able to step throuhg the code. + +The first step is to open the SourceGenerators.sln in either Rider or VisualStudio and recompile the generator using the [**Debug configuration**](How to build the source generators). + +To simplify the process of attaching the debugger when generator is invoked, we provide some utilities that let you attach the debugger to the running process in a controllable manner. + +### Using the global config + +By adding the "unity.netcode.sourcegenerator.attach_debugger" option, you can let generator wait for a debugger to be attached, to either all the invocation or for a specific assembly. + +### Modify the generator code +You can use the `Debug.LaunchDebugger` utility method +```csharp +// Launch the debugger inconditionally +Debug.LaunchDebugger() +// Launch the debugger if the current processed assembly match the name +Debug.LaunchDebugger(GeneratorExecutionContext context, string assembly) +``` +These helper methods can be invoked/called from any place. We suggest to start from the NetcodeSourceGenetator.cs, inside the `Execute` method. + +```csharp +public void Execute(GeneratorExecutionContext executionContext) +{ + .... + Debug.LaunchDebugger(); + try + { + Generate(executionContext, diagnostic); + } + catch (Exception e) + { + ... + } +``` + +>Note: +> Because the execute is invoked multiple time (one per assembly) if your are not using the assembly filter, multiple popup will show-up on your screen. + +In all cases, a dialog will open at the right time, stating which is the process id you should attach to. diff --git a/Documentation~/whats-new.md b/Documentation~/whats-new.md index 5b71ce7..da9e22c 100644 --- a/Documentation~/whats-new.md +++ b/Documentation~/whats-new.md @@ -36,7 +36,7 @@ For a full list of changes, see the [Changelog](xref:changelog). For information * Predicted ghost physics now use custom system to update the physics simulation. The built-in system are instead used for updating the client-only simulatiom. * The limit of 128 components with serialization is now for actively used components instead of components in the project. * Netcode source generator templates should now use the NetCodeSourceGenerator.additionalfile and are identified by an unique id (see [templates](ghost-types-templates.md) documentation for more info). -* Various improvements to the `Multiplayer PlayMode Tools Window`, including; simulator "profiles" (which are representative of real-world speeds), runtime thin client creation/destruction support, live modification of simulator parameters, and a tool to simulate lag spikes via shortcut key. +* Various improvements to the `PlayMode Tools Window`, including; simulator "profiles" (which are representative of real-world speeds), runtime thin client creation/destruction support, live modification of simulator parameters, and a tool to simulate lag spikes via shortcut key. ## Further information diff --git a/Editor/Authoring/GhostComponentAnalytics.cs b/Editor/Authoring/GhostComponentAnalytics.cs index 68e3a8a..d94d892 100644 --- a/Editor/Authoring/GhostComponentAnalytics.cs +++ b/Editor/Authoring/GhostComponentAnalytics.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Text; -using Unity.Collections; using Unity.Entities; using Unity.NetCode.Analytics; +using Unity.Networking.Transport; using UnityEditor; using UnityEngine.Analytics; @@ -24,41 +24,77 @@ static void ModeChanged(PlayModeStateChange playModeState) return; } - var scaleData = ComputeScaleData(); - GhostComponentAnalytics.SendGhostComponentScale(scaleData); - var configurationData = ComputeConfigurationData(); - GhostComponentAnalytics.SendGhostComponentConfiguration(configurationData); + if (GhostComponentAnalytics.CanSendGhostComponentScale()) + { + var scaleData = ComputeScaleData(); + GhostComponentAnalytics.SendGhostComponentScale(scaleData); + } + if (GhostComponentAnalytics.CanSendGhostComponentConfiguration()) + { + var configurationData = ComputeConfigurationData(); + GhostComponentAnalytics.SendGhostComponentConfiguration(configurationData); + } } static GhostScaleAnalyticsData ComputeScaleData() { var data = new GhostScaleAnalyticsData { - playerCount = NetCodeAnalyticsState.GetPlayerCount(), - settings = new PlaymodeSettings + PlayerCount = NetCodeAnalyticsState.GetPlayerCount(), + Settings = new PlaymodeSettings { - thinClientCount = MultiplayerPlayModePreferences.RequestedNumThinClients, - simulatorEnabled = MultiplayerPlayModePreferences.SimulatorEnabled, - delay = MultiplayerPlayModePreferences.PacketDelayMs, - dropPercentage = MultiplayerPlayModePreferences.PacketDropPercentage, - jitter = MultiplayerPlayModePreferences.PacketJitterMs, - playModeType = MultiplayerPlayModePreferences.RequestedPlayType.ToString() - } + ThinClientCount = MultiplayerPlayModePreferences.RequestedNumThinClients, + SimulatorEnabled = MultiplayerPlayModePreferences.SimulatorEnabled, + Delay = MultiplayerPlayModePreferences.PacketDelayMs, + DropPercentage = MultiplayerPlayModePreferences.PacketDropPercentage, + FuzzPercentage = MultiplayerPlayModePreferences.PacketFuzzPercentage, + Jitter = MultiplayerPlayModePreferences.PacketJitterMs, + PlayModeType = MultiplayerPlayModePreferences.RequestedPlayType.ToString(), + SimulatorPreset = MultiplayerPlayModePreferences.CurrentNetworkSimulatorPreset + }, + GhostTypes = Array.Empty(), }; - var spawnedGhostCount = 0; uint serializedSent = 0; uint amount = 0; - var ghostTypes = new List(); + var numMainClientWorlds = 0; + var numServerWorlds = 0; foreach (var world in World.All) { - if (!world.IsServer()) + void CollectMainClientData() { - continue; + TryGetSingleton(world, out data.ClientTickRate); + data.MainClientData.NumOfSpawnedGhost = CountSpawnedGhosts(world.EntityManager); + TryGetSingleton(world, out data.ClientServerTickRate); + + var spawnedGhostCount = CountSpawnedGhosts(world.EntityManager); + TryGetSingleton(world, out var ghostRelevancy); + using var predictedGhostQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + var predictionCount = predictedGhostQuery.CalculateEntityCountWithoutFiltering(); + TryGetSingleton(world, out var predictionSwitchingAnalyticsData); + data.MainClientData = new MainClientData + { + RelevancyMode = ghostRelevancy.GhostRelevancyMode, + NumOfSpawnedGhost = spawnedGhostCount, + NumOfPredictedGhosts = predictionCount, + NumSwitchToInterpolated = predictionSwitchingAnalyticsData.NumTimesSwitchedToInterpolated, + NumSwitchToPredicted = predictionSwitchingAnalyticsData.NumTimesSwitchedToPredicted, + }; + } + + void CollectServerData() + { + data.ServerSpawnedGhostCount = CountSpawnedGhosts(world.EntityManager); + + data.GhostTypes = CollectGhostTypes(world.EntityManager); + data.GhostTypeCount = data.GhostTypes.Length; + + data.SnapshotTargetSize = TryGetSingleton(world, out var snapshotTargetSize) + ? snapshotTargetSize.Value + : NetworkParameterConstants.MTU; } - CollectGhostTypes(world.EntityManager, ghostTypes); var sent = NetCodeAnalyticsState.GetUpdateLength(world); if (sent > 0) { @@ -66,24 +102,49 @@ static GhostScaleAnalyticsData ComputeScaleData() serializedSent += sent; } - spawnedGhostCount += CountSpawnedGhosts(world.EntityManager); + if (world.IsServer()) + { + if (numServerWorlds == 0) + { + CollectServerData(); + } + numServerWorlds++; + } + else if (IsMainClient(world)) + { + if (numMainClientWorlds == 0) + { + CollectMainClientData(); + } + numMainClientWorlds++; + } } - data.ghostTypes = ghostTypes.ToArray(); - data.spawnedGhostCount = spawnedGhostCount; - data.ghostTypeCount = ghostTypes.Count; if (amount == 0) { - data.averageGhostInSnapshot = 0; + data.AverageGhostInSnapshot = 0; } else { - data.averageGhostInSnapshot = serializedSent / amount; + data.AverageGhostInSnapshot = serializedSent / amount; } + data.NumMainClientWorlds = numMainClientWorlds; + data.NumServerWorlds = numServerWorlds; return data; } + static bool TryGetSingleton(World world, out T val) where T : unmanaged, IComponentData + { + using var query = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + return query.TryGetSingleton(out val); + } + + static bool IsMainClient(World world) + { + return world.IsClient() && !world.IsThinClient(); + } + static GhostConfigurationAnalyticsData[] ComputeConfigurationData() { var data = NetCodeAnalytics.RetrieveGhostComponents(); @@ -93,24 +154,16 @@ static GhostConfigurationAnalyticsData[] ComputeConfigurationData() static int CountSpawnedGhosts(EntityManager entityManager) { - var spawnedGhostEntityQuery = - entityManager.CreateEntityQuery(ComponentType.ReadOnly()); - if (spawnedGhostEntityQuery.CalculateEntityCount() != 1) - { - return 0; - } - - var ghostMapSingleton = - spawnedGhostEntityQuery.ToComponentDataArray(Allocator.Temp)[0]; - return ghostMapSingleton.Value.Count(); + using var q = entityManager.CreateEntityQuery(ComponentType.ReadOnly()); + return q.CalculateEntityCountWithoutFiltering(); } - static void CollectGhostTypes(EntityManager entityManager, List ghostTypes) + static GhostTypeData[] CollectGhostTypes(EntityManager entityManager) { - var ghostCollectionQuery = entityManager.CreateEntityQuery(ComponentType.ReadOnly()); + using var ghostCollectionQuery = entityManager.CreateEntityQuery(ComponentType.ReadOnly()); if (ghostCollectionQuery.CalculateEntityCount() != 1) { - return; + return Array.Empty(); } var ghostCollection = ghostCollectionQuery.GetSingletonEntity(); @@ -118,6 +171,7 @@ static void CollectGhostTypes(EntityManager entityManager, List g var ghostCollectionPrefabSerializers = entityManager.GetBuffer(ghostCollection); + var ghostTypes = new List(); for (var index = 0; index < ghostCollectionPrefabSerializers.Length; index++) { var prefabSerializer = ghostCollectionPrefabSerializers[index]; @@ -126,52 +180,74 @@ static void CollectGhostTypes(EntityManager entityManager, List g var ghostId = entityManager.GetComponentData(ghostPrefab); ghostTypes.Add(new GhostTypeData() { - ghostId = $"{ghostId.guid0:x}{ghostId.guid1:x}{ghostId.guid2:x}{ghostId.guid3:x}", - childrenCount = prefabSerializer.NumChildComponents, - componentCount = archetype.TypesCount, - componentsWithSerializedDataCount = prefabSerializer.NumComponents + GhostId = $"{ghostId.guid0:x}{ghostId.guid1:x}{ghostId.guid2:x}{ghostId.guid3:x}", + ChildrenCount = prefabSerializer.NumChildComponents, + ComponentCount = archetype.TypesCount, + ComponentsWithSerializedDataCount = prefabSerializer.NumComponents }); } + return ghostTypes.ToArray(); } } [Serializable] +#if UNITY_2023_2_OR_NEWER + struct GhostTypeData : IAnalytic.IData +#else struct GhostTypeData +#endif { - public string ghostId; - public int childrenCount; - public int componentCount; - public int componentsWithSerializedDataCount; + public string GhostId; + public int ChildrenCount; + public int ComponentCount; + public int ComponentsWithSerializedDataCount; public override string ToString() { - return $"{nameof(ghostId)}: {ghostId}, " + - $"{nameof(childrenCount)}: {childrenCount}, " + - $"{nameof(componentCount)}: {componentCount}, " + - $"{nameof(componentsWithSerializedDataCount)}: {componentsWithSerializedDataCount}"; + return $"{nameof(GhostId)}: {GhostId}, " + + $"{nameof(ChildrenCount)}: {ChildrenCount}, " + + $"{nameof(ComponentCount)}: {ComponentCount}, " + + $"{nameof(ComponentsWithSerializedDataCount)}: {ComponentsWithSerializedDataCount}"; } } [Serializable] +#if UNITY_2023_2_OR_NEWER + struct GhostScaleAnalyticsData : IAnalytic.IData +#else struct GhostScaleAnalyticsData +#endif { - public PlaymodeSettings settings; - public int playerCount; - public int spawnedGhostCount; - public int ghostTypeCount; - public uint averageGhostInSnapshot; - public GhostTypeData[] ghostTypes; + public PlaymodeSettings Settings; + public int PlayerCount; + public int ServerSpawnedGhostCount; + public int GhostTypeCount; + public uint AverageGhostInSnapshot; + public GhostTypeData[] GhostTypes; + public ClientServerTickRate ClientServerTickRate; + public ClientTickRate ClientTickRate; + public MainClientData MainClientData; + public int SnapshotTargetSize; + public int NumMainClientWorlds; + public int NumServerWorlds; public override string ToString() { var builder = new StringBuilder(); - builder.Append($"{nameof(settings)}: {settings}, " + - $"{nameof(playerCount)}: {playerCount}, " + - $"{nameof(spawnedGhostCount)}: {spawnedGhostCount}, " + - $"{nameof(ghostTypeCount)}: {ghostTypeCount}, " + - $"{nameof(averageGhostInSnapshot)}: {averageGhostInSnapshot}, {nameof(ghostTypes)}:\n"); - - foreach (var ghostTypeData in ghostTypes) + builder.Append($"{nameof(Settings)}: {Settings}, " + + $"{nameof(PlayerCount)}: {PlayerCount}, " + + $"{nameof(ServerSpawnedGhostCount)}: {ServerSpawnedGhostCount}, " + + $"{nameof(GhostTypeCount)}: {GhostTypeCount}, " + + $"{nameof(AverageGhostInSnapshot)}: {AverageGhostInSnapshot}, " + + $"{nameof(ClientServerTickRate)}: {ClientServerTickRate}, " + + $"{nameof(ClientTickRate)}:{ClientTickRate}, " + + $"{nameof(MainClientData)}:{MainClientData}, " + + $"{nameof(SnapshotTargetSize)}:{SnapshotTargetSize}, " + + $"{nameof(NumMainClientWorlds)}:{NumMainClientWorlds}, " + + $"{nameof(NumServerWorlds)}:{NumServerWorlds}, " + + $" {nameof(GhostTypes)}:\n"); + + foreach (var ghostTypeData in GhostTypes) { builder.AppendLine($"[{ghostTypeData}],"); } @@ -180,36 +256,59 @@ public override string ToString() } } + [Serializable] + struct MainClientData + { + public GhostRelevancyMode RelevancyMode; + public int NumOfPredictedGhosts; + public long NumSwitchToPredicted; + public long NumSwitchToInterpolated; + public int NumOfSpawnedGhost; + + public override string ToString() + { + return $"{nameof(RelevancyMode)}: {RelevancyMode}, " + + $"{nameof(NumOfPredictedGhosts)}: {NumOfPredictedGhosts}, " + + $"{nameof(NumSwitchToPredicted)}: {NumSwitchToPredicted}, " + + $"{nameof(NumSwitchToInterpolated)}: {NumSwitchToInterpolated}, " + + $"{nameof(NumOfSpawnedGhost)}: {NumOfSpawnedGhost}"; + } + } + [Serializable] struct PlaymodeSettings { - public int thinClientCount; - public bool simulatorEnabled; - public int delay; - public int dropPercentage; - public int jitter; - public string playModeType; + public int ThinClientCount; + public bool SimulatorEnabled; + public int Delay; + public int DropPercentage; + public int FuzzPercentage; + public int Jitter; + public string PlayModeType; + public string SimulatorPreset; public override string ToString() { - return $"{nameof(thinClientCount)}: {thinClientCount}, " + - $"{nameof(simulatorEnabled)}: {simulatorEnabled}, " + - $"{nameof(delay)}, {delay}, " + - $"{nameof(dropPercentage)}, {dropPercentage}, " + - $"{nameof(playModeType)}, {playModeType}, " + - $"{nameof(jitter)}, {jitter}"; + return $"{nameof(ThinClientCount)}: {ThinClientCount}, " + + $"{nameof(SimulatorEnabled)}: {SimulatorEnabled}, " + + $"{nameof(Delay)}: {Delay}, " + + $"{nameof(DropPercentage)}: {DropPercentage}, " + + $"{nameof(FuzzPercentage)}: {FuzzPercentage}, " + + $"{nameof(Jitter)}: {Jitter}, " + + $"{nameof(PlayModeType)}: {PlayModeType}, " + + $"{nameof(SimulatorPreset)}: {SimulatorPreset}, "; } } static class GhostComponentAnalytics { - static bool s_ScaleRegistered = false; - static bool s_ConfigurationRegistered = false; - const int k_MaxEventsPerHour = 1000; - const int k_MaxNumberOfElements = 1000; - const string k_VendorKey = "unity.netcode"; - const string k_Scale = "NetcodeGhostComponentScale"; - const string k_Configuration = "NetcodeGhostComponentConfiguration"; + public const int k_MaxEventsPerHour = 1000; + public const int k_MaxItems = 1000; + public const string k_VendorKey = "unity.netcode"; + public const string k_Scale = "NetcodeGhostComponentScale"; + public const int k_ScaleVersion = 2; + public const int k_ConfigurationVersion = 1; + public const string k_Configuration = "NetcodeGhostComponentConfiguration"; /// /// This will add or update the buffer containing the configuration data from a . @@ -230,56 +329,121 @@ public static void BufferConfigurationData(GhostAuthoringComponent ghostComponen NetCodeAnalytics.StoreGhostComponent(analyticsData); } +#if !UNITY_2023_2_OR_NEWER + static bool s_ScaleRegistered; + static bool s_ConfigurationRegistered; + static bool RegisterEvent(string eventName, int ver) + { + return EditorAnalytics.RegisterEventWithLimit(eventName, k_MaxEventsPerHour, k_MaxItems, k_VendorKey, ver) == AnalyticsResult.Ok; + } +#endif static bool EnableScaleAnalytics() { - AnalyticsResult result = EditorAnalytics.RegisterEventWithLimit(k_Scale, k_MaxEventsPerHour, - k_MaxNumberOfElements, k_VendorKey); - if (result == AnalyticsResult.Ok) +#if !UNITY_2023_2_OR_NEWER + if (s_ScaleRegistered) { - s_ScaleRegistered = true; + return true; } - + s_ScaleRegistered = RegisterEvent(k_Scale, k_ScaleVersion); return s_ScaleRegistered; +#else + return true; +#endif } static bool EnableConfigurationAnalytics() { - AnalyticsResult result = EditorAnalytics.RegisterEventWithLimit(k_Configuration, k_MaxEventsPerHour, - k_MaxNumberOfElements, k_VendorKey); - if (result == AnalyticsResult.Ok) +#if !UNITY_2023_2_OR_NEWER + if (s_ConfigurationRegistered) { - s_ConfigurationRegistered = true; + return true; } - + s_ConfigurationRegistered = RegisterEvent(k_Configuration, k_ConfigurationVersion); return s_ConfigurationRegistered; +#else + return true; +#endif + } + +#if UNITY_2023_2_OR_NEWER + /// + /// Generic basic class that allow to dispatch any data. Used internally by + /// GhostComponentAnalytics + /// + /// + class NetCodeAnalytic : IAnalytic where T: IAnalytic.IData + { + private T m_Data; + + public NetCodeAnalytic(T data) + { + m_Data = data; + } + public bool TryGatherData(out IAnalytic.IData data, out Exception error) + { + data = m_Data; + error = null; + return data != null; + } } + [AnalyticInfo(eventName:k_Scale, vendorKey:k_VendorKey, version: k_ScaleVersion, maxEventsPerHour: k_MaxEventsPerHour, maxNumberOfElements: k_MaxItems)] + class GhostScaleAnalytics : NetCodeAnalytic + { + public GhostScaleAnalytics(GhostScaleAnalyticsData data) : base(data) {} + } + + [AnalyticInfo(eventName:k_Configuration, vendorKey:k_VendorKey, maxEventsPerHour: k_MaxEventsPerHour, maxNumberOfElements: k_MaxItems)] + class GhostConfigurationAnalytics : NetCodeAnalytic + { + public GhostConfigurationAnalytics(GhostConfigurationAnalyticsData data) : base(data) {} + } +#endif static bool CanSendAnalytics() { return EditorAnalytics.enabled; } + public static bool CanSendGhostComponentScale() + { + return CanSendAnalytics() && EnableScaleAnalytics(); + } + + public static bool CanSendGhostComponentConfiguration() + { + return CanSendAnalytics() && EnableConfigurationAnalytics(); + } + public static void SendGhostComponentScale(GhostScaleAnalyticsData data) { - if (!CanSendAnalytics() || !EnableScaleAnalytics()) +#if UNITY_2023_2_OR_NEWER + EditorAnalytics.SendAnalytic(new GhostScaleAnalytics(data)); +#else + if (!s_ScaleRegistered) { return; } - - EditorAnalytics.SendEventWithLimit(k_Scale, data); + EditorAnalytics.SendEventWithLimit(k_Scale, data, k_ScaleVersion); +#endif } public static void SendGhostComponentConfiguration(GhostConfigurationAnalyticsData[] data) { - if (!CanSendAnalytics() || !EnableConfigurationAnalytics()) +#if !UNITY_2023_2_OR_NEWER + if (!s_ConfigurationRegistered) { return; } - foreach (var analyticsData in data) { - EditorAnalytics.SendEventWithLimit(k_Configuration, analyticsData); + EditorAnalytics.SendEventWithLimit(k_Configuration, analyticsData, k_ConfigurationVersion); + } +#else + foreach (var analyticsData in data) + { + EditorAnalytics.SendAnalytic(new GhostConfigurationAnalytics(analyticsData)); } +#endif } } } diff --git a/Editor/Authoring/HierarchyDrawers.cs b/Editor/Authoring/HierarchyDrawers.cs index a21d09d..f211cf6 100644 --- a/Editor/Authoring/HierarchyDrawers.cs +++ b/Editor/Authoring/HierarchyDrawers.cs @@ -63,7 +63,7 @@ void IHierarchyItemDecorator.OnBindItem(HierarchyListViewItem item, HierarchyNod { // GameObject view. isNetCode = true; - isReplicated = item.PrefabType != Hierarchy.HierarchyPrefabType.None; + isReplicated = item.PrefabType != Unity.Entities.Editor.Hierarchy.HierarchyPrefabType.None; } } } diff --git a/Editor/Drawers/BoundingBoxDebugGhostDrawerSystem.cs b/Editor/Drawers/BoundingBoxDebugGhostDrawerSystem.cs index 776aa00..e6ae92e 100644 --- a/Editor/Drawers/BoundingBoxDebugGhostDrawerSystem.cs +++ b/Editor/Drawers/BoundingBoxDebugGhostDrawerSystem.cs @@ -1,4 +1,4 @@ -#if UNITY_EDITOR && !UNITY_DOTSRUNTIME && USING_ENTITIES_GRAPHICS +#if UNITY_EDITOR && USING_ENTITIES_GRAPHICS using System; using Unity.Burst; using Unity.Collections; @@ -162,8 +162,6 @@ protected override void OnDestroy() protected override void OnUpdate() { - DebugGhostDrawer.RefreshWorldCaches(); - var enabled = Enabled && s_CustomDrawer.Enabled && DebugGhostDrawer.HasRequiredWorlds; UpdateEntityMeshDrawer(EntityManager, enabled, m_ServerMeshRendererEntity, m_ServerMat, ServerColor); UpdateEntityMeshDrawer(EntityManager, enabled, m_ClientPredictedMeshRendererEntity, m_PredictedClientMat, PredictedClientColor); @@ -177,7 +175,7 @@ protected override void OnUpdate() Dependency.Complete(); - var serverWorld = DebugGhostDrawer.FirstServerWorld; + var serverWorld = ClientServerBootstrap.ServerWorld; serverWorld.EntityManager.CompleteAllTrackedJobs(); var serverSystem = serverWorld.GetOrCreateSystemManaged(); diff --git a/Editor/MultiplayerPlayModeWindow.cs b/Editor/MultiplayerPlayModeWindow.cs index 3a9b150..41183f1 100644 --- a/Editor/MultiplayerPlayModeWindow.cs +++ b/Editor/MultiplayerPlayModeWindow.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using Unity.Burst; using Unity.Collections; using Unity.Entities; -using Unity.Entities.Conversion; using Unity.Mathematics; using Unity.Networking.Transport; using Unity.Scenes; @@ -16,6 +16,12 @@ namespace Unity.NetCode.Editor { + /// + /// "PlayMode Tools" Window. Provides controls for: + /// - Configuring PlayMode World creation & configuration. + /// - Bespoke views for netcode related Client, ThinClient, and Server worlds. + /// - Controls to aid in testing of netcode, including a Simulator utility. + /// internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu { const int k_MaxWorldsToDisplay = 8; @@ -24,9 +30,11 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu const string k_ToggleLagSpikeSimulatorBindingKey = "Main Menu/Multiplayer/Toggle Lag Spike Simulation"; const string k_SimulatorPresetCaveat = "\n\nNote: The simulator can only add additional latency to a given connection, and it does so naively. Therefore, poor editor performance will exacerbate the delay (and is not compensated for)."; const string k_ProjectSettingsConfigPath = "ProjectSettings > Entities > Build"; + static Color ActiveColor => new Color(0.5f, 0.84f, 0.99f); // TODO: netCode color into this view. GhostAuthoringComponentEditor.netcodeColor; static GUILayoutOption s_PingWidth = GUILayout.Width(100); static GUILayoutOption s_NetworkIdWidth = GUILayout.Width(30); + static GUILayoutOption s_SimulatorViewWidth = GUILayout.Width(120); static GUILayoutOption s_WorldNameWidth = GUILayout.Width(130); static GUIContent s_PlayModeType = new GUIContent("PlayMode Type", "During multiplayer development, it's useful to modify and run the client and server at the same time, in the same process (i.e. \"in-proc\"). DOTS Multiplayer supports this out of the box via the DOTS Entities \"Worlds\" feature.\n\nUse this toggle to determine which mode of operation is used for this playmode session.\n\n\"Client & Server\" is recommended for most workflows."); @@ -39,14 +47,15 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu static GUIContent s_AutoConnectionAddress = new GUIContent("Auto Connect Address", "The ClientServerBootstrapper will attempt to automatically connect the created client world to this address on boot."); static GUIContent s_AutoConnectionPort = new GUIContent("Auto Connect Port", "The ClientServerBootstrapper will attempt to automatically connect the created client world to this port on boot."); - static GUIContent s_SimulatorTitle = new GUIContent("", "Denotes whether or not these client(s) will use Unity Transport's SimulatorUtility."); + static GUIContent s_SimulatorTitle = new GUIContent("Network Emulation", "Enabling this allows you to emulate various realistic network conditions.\n\nIn practice, this toggle denotes whether or not all Client Worlds will pass Unity Transport's SimulatorPipelineStage into the NetworkDriver, during construction.\n\nFor this reason, toggling Network Emulation requires a PlayMode restart."); static GUIContent s_SimulatorPreset = new GUIContent("?? Presets", "Simulate a variety of connection types & server locations.\n\nThese presets have been created by Multiplayer devs.\n\nWe strongly recommend that you test every new multiplayer feature with this simulator enabled.\n\nBy default, switching platform will change which presets are available to you. To toggle showing all presets, use the context menu. Alternatively, you can inject your own presets by modifying the `InUseSimulatorPresets` delegate."); static GUIContent s_ShowAllSimulatorPresets = new GUIContent("Show All Simulator Presets", "Toggle to view all simulator presets, or only your platform specific ones?"); + static GUIContent s_SimulatorView = new GUIContent(string.Empty, string.Empty); + private const string s_SimulatorExplination = "The simulator works by adding a delay before processing all packets sent from - and received by - the ClientWorld's Socket Driver.\n\nIn this view, you can observe and modify "; static GUIContent[] s_SimulatorViewContents = { - new GUIContent("Disabled","Disable the simulator (i.e. clients will not add any artificial delay or packet loss). This will also allow in-proc servers to use IPC connections. Cannot be changed during playmode."), - new GUIContent("Ping View","Enables the simulator. Note that, in this view, you're applying a target configuration for the \"ping\" (i.e. \"RTT\") value, which is the sum of packets sent and received. Thus, per-packet values will be roughly half these values. Switch to \"Per-Packet View\" to see this."), - new GUIContent("Per-Packet View","Enables the simulator. Applies the following conditions on each packet (i.e. each way). Note that the effect on \"ping\" (i.e. \"RTT\") is therefore at least doubled. Switch to the \"Ping View\" to observe this."), + new GUIContent("Ping View",s_SimulatorExplination + "the sum of both the sent and received delays, which therefore becomes an estimation of the \"ping\" (i.e. \"RTT\") value. Thus, per-packet values will be roughly half these values. Switch to the \"Per-Packet View\" to observe this."), + new GUIContent("Per-Packet View",s_SimulatorExplination + "the emulator values applied to each packet (i.e. each way). Note that the effect on \"ping\" (i.e. \"RTT\") is therefore at least doubled. Switch to the \"Ping View\" to observe this."), }; static GUIContent s_PacketDelay = new GUIContent("Packet Delay (ms)", "Fixed delay applied to each packet before it is processed. Simulates real network delay."); @@ -57,7 +66,8 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu static GUIContent s_RttJitter = new GUIContent("RTT Jitter (±ms)", "A random delay calculated and 'added to' or 'subtracted from' from each packets delay (min 0) so that the max jitter (i.e. variance) equals this value.\n\nSimulates network jitter (where packets sent in order \"A > B > C\" can arrive \"A > C > B\"."); static GUIContent s_RttDelayRange = new GUIContent("", "Denotes your clients min and max simulated ping, calculated as \"Delay ± Jitter\".\n\nNote that your actual ping will be higher due to the delay incurred during frame processing, and any real packet delay."); - static GUIContent s_PacketDrop = new GUIContent("Packet Drop (%)", "Denotes the percentage of packets sent or received that will be dropped. Simulates interruptions in UDP packet flow.\n\nDue to Unity Transport implementation, cannot be used to simulate \"total packet loss\" as low-level messages will still be passed, regardless of this setting."); + static GUIContent s_PacketDrop = new GUIContent("Packet Drop (%)", "Denotes the percentage of packets - sent or received - that will be dropped. Simulates interruptions in UDP packet flow."); + static GUIContent s_FuzzyPacket = new GUIContent("Packet Fuzz (%)", "Denotes the percentage of packets - sent or received - that will have random bits flipped (i.e. \"fuzzed\" / \"corrupted\"). Fuzzed packets trigger (often catastrophic) errors in deserialization code (both yours, and ours).\n\nI.e. This tool is used for security testing, and simulates malicious MitM attacks, and thus, error recovery.\n\nNote: These packets will PASS packet CRC validation checks, so cannot be easily discarded."); static GUIContent[] s_InUseSimulatorPresetContents; static List s_InUseSimulatorPresetsCache = new List(32); @@ -85,10 +95,6 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu static readonly string[] k_LagSpikeDurationStrings = { "10ms", "100ms", "200ms", "500ms", "1s", "2s", "5s", "10s", "30s", "1m", "2m"}; internal static readonly int[] k_LagSpikeDurationsSeconds = { 10, 100, 200, 500, 1_000, 2_000, 5_000, 10_000, 30_000, 60_000, 120_000 }; - static List s_ServerWorlds = new List(1); - static List s_ClientWorlds = new List(1); - static List s_ThinClientWorlds = new List(4); - static ulong s_LastNextSequenceNumber; static float s_SecondsTillCanCreateThinClient; static bool s_UserIsInteractingWithMenu; @@ -118,7 +124,7 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu [MenuItem("Multiplayer/Window: PlayMode Tools", priority = 50)] private static void ShowWindow() { - GetWindow(false, "Multiplayer PlayMode Tools", true); + GetWindow(false, "PlayMode Tools", true); } void OnEnable() @@ -162,7 +168,6 @@ static void RefreshSimulatorPresets() void OnDisable() { - ClearCache(); EditorApplication.playModeStateChanged -= PlayModeStateChanged; } @@ -180,8 +185,6 @@ void PlayModeStateChanged(PlayModeStateChange playModeStateChange) void PlayModeUpdate() { - RefreshWorldCaches(); - UpdateNumThinClientWorlds(); var utcNow = DateTime.UtcNow; @@ -197,10 +200,9 @@ void PlayModeUpdate() [MenuItem("Multiplayer/Toggle Lag Spike Simulation _F12", priority = 51)] static void ToggleLagSpikeSimulatorShortcut() { - var firstClient = s_ClientWorlds.FirstOrDefault(x => x.IsCreated) ?? s_ThinClientWorlds.FirstOrDefault(x => x.IsCreated); - if (firstClient != default) + if (ClientServerBootstrap.ClientWorld != null) { - var system = firstClient.GetExistingSystemManaged(); + var system = ClientServerBootstrap.ClientWorld.GetExistingSystemManaged(); system.ToggleLagSpikeSimulator(); ForceRepaint(); } @@ -209,7 +211,7 @@ static void ToggleLagSpikeSimulatorShortcut() /// By default, thin clients will attempt to copy the scenes loaded on the server, or the presenting client. static bool DefaultRuntimeThinClientWorldInitialization(World newThinClientWorld) { - var worldToCopyFrom = s_ClientWorlds.FirstOrDefault() ?? s_ServerWorlds.FirstOrDefault(); + var worldToCopyFrom = ClientServerBootstrap.ClientWorld ?? ClientServerBootstrap.ServerWorld; if (worldToCopyFrom?.IsCreated != true) { Debug.LogError("Cannot properly initialize ThinClientWorld as no Client or Server world found, so no idea which scenes to load."); @@ -244,22 +246,20 @@ void UpdateNumThinClientWorlds() var requestedNumThinClients = MultiplayerPlayModePreferences.RequestedNumThinClients; // Dispose if too many: - while(s_ThinClientWorlds.Count > requestedNumThinClients) + while(ClientServerBootstrap.ThinClientWorlds.Count > requestedNumThinClients) { - var index = s_ThinClientWorlds.Count - 1; - var world = s_ThinClientWorlds[index]; - s_ThinClientWorlds.RemoveAt(index); - ForceRepaint(); - + var index = ClientServerBootstrap.ThinClientWorlds.Count - 1; + var world = ClientServerBootstrap.ThinClientWorlds[index]; if (world.IsCreated) world.Dispose(); + ForceRepaint(); } // Create new: - var hasServerOrClient = s_ServerWorlds.Count + s_ClientWorlds.Count > 0; + var hasServerOrClient = ClientServerBootstrap.ServerWorld != null || ClientServerBootstrap.ClientWorld != null; if (hasServerOrClient && !s_UserIsInteractingWithMenu) { - for(var i = s_ThinClientWorlds.Count; i < requestedNumThinClients && s_SecondsTillCanCreateThinClient <= 0; i++) + for(var i = ClientServerBootstrap.ThinClientWorlds.Count; i < requestedNumThinClients && s_SecondsTillCanCreateThinClient <= 0; i++) { var thinClientWorld = ClientServerBootstrap.CreateThinClientWorld(); ForceRepaint(); @@ -283,44 +283,6 @@ internal static void ForceRepaint() s_LastRepaintedUtc = default; } - /// Removes Disposed worlds from the caches, and adds any new ones too. - void RefreshWorldCaches() - { - if (!Application.isPlaying) - { - ClearCache(); - return; - } - - if (World.NextSequenceNumber != s_LastNextSequenceNumber) - { - ClearCache(); - - foreach (var world in World.All) - { - if (world.IsServer()) - { - s_ServerWorlds.Add(world); - continue; - } - - if (world.IsThinClient()) - s_ThinClientWorlds.Add(world); - else if (world.IsClient()) - s_ClientWorlds.Add(world); - } - - s_LastNextSequenceNumber = World.NextSequenceNumber; - } - } - - static void ClearCache() - { - s_ServerWorlds.Clear(); - s_ClientWorlds.Clear(); - s_ThinClientWorlds.Clear(); - } - // This interface implementation is automatically called by Unity. void IHasCustomMenu.AddItemsToMenu(GenericMenu menu) { @@ -337,8 +299,6 @@ static void ToggleShowingAllSimulatorPresets() void OnGUI() { - RefreshWorldCaches(); - HackFixBoxStyle(); HandleWindowProperties(); @@ -405,10 +365,10 @@ static void HackFixBoxStyle() void DrawAllServerWorlds(ref int numWorldsDisplayed) { - if (s_ServerWorlds.Count > 0) + if (ClientServerBootstrap.ServerWorlds.Count > 0) { DrawSeparator(); - foreach (var serverWorld in s_ServerWorlds) + foreach (var serverWorld in ClientServerBootstrap.ServerWorlds) { if (++numWorldsDisplayed > k_MaxWorldsToDisplay) break; @@ -420,10 +380,10 @@ void DrawAllServerWorlds(ref int numWorldsDisplayed) void DrawAllPresentingClientWorlds(ref int numWorldsDisplayed) { - if (s_ClientWorlds.Count > 0) + if (ClientServerBootstrap.ClientWorlds.Count > 0) { DrawSeparator(); - foreach (var clientWorld in s_ClientWorlds) + foreach (var clientWorld in ClientServerBootstrap.ClientWorlds) { if (++numWorldsDisplayed > k_MaxWorldsToDisplay) break; @@ -435,10 +395,10 @@ void DrawAllPresentingClientWorlds(ref int numWorldsDisplayed) void DrawAllThinClientWorlds(ref int numWorldsDisplayed) { - if (s_ThinClientWorlds.Count > 0) + if (ClientServerBootstrap.ThinClientWorlds.Count > 0) { DrawSeparator(); - foreach (var world in s_ThinClientWorlds) + foreach (var world in ClientServerBootstrap.ThinClientWorlds) { if (++numWorldsDisplayed > k_MaxWorldsToDisplay) break; @@ -486,7 +446,7 @@ static void DrawClientAutoConnect() s_ClientConnect.text = $"Connect to {autoConnectionAddress}:{autoConnectionPort}"; if (GUILayout.Button(s_ClientConnect)) { - foreach (var clientWorld in s_ClientWorlds.Concat(s_ThinClientWorlds)) + foreach (var clientWorld in ClientServerBootstrap.ClientWorlds.Concat(ClientServerBootstrap.ThinClientWorlds)) { var connSystem = clientWorld.GetExistingSystemManaged(); connSystem.ClientConnectionState = MultiplayerClientPlayModeConnectionSystem.ConnectionState.TriggerConnect; @@ -504,8 +464,8 @@ static void DrawClientAutoConnect() { if (!ClientServerBootstrap.WillServerAutoListen) { - var anyConnected = s_ServerWorlds.Any(x => x.GetExistingSystemManaged().IsListening) - || s_ClientWorlds.Concat(s_ThinClientWorlds).Any(x => x.GetExistingSystemManaged().ClientConnectionState != MultiplayerClientPlayModeConnectionSystem.ConnectionState.NotConnected); + var anyConnected = ClientServerBootstrap.ServerWorlds.Any(x => x.IsCreated && x.GetExistingSystemManaged().IsListening) + || ClientServerBootstrap.ClientWorlds.Concat(ClientServerBootstrap.ThinClientWorlds).Any(x => x.IsCreated && x.GetExistingSystemManaged().ClientConnectionState != MultiplayerClientPlayModeConnectionSystem.ConnectionState.NotConnected); if (!anyConnected) { switch (Prefs.RequestedPlayType) @@ -537,22 +497,36 @@ static void DrawThinClientSelector() { GUI.enabled = !EditorApplication.isPlaying || RuntimeThinClientWorldInitialization != null; Prefs.RequestedNumThinClients = EditorGUILayout.IntField(s_NumThinClients, Prefs.RequestedNumThinClients); + GUI.enabled = true; if(RuntimeThinClientWorldInitialization != null) Prefs.ThinClientCreationFrequency = EditorGUILayout.FloatField(s_InstantiationFrequency, Prefs.ThinClientCreationFrequency); else { - GUI.color = Color.grey; + GUI.enabled = false; GUILayout.Box(s_RuntimeInstantiationDisabled, s_BoxStyleHack); - GUI.color = Color.white; + GUI.enabled = true; } } GUILayout.EndHorizontal(); + + var isRunningWithoutOptimizations = Prefs.RequestedNumThinClients > 4 && !BurstCompiler.IsEnabled; + var isRunningHighCount = Prefs.RequestedNumThinClients > 16; + if(isRunningWithoutOptimizations || isRunningHighCount) + EditorGUILayout.HelpBox("Enabling many in-process thin clients will slowdown enter-play-mode durations (as well as throttle the editor itself). It is therefore recommended to have Burst enabled, your Editor set to Release, and to use this feature sparingly.", MessageType.Warning); } static void DrawPlayType() { +#if UNITY_USE_MULTIPLAYER_ROLES + if (Unity.Multiplayer.Editor.EditorMultiplayerManager.enableMultiplayerRoles) + { + EditorGUILayout.HelpBox($"When Multiplayer Content Selection is active, the PlayMode Type is overriden by the active Multiplayer Role.", MessageType.Info); + EditorGUI.BeginDisabledGroup(true); + } +#endif + GUI.color = EditorApplication.isPlayingOrWillChangePlaymode ? Color.grey : Color.white; EditorGUI.BeginChangeCheck(); var requestedPlayType = (int) Prefs.RequestedPlayType; @@ -575,38 +549,57 @@ static void DrawPlayType() EditorApplication.isPlaying = false; } } + +#if UNITY_USE_MULTIPLAYER_ROLES + if (Unity.Multiplayer.Editor.EditorMultiplayerManager.enableMultiplayerRoles) + { + EditorGUI.EndDisabledGroup(); + } +#endif } void DrawSimulator() { - // Simulator: - EditorGUI.BeginChangeCheck(); GUILayout.BeginHorizontal(); - GUI.color = Prefs.SimulatorEnabled ? ActiveColor : Color.grey; - - var wasSimulatorEnabled = Prefs.SimulatorEnabled; - s_SimulatorTitle.text = Prefs.SimulatorEnabled ? "Simulator [ON]" : "Simulator [OFF]"; - GUILayout.Label(s_SimulatorTitle); - Prefs.RequestedSimulatorView = (SimulatorView) GUILayout.Toolbar((int) Prefs.RequestedSimulatorView, s_SimulatorViewContents); - GUILayout.EndHorizontal(); - - if (EditorGUI.EndChangeCheck()) + // Simulator Toggle: { - if (wasSimulatorEnabled != Prefs.SimulatorEnabled) + EditorGUI.BeginChangeCheck(); + GUI.color = Prefs.SimulatorEnabled ? ActiveColor : Color.white; + var wasSimulatorEnabled = Prefs.SimulatorEnabled; + Prefs.SimulatorEnabled = EditorGUILayout.Toggle(s_SimulatorTitle, wasSimulatorEnabled); + if (EditorGUI.EndChangeCheck()) { - EditorApplication.isPlaying = false; - HandleSimulatorValuesChanged(Prefs.IsCurrentNetworkSimulatorPresetCustom); + if (wasSimulatorEnabled != Prefs.SimulatorEnabled) + { + EditorApplication.isPlaying = false; + HandleSimulatorValuesChanged(Prefs.IsCurrentNetworkSimulatorPresetCustom); + } } + GUI.color = Color.white; } + // Per-Packet View vs Ping View. if (Prefs.SimulatorEnabled) { - GUI.color = Prefs.SimulatorEnabled ? Color.white : Color.grey; - EditorGUI.BeginChangeCheck(); - Prefs.CurrentNetworkSimulatorPreset = EditorPopup(s_SimulatorPreset, s_InUseSimulatorPresetContents, Prefs.CurrentNetworkSimulatorPreset); - if (EditorGUI.EndChangeCheck()) - HandleSimulatorValuesChanged(false); + GUILayout.FlexibleSpace(); + + var requestedSimulatorView = (int) Prefs.RequestedSimulatorView; + EditorPopup(s_SimulatorView, s_SimulatorViewContents, ref requestedSimulatorView, s_SimulatorViewWidth); + Prefs.RequestedSimulatorView = (SimulatorView) requestedSimulatorView; + } + + GUILayout.EndHorizontal(); + + if (Prefs.SimulatorEnabled) + { + // Presets: + { + EditorGUI.BeginChangeCheck(); + Prefs.CurrentNetworkSimulatorPreset = EditorPopup(s_SimulatorPreset, s_InUseSimulatorPresetContents, Prefs.CurrentNetworkSimulatorPreset); + if (EditorGUI.EndChangeCheck()) + HandleSimulatorValuesChanged(false); + } // Manual simulator values: { @@ -637,6 +630,7 @@ void DrawSimulator() s_PacketDelayRange.text = $"Range {perPacketMin} to {perPacketMax} (ms)"; EditorGUILayout.MinMaxSlider(s_PacketDelayRange, ref perPacketMin, ref perPacketMax, 0, 500); + if (EditorGUI.EndChangeCheck()) { // Prevents int precision lost causing this value to change when it shouldn't. @@ -645,7 +639,6 @@ void DrawSimulator() Prefs.PacketDelayMs = (int) (perPacketMin + Prefs.PacketJitterMs); HandleSimulatorValuesChanged(true); } - break; } case SimulatorView.PingView: @@ -680,10 +673,11 @@ void DrawSimulator() Prefs.PacketDelayMs = (int) (totalDelayMin * .5f + Prefs.PacketJitterMs); HandleSimulatorValuesChanged(true); } - break; } +#pragma warning disable CS0618 case SimulatorView.Disabled: +#pragma warning restore CS0618 // Show nothing. break; default: @@ -693,18 +687,38 @@ void DrawSimulator() break; } - EditorGUI.BeginChangeCheck(); - Prefs.PacketDropPercentage = EditorGUILayout.IntField(s_PacketDrop, Prefs.PacketDropPercentage); - if (EditorGUI.EndChangeCheck()) - HandleSimulatorValuesChanged(true); + + // Packet Loss %. + { + EditorGUI.BeginChangeCheck(); + GUILayout.BeginHorizontal(); + Prefs.PacketDropPercentage = EditorGUILayout.IntField(s_PacketDrop, Prefs.PacketDropPercentage); + Prefs.PacketFuzzPercentage = EditorGUILayout.IntField(s_FuzzyPacket, Prefs.PacketFuzzPercentage); + GUILayout.EndHorizontal(); + if (EditorGUI.EndChangeCheck()) + HandleSimulatorValuesChanged(true); + } + + // Notify users if they're simulating something very bad. + { + if (Prefs.PacketFuzzPercentage > 0) + EditorGUILayout.HelpBox("This simulator is intentionally corrupting packets (sent by - and received by - the client). Expect errors and data corruptions.", MessageType.Error); + else + { + if (Prefs.PacketDropPercentage > 60 || Prefs.PacketDelayMs + Prefs.PacketJitterMs > 500) + EditorGUILayout.HelpBox("You are simulating a terrible connection. Expect transport connection issues and/or unplayability.", MessageType.Error); + else if (Prefs.PacketDropPercentage > 15 || Prefs.PacketDelayMs + Prefs.PacketJitterMs > 200) + EditorGUILayout.HelpBox("You are simulating a poor connection. Expect netcode instability and/or visible lag.", MessageType.Warning); + } + } // Lag spike UI: if (Prefs.RequestedPlayType != ClientServerBootstrap.PlayType.Server) { DrawSeparator(); - var firstClient = s_ClientWorlds.FirstOrDefault() ?? s_ThinClientWorlds.FirstOrDefault(); + var firstClient = ClientServerBootstrap.ClientWorld ?? ClientServerBootstrap.ThinClientWorlds?.FirstOrDefault(); var connSystem = firstClient?.GetExistingSystemManaged(); GUILayout.BeginHorizontal(); @@ -855,7 +869,7 @@ static void DrawServerWorld(World serverWorld) serverWorld.EntityManager.CompleteAllTrackedJobs(); DisconnectAllClients(serverWorld.EntityManager, NetworkStreamDisconnectReason.ConnectionClose); - foreach (var clientWorld in s_ClientWorlds.Concat(s_ThinClientWorlds)) + foreach (var clientWorld in ClientServerBootstrap.ClientWorlds.Concat(ClientServerBootstrap.ThinClientWorlds)) { var connSystem = clientWorld.GetExistingSystemManaged(); connSystem.ClientConnectionState = MultiplayerClientPlayModeConnectionSystem.ConnectionState.TriggerConnect; @@ -891,6 +905,12 @@ static void EditorPopup(GUIContent content, GUIContent[] list, ref int index) index = EditorGUILayout.Popup(content, index, list); } + static void EditorPopup(GUIContent content, GUIContent[] list, ref int index, GUILayoutOption style) + { + index = math.clamp(index, 0, list.Length); + index = EditorGUILayout.Popup(content, index, list, style); + } + static void HandleSimulatorValuesChanged(bool isUsingCustomValues) { if (!isUsingCustomValues && SimulatorPreset.TryGetPresetFromName(Prefs.CurrentNetworkSimulatorPreset, s_InUseSimulatorPresetsCache, out var preset, out _)) @@ -909,20 +929,31 @@ static void HandleSimulatorValuesChanged(bool isUsingCustomValues) void DrawLoggingGroup() { - GUI.color = ActiveColor; + GUILayout.BeginHorizontal(); + GUI.color = Prefs.ApplyLoggerSettings ? ActiveColor : Color.white; Prefs.ApplyLoggerSettings = EditorGUILayout.Toggle(s_ForceLogLevel, Prefs.ApplyLoggerSettings); + if (!Prefs.ApplyLoggerSettings) + DrawLogFileLocationButton(); + GUILayout.EndHorizontal(); + GUI.enabled = Prefs.ApplyLoggerSettings; GUI.color = Color.white; - Prefs.TargetLogLevel = (NetDebug.LogLevelType) EditorGUILayout.EnumPopup(s_LogLevel, Prefs.TargetLogLevel); - GUILayout.BeginHorizontal(); + if (Prefs.ApplyLoggerSettings) { + Prefs.TargetLogLevel = (NetDebug.LogLevelType) EditorGUILayout.EnumPopup(s_LogLevel, Prefs.TargetLogLevel); + + GUILayout.BeginHorizontal(); Prefs.TargetShouldDumpPackets = EditorGUILayout.Toggle(s_DumpPacketLogs, Prefs.TargetShouldDumpPackets); + DrawLogFileLocationButton(); + GUILayout.EndHorizontal(); + } + + static void DrawLogFileLocationButton() + { GUI.enabled = true; if (GUILayout.Button(s_LogFileLocation, s_RightButtonWidth)) EditorUtility.OpenWithDefaultApp(s_LogFileLocation.tooltip); } - GUILayout.EndHorizontal(); - } void DrawDebugGizmosDrawer() @@ -1005,7 +1036,7 @@ void DrawSeparator() static void RefreshSimulationPipelineParametersLiveForAllWorlds() { - foreach (var clientWorld in s_ClientWorlds.Concat(s_ThinClientWorlds)) + foreach (var clientWorld in ClientServerBootstrap.ClientWorlds.Concat(ClientServerBootstrap.ThinClientWorlds)) { clientWorld.GetExistingSystemManaged().UpdateSimulator = true; } @@ -1014,7 +1045,7 @@ static void RefreshSimulationPipelineParametersLiveForAllWorlds() /// Note: Will disconnect this NetworkId from all server worlds it is found in. static void ServerDisconnectNetworkId(NetworkId networkId, NetworkStreamDisconnectReason reason) { - foreach (var serverWorld in s_ServerWorlds) + foreach (var serverWorld in ClientServerBootstrap.ServerWorlds) { DisconnectSpecificClient(serverWorld.EntityManager, networkId, reason); diff --git a/Editor/SourceGeneratorSettings.cs b/Editor/SourceGeneratorSettings.cs new file mode 100644 index 0000000..08aa15e --- /dev/null +++ b/Editor/SourceGeneratorSettings.cs @@ -0,0 +1,42 @@ +using System.IO; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Unity.NetCode.Editor +{ + static class SourceGeneratorSettings + { + /// + /// Create the Default.globalconfig file in the Assets folder root. + /// + /// + [MenuItem("Multiplayer/Create SourceGenerator AnalyzerConfig")] + static void CreateGlobalConfig() + { + var assetPath = Path.Combine(Application.dataPath, "Default.globalconfig"); + if(System.IO.File.Exists(assetPath)) + return; + using var streamWriter = File.CreateText(assetPath); + streamWriter.WriteLine("# global config file must have the is_global=true present in the first line."); + streamWriter.WriteLine("is_global=true"); + streamWriter.WriteLine(""); + streamWriter.WriteLine("# enabe/disable the Netcode source generator files output in the temp folder. 0 disable, empty or 1 enable."); + streamWriter.WriteLine("unity.netcode.sourcegenerator.write_files_to_disk=1"); + streamWriter.WriteLine(""); + streamWriter.WriteLine("# enable/disable Netcode source generator logs output to the Temp/NetCodeGenerated/sourcegenerato.log file. 0 disable, empty or 1 enable."); + streamWriter.WriteLine("unity.netcode.sourcegenerator.write_logs_to_disk=1"); + streamWriter.WriteLine(""); + streamWriter.WriteLine("# the default Netcode source generator logging level is info."); + streamWriter.WriteLine("unity.netcode.sourcegenerator.logging_level=info"); + streamWriter.WriteLine(""); + streamWriter.WriteLine("# Netcode source generator will emit profile timings. 0 disable, empty or 1 enable."); + streamWriter.WriteLine("unity.netcode.sourcegenerator.emit_timing=0"); + streamWriter.WriteLine(""); + streamWriter.WriteLine("# Netcode source generator will wait attaching the debugger before processing the specified assembly (or all if the value is empty). Keep commented to avoid the debugger to attach"); + streamWriter.WriteLine("#unity.netcode.sourcegenerator.attach_debugger=ASSEMBLY_NAME_OR_EMPTY"); + streamWriter.Flush(); + AssetDatabase.Refresh(); + } + } +} diff --git a/Editor/SourceGeneratorSettings.cs.meta b/Editor/SourceGeneratorSettings.cs.meta new file mode 100644 index 0000000..e0b25f7 --- /dev/null +++ b/Editor/SourceGeneratorSettings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 745d73e885ed4e23a63b267d823b96a8 +timeCreated: 1677264467 \ No newline at end of file diff --git a/Editor/Templates/CommandDataSerializer.cs b/Editor/Templates/CommandDataSerializer.cs index ed31baa..43a09b1 100644 --- a/Editor/Templates/CommandDataSerializer.cs +++ b/Editor/Templates/CommandDataSerializer.cs @@ -1,9 +1,9 @@ // THIS FILE IS AUTO-GENERATED BY NETCODE PACKAGE SOURCE GENERATORS. DO NOT DELETE, MOVE, COPY, MODIFY, OR COMMIT THIS FILE. // TO MAKE CHANGES TO THE SERIALIZATION OF A TYPE, REFER TO THE MANUAL. -using AOT; using Unity.Burst; using Unity.Burst.Intrinsics; using Unity.Collections.LowLevel.Unsafe; +using System.Runtime.CompilerServices; #region __COMMAND_USING_STATEMENT__ using __COMMAND_USING__; #endregion @@ -106,36 +106,53 @@ internal partial struct __COMMAND_NAME__CompareCommandSystem : ISystem struct CompareJob : IJobChunk { public NativeParallelHashMap.ParallelWriter map; - [ReadOnly] public BufferTypeHandle<__COMMAND_COMPONENT_TYPE__> inputHandle; + [ReadOnly] public BufferTypeHandle<__COMMAND_COMPONENT_TYPE__> inputTypeHandle; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Compare(in __COMMAND_COMPONENT_TYPE__ snapshot, in __COMMAND_COMPONENT_TYPE__ baseline) + { + uint changeMask = 0; + #region __GHOST_COMPARE_INPUTS__ + #endregion + return changeMask; + } + public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) { - var inputBufferAccessor = chunk.GetBufferAccessor(ref inputHandle); + var inputBufferAccessor = chunk.GetBufferAccessor(ref inputTypeHandle); for (int entIdx = 0; entIdx < chunk.Count; ++entIdx) { var inputBuffer = inputBufferAccessor[entIdx]; - for (int i = 0 ; i < inputBuffer.Length; ++i) + var length = inputBuffer.Length; + //The buffer can be in two state: + //1,2,3,4,5,6... 64 (all ordered) + //64+1,64+2,64+3,64+4,64+5,6,8,12,10,...64 // split state. + //This is also a search done on the client side. That store the input constantly in increasing bucket order. + //That imply it is more logic to start the comparison from the "oldest" input and going forward. + //Or start from the newest and going backward. We are using the latter. + for (int i = 0; i < length; ++i) { - var curInput = inputBuffer[i]; - if (!curInput.Tick.IsValid) - continue; + ref readonly var curInput = ref inputBuffer.GetInputAtIndex(i); + var curTick = curInput.Tick; + //We can also stop to the first invalid tick. Because tick are invalid only if the buffer is not full yet. + if (!curTick.IsValid) + break; var prevTick = curInput.Tick; prevTick.Decrement(); - if (!inputBuffer.GetDataAtTick(prevTick, out var prevInput)) - { - map.TryAdd(curInput.Tick, curInput.Tick); - continue; - } - - uint changeMask = 0; - // These two new local variables can trigger stack overflow exceptions with exceptionally large ICommandData structs. - // Therefore, we add an upper size limit on the type itself. See test `ValuesAreSerialized_ICommandData`. - var snapshot = curInput; - var baseline = prevInput; -#region __GHOST_COMPARE_INPUTS__ -#endregion - - if (changeMask != 0u) - map.TryAdd(curInput.Tick, curInput.Tick); + //There is not need to search. We know that this is going to be the previous element in the buffer. + var beforeIdx = (i - 1 + length) % length; + //We can stop as soon as we find the first element that is less than or equal the tick + //or that is invalid + ref readonly var prevInput = ref inputBuffer.GetInputAtIndex(beforeIdx); + //This check requires some explanation. We need to determine if the previous slot is the head of the buffer. + //Two cases: + //1) prevInput.Tick <= prevTick (considering overlap) + //2) prevInput.Tick > prevTick (considering overlap) + //if the prevInput is the "head" of the input buffer there cannot be input with a tick less thab prevTick and + //we can stop the run. + //if the prevInput is <= prevTick we need to check if they are identical. + if (!prevInput.Tick.IsValid || prevInput.Tick.IsNewerThan(prevTick) || Compare(curInput, prevInput) != 0) + map.TryAdd(curTick, curTick); } } } @@ -162,8 +179,8 @@ public void OnUpdate(ref SystemState state) m___COMMAND_NAME__Handle.Update(ref state); var job = new CompareJob { - inputHandle = m___COMMAND_NAME__Handle, - map = m_TickMapQuery.GetSingletonRW().ValueRO.Value + inputTypeHandle = m___COMMAND_NAME__Handle, + map = m_TickMapQuery.GetSingletonRW().ValueRW.Value }; state.Dependency = job.ScheduleParallel(m_Query, state.Dependency); } diff --git a/Editor/Templates/GhostComponentSerializer.cs b/Editor/Templates/GhostComponentSerializer.cs index 4ad0fb7..5d6bf4a 100644 --- a/Editor/Templates/GhostComponentSerializer.cs +++ b/Editor/Templates/GhostComponentSerializer.cs @@ -9,7 +9,6 @@ using System; using System.Diagnostics; -using AOT; using Unity.Burst; using Unity.NetCode.LowLevel.Unsafe; using Unity.Collections.LowLevel.Unsafe; @@ -46,7 +45,8 @@ public static GhostComponentSerializer.State GetState(ref SystemState state) SerializationStrategyIndex = -1, SerializesEnabledBit = __GHOST_SERIALIZES_ENABLED_BIT__, #if UNITY_EDITOR || NETCODE_DEBUG - ProfilerMarker = new Unity.Profiling.ProfilerMarker("__GHOST_COMPONENT_TYPE__") + ProfilerMarker = new Unity.Profiling.ProfilerMarker("__GHOST_COMPONENT_TYPE__"), + VariantTypeFullNameHash = TypeHash.FNV1A64("__GHOST_VARIANT_TYPE__") #endif }; @@ -256,7 +256,7 @@ private static void CheckDynamicMaskOffset(int offset, int sizeInBytes) #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.PostSerializeBufferDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.PostSerializeBufferDelegate))] public static void PostSerializeBuffer(IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit, IntPtr snapshotDynamicDataPtr, IntPtr dynamicSizePerEntity, int dynamicSnapshotMaxOffset) { #if COMPONENT_HAS_GHOST_FIELDS @@ -273,7 +273,7 @@ public static void PostSerializeBuffer(IntPtr snapshotData, int snapshotOffset, #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.SerializeBufferDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.SerializeBufferDelegate))] public static void SerializeBuffer(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, IntPtr componentData, IntPtr componentDataLen, int count, IntPtr baselines, @@ -353,7 +353,7 @@ public static void PostSerializeBuffer(IntPtr snapshotData, int snapshotOffset, #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.PostSerializeDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.PostSerializeDelegate))] public static void PostSerialize(IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit) { #if COMPONENT_HAS_GHOST_FIELDS @@ -374,7 +374,7 @@ public static void PostSerialize(IntPtr snapshotData, int snapshotOffset, int sn #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.SerializeDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.SerializeDelegate))] public static void Serialize(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, IntPtr componentData, int componentStride, int count, IntPtr baselines, @@ -408,7 +408,7 @@ public static void PostSerialize(IntPtr snapshotData, int snapshotOffset, int sn #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.SerializeChildDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.SerializeChildDelegate))] public static void SerializeChild(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, IntPtr componentData, int count, IntPtr baselines, @@ -452,7 +452,7 @@ private static void CopyToSnapshot(in GhostSerializerState serializerState, ref #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.CopyToFromSnapshotDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.CopyToFromSnapshotDelegate))] public static void CopyToSnapshot(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, IntPtr componentData, int componentStride, int count) { #if COMPONENT_HAS_GHOST_FIELDS @@ -466,7 +466,7 @@ public static void CopyToSnapshot(IntPtr stateData, IntPtr snapshotData, int sna #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.CopyToFromSnapshotDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.CopyToFromSnapshotDelegate))] public static void CopyFromSnapshot(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, IntPtr componentData, int componentStride, int count) { #if COMPONENT_HAS_GHOST_FIELDS @@ -518,7 +518,7 @@ public static void CopyFromSnapshot(IntPtr stateData, IntPtr snapshotData, int s [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.RestoreFromBackupDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.RestoreFromBackupDelegate))] public static void RestoreFromBackup(IntPtr componentData, IntPtr backupData) { #if COMPONENT_HAS_GHOST_FIELDS @@ -530,7 +530,7 @@ public static void RestoreFromBackup(IntPtr componentData, IntPtr backupData) } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.PredictDeltaDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.PredictDeltaDelegate))] public static void PredictDelta(IntPtr snapshotData, IntPtr baseline1Data, IntPtr baseline2Data, ref GhostDeltaPredictor predictor) { #if COMPONENT_HAS_GHOST_FIELDS @@ -568,7 +568,7 @@ private static void SerializeSnapshot(in Snapshot snapshot, in Snapshot baseline #endif } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.DeserializeDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.DeserializeDelegate))] public static void Deserialize(IntPtr snapshotData, IntPtr baselineData, ref DataStreamReader reader, ref StreamCompressionModel compressionModel, IntPtr changeMaskData, int startOffset) { #if COMPONENT_HAS_GHOST_FIELDS @@ -581,7 +581,7 @@ public static void Deserialize(IntPtr snapshotData, IntPtr baselineData, ref Dat } #if UNITY_EDITOR || NETCODE_DEBUG [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostComponentSerializer.ReportPredictionErrorsDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostComponentSerializer.ReportPredictionErrorsDelegate))] public static void ReportPredictionErrors(IntPtr componentData, IntPtr backupData, IntPtr errorsList, int errorsCount) { #if COMPONENT_HAS_GHOST_FIELDS diff --git a/Editor/Templates/InputSynchronization.cs b/Editor/Templates/InputSynchronization.cs index 7cc01f7..d6ea740 100644 --- a/Editor/Templates/InputSynchronization.cs +++ b/Editor/Templates/InputSynchronization.cs @@ -8,110 +8,29 @@ using __COMMAND_USING__; #endregion +[assembly: RegisterGenericComponentType(typeof(Unity.NetCode.InputBufferData<__COMMAND_COMPONENT_TYPE__>))] +[assembly: RegisterGenericSystemType(typeof(Unity.NetCode.ApplyCurrentInputBufferElementToInputDataSystem<__COMMAND_COMPONENT_TYPE__, __COMMAND_NAMESPACE__.__COMMAND_NAME__EventHelper>))] +[assembly: RegisterGenericSystemType(typeof(Unity.NetCode.CopyInputToCommandBufferSystem<__COMMAND_COMPONENT_TYPE__, __COMMAND_NAMESPACE__.__COMMAND_NAME__EventHelper>))] +[assembly: Unity.Jobs.RegisterGenericJobType(typeof(Unity.NetCode.ApplyInputDataFromBufferJob<__COMMAND_COMPONENT_TYPE__, __COMMAND_NAMESPACE__.__COMMAND_NAME__EventHelper>))] +[assembly: Unity.Jobs.RegisterGenericJobType(typeof(Unity.NetCode.CopyInputToBufferJob<__COMMAND_COMPONENT_TYPE__, __COMMAND_NAMESPACE__.__COMMAND_NAME__EventHelper>))] + namespace __COMMAND_NAMESPACE__ { - [DontSupportPrefabOverrides] - [GhostComponent(SendDataForChildEntity = true)] [System.Runtime.CompilerServices.CompilerGenerated] - [Unity.Entities.InternalBufferCapacity(0)] - public struct __COMMAND_NAME__InputBufferData : IInputBufferData + internal struct __COMMAND_NAME__EventHelper : IInputEventHelper<__COMMAND_COMPONENT_TYPE__> { - [DontSerializeForCommand] - public Unity.NetCode.NetworkTick Tick { get; set; } - public __COMMAND_COMPONENT_TYPE__ InternalInput; - - public void DecrementEventsAndAssignToInput(IntPtr prevInputBufferDataPtr, IntPtr inputPtr) + public void DecrementEvents(ref __COMMAND_COMPONENT_TYPE__ input, in __COMMAND_COMPONENT_TYPE__ prevInput) { - ref var prevInput = ref GhostComponentSerializer.TypeCast<__COMMAND_NAME__InputBufferData>(prevInputBufferDataPtr); - ref var input = ref GhostComponentSerializer.TypeCast<__COMMAND_COMPONENT_TYPE__>(inputPtr); - input = InternalInput; #region __DECREMENT_INPUTEVENT__ - input.__EVENTNAME__.Count -= prevInput.InternalInput.__EVENTNAME__.Count; + input.__EVENTNAME__.Count -= prevInput.__EVENTNAME__.Count; #endregion } - public void IncrementEventsAndSetCurrentInputData(IntPtr prevInputBufferDataPtr, IntPtr inputPtr) + public void IncrementEvents(ref __COMMAND_COMPONENT_TYPE__ input, in __COMMAND_COMPONENT_TYPE__ lastInput) { - ref var input = ref GhostComponentSerializer.TypeCast<__COMMAND_COMPONENT_TYPE__>(inputPtr); - ref var lastInput = ref GhostComponentSerializer.TypeCast<__COMMAND_NAME__InputBufferData>(prevInputBufferDataPtr); - InternalInput = input; #region __INCREMENT_INPUTEVENT__ - InternalInput.__EVENTNAME__.Count += lastInput.InternalInput.__EVENTNAME__.Count; + input.__EVENTNAME__.Count += lastInput.__EVENTNAME__.Count; #endregion } } - - [BurstCompile] - [System.Runtime.CompilerServices.CompilerGenerated] - [UpdateInGroup(typeof(GhostInputSystemGroup), OrderLast = true)] - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)] - internal partial struct __COMMAND_NAME__CopyInputToCommandBufferSystem : ISystem - { - private CopyInputToCommandBuffer<__COMMAND_NAME__InputBufferData, __COMMAND_COMPONENT_TYPE__> m_System; - private EntityQuery m_EntityQuery; - - [BurstCompile] - public struct CopyInputs : IJobChunk - { - public CopyInputToCommandBuffer<__COMMAND_NAME__InputBufferData, __COMMAND_COMPONENT_TYPE__>.CopyInputToBufferJob Job; - [BurstCompile] - public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) - { - Job.Execute(chunk, unfilteredChunkIndex); - } - } - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - m_System = new CopyInputToCommandBuffer<__COMMAND_NAME__InputBufferData, __COMMAND_COMPONENT_TYPE__>(); - m_EntityQuery = m_System.Create(ref state); - state.RequireForUpdate(m_EntityQuery); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var sendJob = new CopyInputs{Job = m_System.InitJobData(ref state)}; - state.Dependency = sendJob.Schedule(m_EntityQuery, state.Dependency); - } - } - - // This needs to run early to ensure the input data has been applied from buffer to input data - // struct before the input processing system runs - [BurstCompile] - [System.Runtime.CompilerServices.CompilerGenerated] - [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderFirst = true)] - [UpdateBefore(typeof(PredictedFixedStepSimulationSystemGroup))] - internal partial struct __COMMAND_NAME___ApplyCurrentInputBufferElementToInputDataSystem : ISystem - { - private ApplyCurrentInputBufferElementToInputData<__COMMAND_NAME__InputBufferData, __COMMAND_COMPONENT_TYPE__> m_System; - private EntityQuery m_EntityQuery; - - [BurstCompile] - public struct ApplyCurrentInput : IJobChunk - { - public ApplyCurrentInputBufferElementToInputData<__COMMAND_NAME__InputBufferData, __COMMAND_COMPONENT_TYPE__>.ApplyInputDataFromBufferJob Job; - [BurstCompile] - public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) - { - Job.Execute(chunk, unfilteredChunkIndex); - } - } - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - m_System = new ApplyCurrentInputBufferElementToInputData<__COMMAND_NAME__InputBufferData, __COMMAND_COMPONENT_TYPE__>(); - m_EntityQuery = m_System.Create(ref state); - state.RequireForUpdate(m_EntityQuery); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var applyJob = new ApplyCurrentInput{Job = m_System.InitJobData(ref state)}; - state.Dependency = applyJob.Schedule(m_EntityQuery, state.Dependency); - } - } } diff --git a/Editor/Templates/RpcCommandSerializer.cs b/Editor/Templates/RpcCommandSerializer.cs index 8c95f73..52d9335 100644 --- a/Editor/Templates/RpcCommandSerializer.cs +++ b/Editor/Templates/RpcCommandSerializer.cs @@ -1,6 +1,5 @@ // THIS FILE IS AUTO-GENERATED BY NETCODE PACKAGE SOURCE GENERATORS. DO NOT DELETE, MOVE, COPY, MODIFY, OR COMMIT THIS FILE. // TO MAKE CHANGES TO THE SERIALIZATION OF A TYPE, REFER TO THE MANUAL. -using AOT; using Unity.Burst; using Unity.Burst.Intrinsics; using Unity.Assertions; @@ -28,7 +27,7 @@ public void Deserialize(ref DataStreamReader reader, in RpcDeserializerState sta #endregion } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { RpcExecutor.ExecuteCreateRequestComponent<__COMMAND_NAME__Serializer, __COMMAND_COMPONENT_TYPE__>(ref parameters); diff --git a/Editor/Unity.NetCode.Editor.asmdef b/Editor/Unity.NetCode.Editor.asmdef index ed61d18..6901b82 100644 --- a/Editor/Unity.NetCode.Editor.asmdef +++ b/Editor/Unity.NetCode.Editor.asmdef @@ -17,7 +17,8 @@ "Unity.Properties.UI", "Unity.Properties", "Unity.Properties.UI.Editor", - "Unity.Entities.Build" + "Unity.Entities.Build", + "Unity.DedicatedServer.MultiplayerRoles.Editor" ], "includePlatforms": [ "Editor" @@ -28,6 +29,12 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.dedicated-server", + "expression": "0.8.0", + "define": "UNITY_USE_MULTIPLAYER_ROLES" + } + ], "noEngineReferences": false } \ No newline at end of file diff --git a/Runtime/Analytics/GhostConfigurationAnalyticsData.cs b/Runtime/Analytics/GhostConfigurationAnalyticsData.cs index ff064bb..9fe0116 100644 --- a/Runtime/Analytics/GhostConfigurationAnalyticsData.cs +++ b/Runtime/Analytics/GhostConfigurationAnalyticsData.cs @@ -4,7 +4,11 @@ namespace Unity.NetCode.Analytics { [Serializable] +#if UNITY_2023_2_OR_NEWER + struct GhostConfigurationAnalyticsData : UnityEngine.Analytics.IAnalytic.IData +#else struct GhostConfigurationAnalyticsData +#endif { public string id; public string ghostMode; diff --git a/Runtime/Authoring/DefaultVariantSystemBase.cs b/Runtime/Authoring/DefaultVariantSystemBase.cs index f600c87..1361520 100644 --- a/Runtime/Authoring/DefaultVariantSystemBase.cs +++ b/Runtime/Authoring/DefaultVariantSystemBase.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using Unity.Entities; using Unity.Collections; -#if ENABLE_UNITY_COLLECTIONS_CHECKS && !UNITY_DOTSRUNTIME +#if ENABLE_UNITY_COLLECTIONS_CHECKS using System.Reflection; #endif @@ -116,7 +116,7 @@ static ulong TryGetHashElseZero(ComponentType componentType, Type variantType) return GhostVariantsUtility.ClientOnlyHash; if (variantType == typeof(ServerOnlyVariant)) return GhostVariantsUtility.ServerOnlyHash; - return GhostVariantsUtility.UncheckedVariantHash(variantType.FullName, new FixedString512Bytes(componentType.GetDebugTypeName())); + return GhostVariantsUtility.UncheckedVariantHash(variantType.FullName, componentType); } } @@ -211,7 +211,7 @@ public GhostVariantRules(NativeHashMap public bool TrySetDefaultVariant(ComponentType componentType, DefaultVariantSystemBase.Rule rule, SystemBase currentSystem) { -#if ENABLE_UNITY_COLLECTIONS_CHECKS && !UNITY_DOTSRUNTIME +#if ENABLE_UNITY_COLLECTIONS_CHECKS ValidateVariantRule(componentType, rule, currentSystem); #endif var added = DefaultVariants.TryAdd(componentType, rule.CreateHashRule(componentType)); @@ -232,7 +232,7 @@ public bool TrySetDefaultVariant(ComponentType componentType, DefaultVariantSyst /// public void SetDefaultVariant(ComponentType componentType, DefaultVariantSystemBase.Rule rule, SystemBase currentSystem) { -#if ENABLE_UNITY_COLLECTIONS_CHECKS && !UNITY_DOTSRUNTIME +#if ENABLE_UNITY_COLLECTIONS_CHECKS ValidateVariantRule(componentType, rule, currentSystem); #endif var newRuleHash = rule.CreateHashRule(componentType); @@ -251,14 +251,14 @@ public void SetDefaultVariant(ComponentType componentType, DefaultVariantSystemB DefaultVariants[componentType] = newRuleHash; } -#if ENABLE_UNITY_COLLECTIONS_CHECKS && !UNITY_DOTSRUNTIME +#if ENABLE_UNITY_COLLECTIONS_CHECKS void ValidateVariantRule(ComponentType componentType, DefaultVariantSystemBase.Rule rule, ComponentSystemBase systemBase) { if (rule.VariantForParents == default && rule.VariantForChildren == default) throw new System.ArgumentException($"`{componentType}` has an invalid default variant rule ({rule}) defined in `{TypeManager.GetSystemName(systemBase.GetType())}` (in '{systemBase.World.Name}'), as both are `null`!"); var managedType = componentType.GetManagedType(); - if (typeof(IInputBufferData).IsAssignableFrom(managedType)) + if (typeof(InputBufferData<>).IsAssignableFrom(managedType)) throw new System.ArgumentException($"`{managedType}` is of type `IInputBufferData`, which must get its default variants from the `IInputComponentData` that it is code-generated from. Replace this dictionary entry ({rule}) with the `IInputComponentData` type in system `{TypeManager.GetSystemName(systemBase.GetType())}`, in '{systemBase.World.Name}'!"); ValidateUserDefinedDefaultVariantRule(componentType, rule.VariantForParents, systemBase); diff --git a/Runtime/Authoring/GhostVariantsUtility.cs b/Runtime/Authoring/GhostVariantsUtility.cs index ef990d6..5874b1a 100644 --- a/Runtime/Authoring/GhostVariantsUtility.cs +++ b/Runtime/Authoring/GhostVariantsUtility.cs @@ -1,4 +1,5 @@ using System; +using Unity.Burst; using Unity.Collections; using Unity.Entities; @@ -21,14 +22,30 @@ internal static class GhostVariantsUtility internal static readonly ulong ServerOnlyHash = TypeHash.CombineFNV1A64(k_NetCodeGhostNetVariantHash, TypeHash.FNV1A64((FixedString64Bytes)$"Unity.NetCode.{k_ServerOnlyVariant}")); internal static readonly ulong DontSerializeHash = TypeHash.CombineFNV1A64(k_NetCodeGhostNetVariantHash, TypeHash.FNV1A64((FixedString64Bytes)$"Unity.NetCode.{k_DontSerializeVariant}")); + static ulong CalculateVariantHash(ulong variantTypeHash, ulong componentTypeHash) + { + var hash = k_NetCodeGhostNetVariantHash; + hash = TypeHash.CombineFNV1A64(hash, componentTypeHash); + hash = TypeHash.CombineFNV1A64(hash, variantTypeHash); + return hash; + } + /// Calculates the "variant hash" for the component type itself, so that we can fetch the meta-data. + /// It's a little odd, but the default serializer for a Component is the ComponentType itself. I.e. It is its own variant. + /// The ComponentType to be used for both the component, and the variant. + /// The calculated hash. + public static ulong CalculateVariantHashForComponent(ComponentType componentType) + { + var componentTypeHash = TypeManager.GetFullNameHash(componentType.TypeIndex); + return CalculateVariantHash(componentTypeHash, componentTypeHash); + } + /// Calculates a stable hash for a variant via . /// The Variant Type's . /// The ComponentType that this variant applies to. /// The calculated hash. public static ulong UncheckedVariantHash(in FixedString512Bytes variantTypeFullName, ComponentType componentType) { - var componentTypeFullName = componentType.GetDebugTypeName(); - return UncheckedVariantHash(variantTypeFullName, new FixedString512Bytes(componentTypeFullName)); + return CalculateVariantHash(TypeHash.FNV1A64(variantTypeFullName), TypeManager.GetFullNameHash(componentType.TypeIndex)); } /// Calculates the "variant hash" for the variant + component pair. @@ -37,21 +54,7 @@ public static ulong UncheckedVariantHash(in FixedString512Bytes variantTypeFullN /// The calculated hash. public static ulong UncheckedVariantHash(in FixedString512Bytes variantTypeFullName, in FixedString512Bytes componentTypeFullName) { - var hash = k_NetCodeGhostNetVariantHash; - hash = TypeHash.CombineFNV1A64(hash, TypeHash.FNV1A64(componentTypeFullName)); - hash = TypeHash.CombineFNV1A64(hash, TypeHash.FNV1A64(variantTypeFullName)); - return hash; - } - - /// Calculates the "variant hash" for the component type itself, so that we can fetch the meta-data. - /// It's a little odd, but the default serializer for a Component is the ComponentType itself. I.e. It is its own variant. - /// The ComponentType to be used for both the component, and the variant. - /// The calculated hash. - public static ulong CalculateVariantHashForComponent(ComponentType componentType) - { - var baseComponentTypeName = componentType.GetDebugTypeName(); - var fs = new FixedString512Bytes(baseComponentTypeName); - return UncheckedVariantHash(fs, fs); + return CalculateVariantHash(TypeHash.FNV1A64(variantTypeFullName), TypeHash.FNV1A64(componentTypeFullName)); } /// @@ -60,12 +63,32 @@ public static ulong CalculateVariantHashForComponent(ComponentType componentType /// The Variant Type's System.Type.FullName. /// The Component Type's System.Type.FullName that this variant applies to. /// The calculated hash. + /// This method is not Burst Compatible. + [ExcludeFromBurstCompatTesting("Use managed types")] public static ulong UncheckedVariantHashNBC(string variantTypeFullName, string componentTypeFullName) { - var hash = k_NetCodeGhostNetVariantHash; - hash = TypeHash.CombineFNV1A64(hash, TypeHash.FNV1A64(componentTypeFullName)); - hash = TypeHash.CombineFNV1A64(hash, TypeHash.FNV1A64(variantTypeFullName)); - return hash; + return CalculateVariantHash(TypeHash.FNV1A64(variantTypeFullName), TypeHash.FNV1A64(componentTypeFullName)); + } + + /// Calculates a stable hash for a variant by combining the variant Type.Fullname and + /// name hash . + /// The Variant struct declaration type. + /// The ComponentType that this variant applies to. + /// The calculated hash. + [ExcludeFromBurstCompatTesting("Use managed types")] + public static ulong UncheckedVariantHashNBC(Type variantStructDeclaration, ComponentType componentType) + { + return CalculateVariantHash(TypeHash.FNV1A64(variantStructDeclaration.FullName), TypeManager.GetFullNameHash(componentType.TypeIndex)); } + + /// Calculates the "variant hash" for the variant + component pair. + /// The hash of the Variant Type's System.Type.FullName. + /// The ComponentType that this variant applies to. + /// The calculated hash. + public static ulong UncheckedVariantHash(ulong variantTypeHash, ComponentType componentType) + { + return CalculateVariantHash(variantTypeHash, TypeManager.GetFullNameHash(componentType.TypeIndex)); + } + } } diff --git a/Runtime/Authoring/Hybrid/PreSpawnedGhostsBakingSystem.cs b/Runtime/Authoring/Hybrid/PreSpawnedGhostsBakingSystem.cs index fd6443c..dd6e40c 100644 --- a/Runtime/Authoring/Hybrid/PreSpawnedGhostsBakingSystem.cs +++ b/Runtime/Authoring/Hybrid/PreSpawnedGhostsBakingSystem.cs @@ -17,7 +17,7 @@ internal struct PrespawnedGhostBakedBefore: IComponentData { } /// /// Postprocess all the game objects present in a subscene which present a GhostAuthoringComponent by adding to the primary /// entities the following components: - /// - A PrespawnId component: contains a unique identifier (per subscene) that is guaranteed to be determistic + /// - A PreSpawnedGhostIndex component: contains a unique identifier (per subscene) that is guaranteed to be deterministic /// - A SubSceneGhostComponentHash shared component: used to deterministically group the ghost instances /// /// @@ -27,7 +27,7 @@ internal struct PrespawnedGhostBakedBefore: IComponentData { } [BakingVersion("cmarastoni", 1)] partial class PreSpawnedGhostsBakingSystem : SystemBase { - private EntityQuery m_SceneSectionEntityQuery; + private EntityQuery m_SceneSectionEntityQuery; protected override void OnDestroy() { @@ -91,20 +91,18 @@ protected override void OnUpdate() hashToEntity.Add(combinedComponentHash, entity); else Debug.LogError($"Two ghosts can't be in the same exact position and rotation {EntityManager.GetName(entity)}"); - - hashData.Dispose(); } }).WithoutBurst().Run(); if (hashToEntity.Count() > 0) { //Add the components in batch - var values = hashToEntity.GetValueArray(Allocator.TempJob); + var values = hashToEntity.GetValueArray(Allocator.Temp); EntityManager.AddComponent(values, typeof(PreSpawnedGhostIndex)); EntityManager.AddComponent(values, typeof(PrespawnGhostBaseline)); EntityManager.AddComponent(values, typeof(PrespawnedGhostBakedBefore)); - var keys = hashToEntity.GetKeyArray(Allocator.TempJob); + var keys = hashToEntity.GetKeyArray(Allocator.Temp); keys.Sort(); // Assign ghost IDs to the pre-spawned entities sorted by component data hash @@ -154,8 +152,6 @@ protected override void OnUpdate() EntityManager.AddComponent(sectionEntity); } //We can add more here. Ideally the serialization. A way would be to use a sort of offset re-mapping - values.Dispose(); - keys.Dispose(); } hashToEntity.Dispose(); diff --git a/Runtime/Authoring/Hybrid/Unity.NetCode.Authoring.Hybrid.asmdef b/Runtime/Authoring/Hybrid/Unity.NetCode.Authoring.Hybrid.asmdef index 4f5c2a2..9d7ebda 100644 --- a/Runtime/Authoring/Hybrid/Unity.NetCode.Authoring.Hybrid.asmdef +++ b/Runtime/Authoring/Hybrid/Unity.NetCode.Authoring.Hybrid.asmdef @@ -21,15 +21,7 @@ "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, - "defineConstraints": [ - "!UNITY_DOTSRUNTIME" - ], - "versionDefines": [ - { - "name": "com.unity.platforms", - "expression": "", - "define": "USING_PLATFORMS_PACKAGE" - } - ], + "defineConstraints": [], + "versionDefines": [], "noEngineReferences": false } diff --git a/Runtime/ClientServerWorld/ClientInitializationSystemGroup.cs b/Runtime/ClientServerWorld/ClientInitializationSystemGroup.cs index 91e89d5..b19715d 100644 --- a/Runtime/ClientServerWorld/ClientInitializationSystemGroup.cs +++ b/Runtime/ClientServerWorld/ClientInitializationSystemGroup.cs @@ -8,9 +8,7 @@ namespace Unity.NetCode /// Used only for DOTSRuntime and tests or other specific use cases. /// #if !UNITY_SERVER || UNITY_EDITOR -#if !UNITY_DOTSRUNTIME [DisableAutoCreation] -#endif [UpdateInGroup(typeof(InitializationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation)] internal partial class TickClientInitializationSystem : TickComponentSystemGroup diff --git a/Runtime/ClientServerWorld/ClientPresentationSystemGroup.cs b/Runtime/ClientServerWorld/ClientPresentationSystemGroup.cs index 9b2b91d..1fd7d19 100644 --- a/Runtime/ClientServerWorld/ClientPresentationSystemGroup.cs +++ b/Runtime/ClientServerWorld/ClientPresentationSystemGroup.cs @@ -9,9 +9,7 @@ namespace Unity.NetCode /// Used only for DOTSRuntime and tests or other specific use cases. /// #if !UNITY_SERVER || UNITY_EDITOR -#if !UNITY_DOTSRUNTIME [DisableAutoCreation] -#endif [UpdateInGroup(typeof(PresentationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation)] internal partial class TickClientPresentationSystem : TickComponentSystemGroup diff --git a/Runtime/ClientServerWorld/ClientServerBootstrap.cs b/Runtime/ClientServerWorld/ClientServerBootstrap.cs index a75cd05..d165ef9 100644 --- a/Runtime/ClientServerWorld/ClientServerBootstrap.cs +++ b/Runtime/ClientServerWorld/ClientServerBootstrap.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; -using System.Linq; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Networking.Transport; -using Unity.Scenes; namespace Unity.NetCode { @@ -19,19 +17,58 @@ namespace Unity.NetCode /// For the server, it allow binding the server transport to a specific listening port and address (especially useful /// when running the server on some cloud provider) via . /// + /// + /// We strongly recommend setting `Application.runInBackground = true;` (or project-wide via Project Settings) once you intend to connect to the server (or accept connections on said server). + /// If you don't, your multiplayer will stall (and likely disconnect) if and when the application loses focus (e.g. by the player tabbing out), as netcode will be unable to tick (due to the application pausing). + /// In fact, a Dedicated Server Build should probably always have `Run in Background` enabled. + /// We provide suppressible error warnings for this case via `WarnAboutApplicationRunInBackground`. + /// [UnityEngine.Scripting.Preserve] public class ClientServerBootstrap : ICustomBootstrap { /// /// The maximum number of thin clients that can be created in the editor. + /// Created to avoid self-inflicted long editor hangs, + /// although removed as users should be able to test large player counts (e.g. for UTP reasons). /// - public const int k_MaxNumThinClients = 32; + public const int k_MaxNumThinClients = 1000; + + /// + /// A reference to the server world, assigned during the default server world creation. If there + /// were multiple worlds created this will be the first one. + /// + public static World ServerWorld => ServerWorlds != null && ServerWorlds.Count > 0 && ServerWorlds[0].IsCreated ? ServerWorlds[0] : null; + + /// + /// A reference to the client world, assigned during the default client world creation. If there + /// were multiple worlds created this will be the first one. + /// + public static World ClientWorld => ClientWorlds != null && ClientWorlds.Count > 0 && ClientWorlds[0].IsCreated ? ClientWorlds[0] : null; + + /// + /// A list of all server worlds created during the default creation flow. If this type of world + /// is created manually and not via the bootstrap APIs this list needs to be manually populated. + /// + public static List ServerWorlds => ClientServerTracker.ServerWorlds; + + /// + /// A list of all client worlds created during the default creation flow. If this type of world + /// is created manually and not via the bootstrap APIs this list needs to be manually populated. + /// + public static List ClientWorlds => ClientServerTracker.ClientWorlds; + + /// + /// A list of all thin client worlds created during the default creation flow. If this type of world + /// is created manually and not via the bootstrap APIs this list needs to be manually populated. + /// + public static List ThinClientWorlds => ClientServerTracker.ThinClientWorlds; #if UNITY_EDITOR || !UNITY_SERVER private static int NextThinClientId; /// /// Initialize the bootstrap class and reset the static data everytime a new instance is created. /// + public ClientServerBootstrap() { NextThinClientId = 1; @@ -62,81 +99,14 @@ public static World CreateLocalWorld(string defaultWorldName) var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.Default); DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, systems); -#if !UNITY_DOTSRUNTIME ScriptBehaviourUpdateOrder.AppendWorldToCurrentPlayerLoop(world); -#endif return world; } -#if UNITY_DOTSRUNTIME - private static void CreateTickWorld() - { - if (World.DefaultGameObjectInjectionWorld == null) - { - World.DefaultGameObjectInjectionWorld = new World("NetcodeTickWorld", WorldFlags.Game); - - var systems = new Type[]{ -#if !UNITY_SERVER - typeof(TickClientInitializationSystem), typeof(TickClientSimulationSystem), typeof(TickClientPresentationSystem), -#endif -#if !UNITY_CLIENT - typeof(TickServerInitializationSystem), typeof(TickServerSimulationSystem), -#endif - typeof(WorldUpdateAllocatorResetSystem) - }; - DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(World.DefaultGameObjectInjectionWorld, systems); - } - } -#if !UNITY_CLIENT - private static void AppendWorldToServerTickWorld(World childWorld) - { - CreateTickWorld(); - var initializationTickSystem = World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); - var simulationTickSystem = World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); - - //Bind main world group to tick systems (DefaultWorld tick the client world) - if (initializationTickSystem == null || simulationTickSystem == null) - throw new InvalidOperationException("Tying to add a world to the tick systems of the default world, but the default world does not have the tick systems"); - - var initializationGroup = childWorld.GetExistingSystemManaged(); - var simulationGroup = childWorld.GetExistingSystemManaged(); - - if (initializationGroup != null) - initializationTickSystem.AddSystemGroupToTickList(initializationGroup); - if (simulationGroup != null) - simulationTickSystem.AddSystemGroupToTickList(simulationGroup); - } -#endif -#if !UNITY_SERVER - private static void AppendWorldToClientTickWorld(World childWorld) - { - CreateTickWorld(); - var initializationTickSystem = World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); - var simulationTickSystem = World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); - var presentationTickSystem = World.DefaultGameObjectInjectionWorld?.GetExistingSystemManaged(); - - //Bind main world group to tick systems (DefaultWorld tick the client world) - if (initializationTickSystem == null || simulationTickSystem == null || presentationTickSystem == null) - throw new InvalidOperationException("Tying to add a world to the tick systems of the default world, but the default world does not have the tick systems"); - - var initializationGroup = childWorld.GetExistingSystemManaged(); - var simulationGroup = childWorld.GetExistingSystemManaged(); - var presentationGroup = childWorld.GetExistingSystemManaged(); - - if (initializationGroup != null) - initializationTickSystem.AddSystemGroupToTickList(initializationGroup); - if (simulationGroup != null) - simulationTickSystem.AddSystemGroupToTickList(simulationGroup); - if (presentationGroup != null) - presentationTickSystem.AddSystemGroupToTickList(presentationGroup); - } -#endif -#endif /// - /// Implement the ICustomBootstrap interface. Create the default client and serer worlds by + /// Implement the ICustomBootstrap interface. Create the default client and server worlds /// based on the . - /// In the editor, it also create thin clients worlds, if is not 0. - /// As part of the initialization process, if the + /// In the editor, it also creates thin client worlds, if is not 0. /// /// The name to use for the default world. Unused, can be null or empty /// @@ -189,11 +159,8 @@ public static World CreateThinClientWorld() var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.ThinClientSimulation); DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, systems); -#if UNITY_DOTSRUNTIME - AppendWorldToClientTickWorld(world); -#else ScriptBehaviourUpdateOrder.AppendWorldToCurrentPlayerLoop(world); -#endif + ThinClientWorlds.Add(world); return world; #endif @@ -214,17 +181,13 @@ public static World CreateClientWorld(string name) var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.Presentation); DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, systems); - -#if UNITY_DOTSRUNTIME - AppendWorldToClientTickWorld(world); -#else ScriptBehaviourUpdateOrder.AppendWorldToCurrentPlayerLoop(world); -#endif if (World.DefaultGameObjectInjectionWorld == null) World.DefaultGameObjectInjectionWorld = world; - return world; + ClientWorlds.Add(world); + return world; #endif } @@ -307,16 +270,12 @@ public static World CreateServerWorld(string name) var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.ServerSimulation); DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, systems); - -#if UNITY_DOTSRUNTIME - AppendWorldToServerTickWorld(world); -#else ScriptBehaviourUpdateOrder.AppendWorldToCurrentPlayerLoop(world); -#endif if (World.DefaultGameObjectInjectionWorld == null) World.DefaultGameObjectInjectionWorld = world; + ServerWorlds.Add(world); return world; #endif } @@ -333,7 +292,7 @@ public static World CreateServerWorld(string name) /// The default address to connect to when using auto connect (`AutoConnectPort` is not zero). /// If this value is `NetworkEndPoint.AnyIpv4` auto connect will not be used, even if the port is specified. /// This is to allow auto listen without auto connect. - /// The address specified in the `Multiplayer PlayMode Tools` window takes precedence over this when running in the editor (in `PlayType.Client`). + /// The address specified in the `PlayMode Tools` window takes precedence over this when running in the editor (in `PlayType.Client`). /// If that address is not valid or you are running in a player, then `DefaultConnectAddress` will be used instead. /// /// Note that the `DefaultConnectAddress.Port` will be clobbered by the `AutoConnectPort` if it's set. @@ -416,6 +375,19 @@ internal struct ServerClientCount /// If at least one world with flags has been created. /// public static bool HasClientWorlds => WorldCounts.Data.clientWorlds > 0; + + static class ClientServerTracker + { + internal static List ServerWorlds; + internal static List ClientWorlds; + internal static List ThinClientWorlds; + static ClientServerTracker() + { + ServerWorlds = new List(); + ClientWorlds = new List(); + ThinClientWorlds = new List(); + } + } } /// @@ -504,6 +476,7 @@ public void OnCreate(ref SystemState state) public void OnDestroy(ref SystemState state) { --ClientServerBootstrap.WorldCounts.Data.serverWorlds; + ClientServerBootstrap.ServerWorlds.Remove(state.World); } } @@ -532,6 +505,7 @@ public void OnCreate(ref SystemState state) public void OnDestroy(ref SystemState state) { --ClientServerBootstrap.WorldCounts.Data.clientWorlds; + ClientServerBootstrap.ClientWorlds.Remove(state.World); } } @@ -547,28 +521,20 @@ public void OnCreate(ref SystemState state) simulationGroup.RateManager = new NetcodeClientRateManager(simulationGroup); ++ClientServerBootstrap.WorldCounts.Data.clientWorlds; - if(ClientServerBootstrap.TryFindAutoConnectEndPoint(out var autoConnectEp)) + if (ClientServerBootstrap.TryFindAutoConnectEndPoint(out var autoConnectEp)) { SystemAPI.GetSingletonRW().ValueRW.Connect(state.EntityManager, autoConnectEp); } - else + // Thin client has no auto connect endpoint configured to connect to. Check if the client has connected to + // something already (so it has manually connected), if so then connect to the same address + else if (ClientServerBootstrap.ClientWorld != null && ClientServerBootstrap.ClientWorld.IsCreated) { - // Thin client has no auto connect endpoint configured to connect to. Check if the client has connected to - // something already (so it has manually connected), if so then connect to the same address - for (int i = 0; i < World.All.Count; ++i) - { - var world = World.All[i]; - if (world.IsClient()) - { - using var driver = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); - UnityEngine.Assertions.Assert.IsFalse(driver.IsEmpty); - var driverData = driver.ToComponentDataArray(Allocator.Temp); - UnityEngine.Assertions.Assert.IsTrue(driverData.Length == 1); - if (driverData[0].LastEndPoint.IsValid) - SystemAPI.GetSingletonRW().ValueRW.Connect(state.EntityManager, driverData[0].LastEndPoint); - break; - } - } + using var driver = ClientServerBootstrap.ClientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + UnityEngine.Assertions.Assert.IsFalse(driver.IsEmpty); + var driverData = driver.ToComponentDataArray(Allocator.Temp); + UnityEngine.Assertions.Assert.IsTrue(driverData.Length == 1); + if (driverData[0].LastEndPoint.IsValid) + SystemAPI.GetSingletonRW().ValueRW.Connect(state.EntityManager, driverData[0].LastEndPoint); } state.Enabled = false; @@ -577,6 +543,7 @@ public void OnCreate(ref SystemState state) public void OnDestroy(ref SystemState state) { --ClientServerBootstrap.WorldCounts.Data.clientWorlds; + ClientServerBootstrap.ThinClientWorlds.Remove(state.World); } } } diff --git a/Runtime/ClientServerWorld/ClientServerTickRate.cs b/Runtime/ClientServerWorld/ClientServerTickRate.cs index 75b83d7..969bc25 100644 --- a/Runtime/ClientServerWorld/ClientServerTickRate.cs +++ b/Runtime/ClientServerWorld/ClientServerTickRate.cs @@ -1,23 +1,75 @@ +using System; +using System.Diagnostics; using Unity.Entities; namespace Unity.NetCode { /// - /// Create a ClientServerTickRate singleton to configure the client and server simulation simulation time step, - /// and the server packet send rate. - /// The singleton can be created at runtime or by adding the component to a singleton entity in sub-scene. - /// It is not mandatory to create the singleton in the client worlds (while it is considered best practice), since the - /// relevant settings for the client (the and ) are synced - /// as part of the initial handshake (). + /// The ClientServerTickRate singleton is used to configure the client and server simulation time step, + /// server packet send rate and other related settings. + /// The singleton entity is automatically created for the clients in the + /// first update if not present. + /// On the server, by countrary, the entity is never automatically created and it is up to the user to create the singletong instance if + /// they need to. + /// + /// This behaviour is asymmetric because the client need to have this singleton data synced with the server one. It is like + /// this for compatibility reason and It may be changed in the future. + /// + /// In order to configure these settings you can either: + /// + /// - Create the entity in a custom after the worlds has been created. + /// - On a system, in either the OnCreate or OnUpdate. + /// + /// It is not mandatory to set all the fields to a proper value when creating the singleton. It is sufficient to change only the relevant setting, and call the method to + /// configure the fields that does not have a value set. + /// + /// class MyCustomClientServerBootstrap : ClientServerBootstrap + /// { + /// override public void Initialize(string defaultWorld) + /// { + /// base.Initialise(defaultWorld); + /// var customTickRate = new ClientServerTickRate(); + /// //run at 30hz + /// customTickRate.simulationTickRate = 30; + /// customTickRate.ResolveDefault(); + /// foreach(var world in World.All) + /// { + /// if(world.IsServer()) + /// { + /// //In this case we only create on the server, but we can do the same also for the client world + /// var tickRateEntity = world.EntityManager.CreateSingleton(new ClientServerTickRate + /// { + /// SimulationTickRate = 30; + /// }); + /// } + /// } + /// } + /// } + /// + /// The settings are synced as part of the of the initial client connection handshake. + /// ( data). /// The ClientServerTickRate should also be used to customise other server only timing settings, such as - /// the maximum number of tick per frame, tick batching ( and others. See the - /// individual fields documentation for more information. + /// + /// the maximum number of tick per frame + /// the maximum number of tick per frame + /// tick batching ( and others. + /// + /// See the individual fields documentation for more information. /// /// - /// It is not mandatory to set all the fields to a proper value when creating the singleton. - /// It is sufficient to change only the relevant setting, and call the method to - /// configure the fields that does not have a value set. + /// + /// + /// Once the client is connected, changes to the are not replicated. If you change the settings are runtime, the same change must + /// be done on both client and server. + /// + /// + /// The ClientServerTickRate should never be added to sub-scene with a baker. In case you want to setup the ClientServerTickRate + /// based on some scene settings, we suggest to implement your own component and change the ClientServerTickRate inside a system in + /// your game. + /// + /// /// + [Serializable] public struct ClientServerTickRate : IComponentData { /// @@ -45,9 +97,24 @@ public enum FrameRateMode /// public int SimulationTickRate; + /// + /// Multiplier used to calculate the tick rate/frequency for the . + /// The group rate must be an integer multiple of the . + /// Default value is 1, meaning that the run at the same frequency + /// of the prediction loop. + /// The calculated frequency is 1.0/(SimulationTickRate*PredictedFixedStepSimulationTickRatio) + /// + public int PredictedFixedStepSimulationTickRatio; + /// 1f / . Think of this as the netcode version of `fixedDeltaTime`. public float SimulationFixedTimeStep => 1f / SimulationTickRate; + /// + /// The fixed time used to run the physics simulation. Is always an integer multiple of the SimulationFixedTimeStep.
+ /// The value is equal to 1f / ( * ). + ///
+ public float PredictedFixedStepSimulationTimeStep => 1f / (PredictedFixedStepSimulationTickRatio*SimulationTickRate); + /// /// The rate at which the server sends snapshots to the clients. This can be lower than than /// the simulation frequency which means the server only sends new snapshots to the clients @@ -88,8 +155,8 @@ public bool SendSnapshotsForCatchUpTicks get { return m_SendSnapshotsForCatchUpTicks == 1; } set { m_SendSnapshotsForCatchUpTicks = value ? (byte)1 : (byte)0; } } - private byte m_SendSnapshotsForCatchUpTicks; + private byte m_SendSnapshotsForCatchUpTicks; /// /// Set all the properties that hasn't been changed by the user or that have invalid ranges to a proper default value. @@ -99,13 +166,34 @@ public void ResolveDefaults() { if (SimulationTickRate <= 0) SimulationTickRate = 60; + if (PredictedFixedStepSimulationTickRatio <= 0) + PredictedFixedStepSimulationTickRatio = 1; if (NetworkTickRate <= 0) NetworkTickRate = SimulationTickRate; + if (NetworkTickRate > SimulationTickRate) + NetworkTickRate = SimulationTickRate; if (MaxSimulationStepsPerFrame <= 0) MaxSimulationStepsPerFrame = 1; if (MaxSimulationStepBatchSize <= 0) MaxSimulationStepBatchSize = 4; } + + [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")] + internal readonly void Validate() + { + if (SimulationTickRate <= 0) + throw new ArgumentException($"The {nameof(SimulationTickRate)} must be always > 0"); + if (PredictedFixedStepSimulationTickRatio <= 0) + throw new ArgumentException($"The {nameof(PredictedFixedStepSimulationTickRatio)} must be always > 0"); + if (NetworkTickRate <= 0) + throw new ArgumentException($"The {nameof(NetworkTickRate)} must be always > 0"); + if (NetworkTickRate > SimulationTickRate) + throw new ArgumentException($"The {nameof(NetworkTickRate)} must be always less or equal"); + if (MaxSimulationStepsPerFrame <= 0) + throw new ArgumentException($"The {nameof(MaxSimulationStepsPerFrame)} must be always > 0"); + if (MaxSimulationStepBatchSize <= 0) + throw new ArgumentException($"The {nameof(MaxSimulationStepBatchSize)} be always > 0"); + } } /// @@ -119,6 +207,10 @@ internal struct ClientServerTickRateRefreshRequest : IComponentData /// public int SimulationTickRate; /// + /// The ratio between the and the . + /// + public int PredictedFixedStepSimulationTickRatio; + /// /// The rate at which the packet are sent to the client /// public int NetworkTickRate; @@ -132,6 +224,15 @@ internal struct ClientServerTickRateRefreshRequest : IComponentData /// frame rate. See /// public int MaxSimulationStepBatchSize; + + public void ApplyTo(ref ClientServerTickRate tickRate) + { + tickRate.MaxSimulationStepsPerFrame = MaxSimulationStepsPerFrame; + tickRate.NetworkTickRate = NetworkTickRate; + tickRate.SimulationTickRate = SimulationTickRate; + tickRate.MaxSimulationStepBatchSize = MaxSimulationStepBatchSize; + tickRate.PredictedFixedStepSimulationTickRatio = PredictedFixedStepSimulationTickRatio; + } } /// @@ -139,6 +240,7 @@ internal struct ClientServerTickRateRefreshRequest : IComponentData /// to configure all the network time synchronization, interpolation delay, prediction batching and other setting for the client. /// See the individual fields for more information about the individual properties. /// + [Serializable] public struct ClientTickRate : IComponentData { /// diff --git a/Runtime/ClientServerWorld/ClientSimulationSystemGroup.cs b/Runtime/ClientServerWorld/ClientSimulationSystemGroup.cs index 8e994a8..45e8af3 100644 --- a/Runtime/ClientServerWorld/ClientSimulationSystemGroup.cs +++ b/Runtime/ClientServerWorld/ClientSimulationSystemGroup.cs @@ -21,6 +21,7 @@ class NetcodeClientRateManager : IRateManager private EntityQuery m_ClientSeverTickRateQuery; private EntityQuery m_NetworkStreamInGameQuery; private EntityQuery m_NetworkTimeSystemDataQuery; + private readonly PredictedFixedStepSimulationSystemGroup m_PredictedFixedStepSimulationSystemGroup; private bool m_DidPushTime; internal NetcodeClientRateManager(ComponentSystemGroup group) @@ -32,6 +33,7 @@ internal NetcodeClientRateManager(ComponentSystemGroup group) m_ClientSeverTickRateQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); m_NetworkStreamInGameQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); m_NetworkTimeSystemDataQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + m_PredictedFixedStepSimulationSystemGroup = group.World.GetExistingSystemManaged(); var netTimeEntity = group.World.EntityManager.CreateEntity( ComponentType.ReadWrite(), @@ -56,10 +58,10 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) m_ClientSeverTickRateQuery.TryGetSingleton(out var tickRate); tickRate.ResolveDefaults(); + if (m_PredictedFixedStepSimulationSystemGroup != null) + m_PredictedFixedStepSimulationSystemGroup.ConfigureTimeStep(tickRate); var networkTimeSystemData = m_NetworkTimeSystemDataQuery.GetSingleton(); - - float fixedTimeStep = tickRate.SimulationFixedTimeStep; // Calculate update time based on values received from the network time system var curServerTick = networkTimeSystemData.predictTargetTick; var curInterpoationTick = networkTimeSystemData.interpolateTargetTick; @@ -93,7 +95,7 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) if (curServerTick.IsValid && previousServerTick.Value.IsValid) { var deltaTicks = curServerTick.TicksSince(previousServerTick.Value); - networkDeltaTime = (deltaTicks + serverTickFraction - previousServerTick.Fraction) * fixedTimeStep; + networkDeltaTime = (deltaTicks + serverTickFraction - previousServerTick.Fraction) * tickRate.SimulationFixedTimeStep; networkTime.SimulationStepBatchSize = (int)deltaTicks; // If last tick was fractional - consider this as re-doing that tick since it will be re-predicted if (previousServerTick.Fraction < 1) @@ -193,9 +195,7 @@ protected override void OnUpdate() #if !UNITY_CLIENT || UNITY_SERVER || UNITY_EDITOR [UpdateAfter(typeof(TickServerSimulationSystem))] #endif -#if !UNITY_DOTSRUNTIME [DisableAutoCreation] -#endif [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation)] internal partial class TickClientSimulationSystem : TickComponentSystemGroup { diff --git a/Runtime/ClientServerWorld/ServerInitializationSystemGroup.cs b/Runtime/ClientServerWorld/ServerInitializationSystemGroup.cs index da7ad38..452dddc 100644 --- a/Runtime/ClientServerWorld/ServerInitializationSystemGroup.cs +++ b/Runtime/ClientServerWorld/ServerInitializationSystemGroup.cs @@ -8,9 +8,7 @@ namespace Unity.NetCode /// Used only for DOTSRuntime and tests or other specific use cases. /// #if !UNITY_CLIENT || UNITY_SERVER || UNITY_EDITOR -#if !UNITY_DOTSRUNTIME [DisableAutoCreation] -#endif [UpdateInGroup(typeof(InitializationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation)] internal partial class TickServerInitializationSystem : TickComponentSystemGroup diff --git a/Runtime/ClientServerWorld/ServerSimulationSystemGroup.cs b/Runtime/ClientServerWorld/ServerSimulationSystemGroup.cs index 4f86a0a..12ddbeb 100644 --- a/Runtime/ClientServerWorld/ServerSimulationSystemGroup.cs +++ b/Runtime/ClientServerWorld/ServerSimulationSystemGroup.cs @@ -2,6 +2,7 @@ using Unity.Entities; using Unity.Profiling; using Unity.Collections; +using Unity.Mathematics; namespace Unity.NetCode { @@ -16,6 +17,7 @@ unsafe class NetcodeServerRateManager : IRateManager private int m_CurrentTickAge; private bool m_DidPushTime; DoubleRewindableAllocators* m_OldGroupAllocators = null; + private readonly PredictedFixedStepSimulationSystemGroup m_PredictedFixedStepSimulationSystemGroup; private struct Count { // The total number of step the simulation should take @@ -71,16 +73,14 @@ private void AdjustTargetFrameRate(int tickRate, float fixedTimeStep) else if (m_AccumulatedTime < 0.25f * fixedTimeStep) rate -= 2; // lower rate means bigger deltaTime which means remaining accumulatedTime gets bigger - // TODO: need to do solve this for dots runtime. For now just do nothing - #if !UNITY_DOTSRUNTIME UnityEngine.Application.targetFrameRate = rate; - #endif } internal NetcodeServerRateManager(ComponentSystemGroup group) { // Create the queries for singletons m_NetworkTimeQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); m_ClientSeverTickRateQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); + m_PredictedFixedStepSimulationSystemGroup = group.World.GetExistingSystemManaged(); m_fixedUpdateMarker = new ProfilerMarker("ServerFixedUpdate"); @@ -96,8 +96,6 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) { m_ClientSeverTickRateQuery.TryGetSingleton(out var tickRate); tickRate.ResolveDefaults(); - - var fixedTimeStep = tickRate.SimulationFixedTimeStep; if (m_DidPushTime) { group.World.PopTime(); @@ -106,16 +104,16 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) } else { - m_UpdateCount = GetUpdateCount(group.World.Time.DeltaTime, fixedTimeStep, tickRate.MaxSimulationStepsPerFrame, tickRate.MaxSimulationStepBatchSize); + m_UpdateCount = GetUpdateCount(group.World.Time.DeltaTime, tickRate.SimulationFixedTimeStep, tickRate.MaxSimulationStepsPerFrame, tickRate.MaxSimulationStepBatchSize); m_CurrentTickAge = m_UpdateCount.Total-1; - + m_PredictedFixedStepSimulationSystemGroup.ConfigureTimeStep(tickRate); #if UNITY_SERVER && !UNITY_EDITOR if (tickRate.TargetFrameRateMode != ClientServerTickRate.FrameRateMode.BusyWait) #else if (tickRate.TargetFrameRateMode == ClientServerTickRate.FrameRateMode.Sleep) #endif { - AdjustTargetFrameRate(tickRate.SimulationTickRate, fixedTimeStep); + AdjustTargetFrameRate(tickRate.SimulationTickRate, tickRate.SimulationFixedTimeStep); } } if (m_CurrentTickAge < 0) @@ -125,7 +123,7 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) } if (m_CurrentTickAge == (m_UpdateCount.Short - 1)) --m_UpdateCount.Length; - var dt = fixedTimeStep * m_UpdateCount.Length; + var dt = tickRate.SimulationFixedTimeStep * m_UpdateCount.Length; // Check for wrap around ref var networkTime = ref m_NetworkTimeQuery.GetSingletonRW().ValueRW; var currentServerTick = networkTime.ServerTick; @@ -165,9 +163,7 @@ public float Timestep /// Used only for DOTSRuntime and tests or other specific use cases. /// #if !UNITY_CLIENT || UNITY_SERVER || UNITY_EDITOR -#if !UNITY_DOTSRUNTIME [DisableAutoCreation] -#endif [WorldSystemFilter(WorldSystemFilterFlags.LocalSimulation)] internal partial class TickServerSimulationSystem : TickComponentSystemGroup { diff --git a/Runtime/Command/CommandSendSystem.cs b/Runtime/Command/CommandSendSystem.cs index 4bbe929..4d6a782 100644 --- a/Runtime/Command/CommandSendSystem.cs +++ b/Runtime/Command/CommandSendSystem.cs @@ -35,6 +35,28 @@ public partial class GhostInputSystemGroup : ComponentSystemGroup { } + /// + /// The parent group for all generated systems that copy data from the an to the + /// underlying , that is the ring buffer that will contains the generated user commands. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation, + WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(GhostInputSystemGroup), OrderLast = true)] + public partial class CopyInputToCommandBufferSystemGroup : ComponentSystemGroup + { + } + + /// + /// The parent group for all generated systems that copy data from and underlying + /// to its parent . + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation, + WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderFirst = true)] + public partial class CopyCommandBufferToInputSystemGroup : ComponentSystemGroup + { + } + /// /// This group contains all core-generated system that are used to compare commands for sake of identifing the ticks the client /// has changed input (see . @@ -157,8 +179,10 @@ partial struct CommandSendPacket : IJobEntity var requiredPayloadSize = k_CommandHeadersBytes + rpcData.Length; int maxSnapshotSizeWithoutFragmentation = NetworkParameterConstants.MTU - concurrentDriver.driver.MaxHeaderSize(concurrentDriver.unreliablePipeline); var pipelineToUse = requiredPayloadSize > maxSnapshotSizeWithoutFragmentation ? concurrentDriver.unreliableFragmentedPipeline : concurrentDriver.unreliablePipeline; - if (concurrentDriver.driver.BeginSend(pipelineToUse, connection.Value, out var writer, requiredPayloadSize) != 0) + int result; + if ((result = concurrentDriver.driver.BeginSend(pipelineToUse, connection.Value, out var writer, requiredPayloadSize)) < 0) { + netDebug.LogWarning($"CommandSendPacket BeginSend failed with errorCode: {result}!"); rpcData.Clear(); return; } @@ -190,9 +214,8 @@ partial struct CommandSendPacket : IJobEntity if(writer.HasFailedWrites) netDebug.LogError("CommandSendPacket job triggered Writer.HasFailedWrites, despite allocating the collection based on needed size!"); - var result = 0; if ((result = concurrentDriver.driver.EndSend(writer)) <= 0) - netDebug.LogError(FixedString.Format("An error occured during EndSend. ErrorCode: {0}", result)); + netDebug.LogError($"CommandSendPacket EndSend failed with errorCode: {result}!"); } } [BurstCompile] diff --git a/Runtime/Command/CommandTarget.cs b/Runtime/Command/CommandTarget.cs index 027357d..ee7b36e 100644 --- a/Runtime/Command/CommandTarget.cs +++ b/Runtime/Command/CommandTarget.cs @@ -16,9 +16,9 @@ public struct CommandTargetComponent : IComponentData /// It is mandatory to set a valid reference to the in order to receive client /// commands if: /// - you are not using the . - /// - you want to supoort thin-clients (because does not work in that case) - /// The use of and CommandTarget is complementary and they can used - /// at the sam time. + /// - you want to support thin-clients (because does not work in that case) + /// The use of and CommandTarget is complementary. I.e. They can both be used + /// at the same time. /// /// /// The target entity must have at least one `ICommandData` component on it. diff --git a/Runtime/Command/ICommandData.cs b/Runtime/Command/ICommandData.cs index a876e55..01c7633 100644 --- a/Runtime/Command/ICommandData.cs +++ b/Runtime/Command/ICommandData.cs @@ -1,6 +1,7 @@ using System; using Unity.Collections; using Unity.Entities; +using Unity.NetCode.LowLevel.Unsafe; namespace Unity.NetCode { @@ -164,6 +165,20 @@ public static bool GetDataAtTick(this DynamicBuffer commandArray, NetworkT return true; } + /// + /// Get a readonly reference to the input at the given index. Need to be used in safe context, where you know + /// the buffer is not going to be modified. That would invalidate the reference in that case and we can't guaratee + /// the data you are reading is going to be valid anymore. + /// + /// + /// + /// + /// A readonly reference to the element + public static ref readonly T GetInputAtIndex(this DynamicBuffer buffer, int index) where T: unmanaged, ICommandData + { + return ref buffer.ElementAtRO(index); + } + /// /// Add an instance of a into the command circular buffer. /// The command buffer capacity if fixed and the is diff --git a/Runtime/Command/IInputComponentData.cs b/Runtime/Command/IInputComponentData.cs index e62341c..b551874 100644 --- a/Runtime/Command/IInputComponentData.cs +++ b/Runtime/Command/IInputComponentData.cs @@ -1,8 +1,6 @@ using System; -using Unity.Burst; -using Unity.Entities; using Unity.Collections; -using Unity.NetCode.LowLevel.Unsafe; +using Unity.Entities; namespace Unity.NetCode { @@ -52,250 +50,56 @@ public void Set() /// Interface used to handle automatic input command data setup with the IInputComponentData /// style inputs. This is used internally by code generation, don't use this directly. /// + [Obsolete("The IInputBufferData interface has been deprecated. It was meant for internal use and any reference to it is considered an error. Please always use ICommandData instead", true)] public interface IInputBufferData : ICommandData { - /// - /// Take the stored input data we have and copy to the given input data pointed to. Decrement - /// any event counters by the counter value in the previous command buffer data element. - /// - /// Command data from the previous tick - /// Our stored input data will be copied over to this location - public void DecrementEventsAndAssignToInput(IntPtr prevInputBufferDataPtr, IntPtr inputPtr); - /// - /// Save the input data with any event counters incremented by the counter from the last stored - /// input in the command buffer for the current tick. See . - /// - /// Pointer to the last command data in the buffer - /// Pointer to input data to be saved in this command data - public void IncrementEventsAndSetCurrentInputData(IntPtr lastInputBufferDataPtr, IntPtr inputPtr); } /// - /// For internal use only, helper struct that should be used to implement systems that copy the content of an - /// into the code-generated buffer. + /// The underlying buffer used to store the . /// - /// - /// - [BurstCompile] - public partial struct CopyInputToCommandBuffer - where TInputBufferData : unmanaged, IInputBufferData - where TInputComponentData : unmanaged, IInputComponentData + /// + /// The buffer replication behaviour cannot be overriden on per-prefab basis and it is by default sent also + /// for child entities. + /// + /// An unmanaged struct implementing the interface"/> + [DontSupportPrefabOverrides] + [GhostComponent(SendDataForChildEntity = true)] + [InternalBufferCapacity(0)] + public struct InputBufferData : ICommandData where T: unmanaged, IInputComponentData { - private EntityQuery m_TimeQuery; - private EntityQuery m_ConnectionQuery; - [ReadOnly] private ComponentTypeHandle m_GhostOwnerDataType; - [ReadOnly] private ComponentTypeHandle m_InputDataType; - private BufferTypeHandle m_InputBufferTypeHandle; - /// - /// For internal use only, simplify the creation of system jobs that copies data to the underlying buffer. + /// The tick the command should be executed. It is mandatory to set the tick before adding the command to the + /// buffer using . /// - [BurstCompile] - public struct CopyInputToBufferJob - { - internal NetworkTick Tick; - internal int ConnectionId; - [ReadOnly] internal ComponentTypeHandle InputDataType; - [ReadOnly] internal ComponentTypeHandle GhostOwnerDataType; - internal BufferTypeHandle InputBufferDataType; - - /// - /// Implements the component copy and input event management. - /// Should be called your job method. - /// - /// - /// - [BurstCompile] - public void Execute(ArchetypeChunk chunk, int orderIndex) - { - var inputs = chunk.GetNativeArray(ref InputDataType); - var owners = chunk.GetNativeArray(ref GhostOwnerDataType); - var inputBuffers = chunk.GetBufferAccessor(ref InputBufferDataType); - - for (int i = 0, chunkEntityCount = chunk.Count; i < chunkEntityCount; ++i) - { - var inputData = inputs[i]; - var owner = owners[i]; - var inputBuffer = inputBuffers[i]; - - // Validate owner ID in case all entities are being predicted, only inputs from local player should be collected - if (owner.NetworkId != ConnectionId) - continue; - - var input = default(TInputBufferData); - input.Tick = Tick; - var inputDataPtr = GhostComponentSerializer.IntPtrCast(ref inputData); - - // Increment event count for current tick. There could be an event and then no event but on the same - // predicted/simulated tick, this will still be registered as an event (count > 0) instead of the later - // event overriding the event to 0/false. - inputBuffer.GetDataAtTick(Tick, out var inputDataElement); - var inputDataElementPtr = GhostComponentSerializer.IntPtrCast(ref inputDataElement); - input.IncrementEventsAndSetCurrentInputData(inputDataElementPtr, inputDataPtr); - - inputBuffer.AddCommandData(input); - } - } - } - + [DontSerializeForCommand] + public NetworkTick Tick { get; set; } /// - /// Initialize the CopyInputToCommandBuffer by updating all the component type handles and create a - /// a new instance. + /// The struct that hold the input data. /// - /// - /// a new instance. - [BurstCompile] - public CopyInputToBufferJob InitJobData(ref SystemState state) - { - m_GhostOwnerDataType.Update(ref state); - m_InputBufferTypeHandle.Update(ref state); - m_InputDataType.Update(ref state); - - var jobData = new CopyInputToBufferJob - { - Tick = m_TimeQuery.GetSingleton().ServerTick, - ConnectionId = m_ConnectionQuery.GetSingleton().Value, - GhostOwnerDataType = m_GhostOwnerDataType, - InputBufferDataType = m_InputBufferTypeHandle, - InputDataType = m_InputDataType, - }; - return jobData; - } - - /// - /// Creates the internal component type handles, register to system state the component queries. - /// Very important, add an implicity constraint for running the parent system only when the client - /// is connected to the server, by requiring at least one connection with a components. - /// - /// Should be called inside your the system OnCreate method. - /// - /// - /// - /// - [BurstCompile] - public EntityQuery Create(ref SystemState state) - { - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - var query = state.GetEntityQuery(builder); - m_TimeQuery = state.GetEntityQuery(ComponentType.ReadOnly()); - m_ConnectionQuery = state.GetEntityQuery(ComponentType.ReadOnly()); - m_GhostOwnerDataType = state.GetComponentTypeHandle(true); - m_InputBufferTypeHandle = state.GetBufferTypeHandle(); - m_InputDataType = state.GetComponentTypeHandle(true); - state.RequireForUpdate(); - return query; - } + public T InternalInput; } /// - /// For internal use only, helper struct that should be used to implements systems that copies - /// commands from the buffer to the component - /// present on the entity. + /// Internal use only, interface implemented by code-generated helpers to increment and decrement + /// events when copy to/from the underlying /// - /// - /// - [BurstCompile] - public partial struct ApplyCurrentInputBufferElementToInputData - where TInputBufferData : unmanaged, IInputBufferData - where TInputComponentData : unmanaged, IInputComponentData + /// + public interface IInputEventHelper where T: unmanaged, IInputComponentData { - private EntityQuery m_TimeQuery; - [ReadOnly] private EntityTypeHandle m_EntityTypeHandle; - private ComponentTypeHandle m_InputDataType; - [ReadOnly] private BufferTypeHandle m_InputBufferTypeHandle; - /// - /// Helper struct that should be used to implement jobs that copies commands from an buffer - /// to the respective . - /// - [BurstCompile] - public struct ApplyInputDataFromBufferJob - { - internal NetworkTick Tick; - internal int StepLength; - internal ComponentTypeHandle InputDataType; - internal BufferTypeHandle InputBufferTypeHandle; - - /// - /// Copy the command for current server tick to the input component. - /// Should be called your job method. - /// - /// - /// - [BurstCompile] - public void Execute(ArchetypeChunk chunk, int orderIndex) - { - var inputs = chunk.GetNativeArray(ref InputDataType); - var inputBuffers = chunk.GetBufferAccessor(ref InputBufferTypeHandle); - - for (int i = 0, chunkEntityCount = chunk.Count; i < chunkEntityCount; ++i) - { - var inputData = inputs[i]; - var inputBuffer = inputBuffers[i]; - - // Sample tick and tick-StepLength, if tick is not in the buffer it will return the latest input - // closest to it, and the same input for tick-StepLength, which is the right result as it should - // assume the same tick is repeating - inputBuffer.GetDataAtTick(Tick, out var inputDataElement); - var prevSampledTick = Tick; - prevSampledTick.Subtract((uint)StepLength); - inputBuffer.GetDataAtTick(prevSampledTick, out var prevInputDataElement); - - var prevInputDataElementPtr = GhostComponentSerializer.IntPtrCast(ref prevInputDataElement); - var inputDataPtr = GhostComponentSerializer.IntPtrCast(ref inputData); - inputDataElement.DecrementEventsAndAssignToInput(prevInputDataElementPtr, inputDataPtr); - inputs[i] = inputData; - } - } - } - - /// - /// Update the component type handles and create a new - /// that can be passed to your job. + /// Take the stored input data we have and copy to the given input data pointed to. Decrement + /// any event counters by the counter value in the previous command buffer data element. /// - /// - /// a new instance. - [BurstCompile] - public ApplyInputDataFromBufferJob InitJobData(ref SystemState state) - { - m_EntityTypeHandle.Update(ref state); - m_InputBufferTypeHandle.Update(ref state); - m_InputDataType.Update(ref state); - - var networkTime = m_TimeQuery.GetSingleton(); - var jobData = new ApplyInputDataFromBufferJob - { - Tick = networkTime.ServerTick, - StepLength = networkTime.SimulationStepBatchSize, - InputBufferTypeHandle = m_InputBufferTypeHandle, - InputDataType = m_InputDataType - }; - return jobData; - } - + /// Command data from the previous tick + /// Our stored input data will be copied over to this location + public void DecrementEvents(ref T inputData, in T prevInputData); /// - /// Creates all the internal queries and setup the internal component type handles. - /// Very important, add an implicity constraint for running the parent system only when the client - /// is connected to the server, by requiring at least one connection with a components. - /// - /// Should be called inside your the system OnCreate method. - /// + /// Save the input data with any event counters incremented by the counter from the last stored + /// input in the command buffer for the current tick. See . /// - /// - /// - [BurstCompile] - public EntityQuery Create(ref SystemState state) - { - var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll(); - var query = state.GetEntityQuery(builder); - m_TimeQuery = state.GetEntityQuery(ComponentType.ReadOnly()); - m_EntityTypeHandle = state.GetEntityTypeHandle(); - m_InputBufferTypeHandle = state.GetBufferTypeHandle(); - m_InputDataType = state.GetComponentTypeHandle(); - state.RequireForUpdate(); - return query; - } + /// Pointer to the last command data in the buffer + /// Pointer to input data to be saved in this command data + public void IncrementEvents(ref T inputData, in T lastInputData); } } diff --git a/Runtime/Command/InputCommandSystems.cs b/Runtime/Command/InputCommandSystems.cs new file mode 100644 index 0000000..77b9482 --- /dev/null +++ b/Runtime/Command/InputCommandSystems.cs @@ -0,0 +1,223 @@ +using Unity.Burst; +using Unity.Burst.Intrinsics; +using Unity.Collections; +using Unity.Entities; +using UnityEngine.UIElements; + +namespace Unity.NetCode +{ + /// + /// Internal job (don't use directly) used to copy the input data for struct implementing the + /// to the underlying command data + /// buffer. The job is also responsible to increment the counters, in case the input + /// component contains input events. + /// + /// + /// + [BurstCompile] + public struct CopyInputToBufferJob : IJobChunk + where TInputComponentData : unmanaged, IInputComponentData + where TInputHelper : unmanaged, IInputEventHelper + { + internal NetworkTick Tick; + internal int ConnectionId; + [ReadOnly] internal ComponentTypeHandle InputDataType; + [ReadOnly] internal ComponentTypeHandle GhostOwnerDataType; + internal BufferTypeHandle> InputBufferDataType; + + /// + /// Copy the input component for current server tick to the command buffer. + /// + /// + /// + /// + /// + [BurstCompile] + public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) + { + var inputs = chunk.GetNativeArray(ref InputDataType); + var owners = chunk.GetNativeArray(ref GhostOwnerDataType); + var inputBuffers = chunk.GetBufferAccessor(ref InputBufferDataType); + var helper = new TInputHelper(); + for (int i = 0, chunkEntityCount = chunk.Count; i < chunkEntityCount; ++i) + { + var inputData = inputs[i]; + var owner = owners[i]; + var inputBuffer = inputBuffers[i]; + + // Validate owner ID in case all entities are being predicted, only inputs from local player should be collected + if (owner.NetworkId != ConnectionId) + continue; + inputBuffer.GetDataAtTick(Tick, out var inputDataElement); + // Increment event count for current tick. There could be an event and then no event but on the same + // predicted/simulated tick, this will still be registered as an event (count > 0) instead of the later + // event overriding the event to 0/false. + var input = default(InputBufferData); + input.Tick = Tick; + input.InternalInput = inputData; + helper.IncrementEvents(ref input.InternalInput, inputDataElement.InternalInput); + + inputBuffer.AddCommandData(input); + } + } + } + + /// + /// For internal use only, system that that copy the content of an into + /// buffer present on the entity. + /// + /// + /// + [BurstCompile] + [UpdateInGroup(typeof(CopyInputToCommandBufferSystemGroup))] + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)] + public partial struct CopyInputToCommandBufferSystem : ISystem + where TInputComponentData : unmanaged, IInputComponentData + where TInputHelper : unmanaged, IInputEventHelper + { + private EntityQuery m_EntityQuery; + private EntityQuery m_TimeQuery; + private EntityQuery m_ConnectionQuery; + private ComponentTypeHandle m_GhostOwnerDataType; + private ComponentTypeHandle m_InputDataType; + private BufferTypeHandle> m_InputBufferTypeHandle; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll, TInputComponentData, GhostOwner>(); + m_EntityQuery = state.GetEntityQuery(builder); + m_TimeQuery = state.GetEntityQuery(ComponentType.ReadOnly()); + m_ConnectionQuery = state.GetEntityQuery(ComponentType.ReadOnly()); + m_GhostOwnerDataType = state.GetComponentTypeHandle(true); + m_InputBufferTypeHandle = state.GetBufferTypeHandle>(); + m_InputDataType = state.GetComponentTypeHandle(true); + state.RequireForUpdate(); + state.RequireForUpdate(m_EntityQuery); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + m_GhostOwnerDataType.Update(ref state); + m_InputBufferTypeHandle.Update(ref state); + m_InputDataType.Update(ref state); + + var job = new CopyInputToBufferJob + { + Tick = m_TimeQuery.GetSingleton().ServerTick, + ConnectionId = m_ConnectionQuery.GetSingleton().Value, + GhostOwnerDataType = m_GhostOwnerDataType, + InputBufferDataType = m_InputBufferTypeHandle, + InputDataType = m_InputDataType, + }; + state.Dependency = job.Schedule(m_EntityQuery, state.Dependency); + } + } + + /// + /// For internal use only, system that copies commands from the buffer + /// to the component present on the entity. + /// + /// + /// + // This needs to run early to ensure the input data has been applied from buffer to input data + // struct before the input processing system runs + [BurstCompile] + [UpdateInGroup(typeof(CopyCommandBufferToInputSystemGroup), OrderFirst = true)] + [UpdateBefore(typeof(PredictedFixedStepSimulationSystemGroup))] + public partial struct ApplyCurrentInputBufferElementToInputDataSystem : ISystem + where TInputComponentData : unmanaged, IInputComponentData + where TInputHelper : unmanaged, IInputEventHelper + { + private EntityQuery m_EntityQuery; + private EntityQuery m_TimeQuery; + private EntityTypeHandle m_EntityTypeHandle; + private ComponentTypeHandle m_InputDataType; + private BufferTypeHandle> m_InputBufferTypeHandle; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll, TInputComponentData, PredictedGhost>(); + m_EntityQuery = state.GetEntityQuery(builder); + m_TimeQuery = state.GetEntityQuery(ComponentType.ReadOnly()); + m_EntityTypeHandle = state.GetEntityTypeHandle(); + m_InputBufferTypeHandle = state.GetBufferTypeHandle>(); + m_InputDataType = state.GetComponentTypeHandle(); + state.RequireForUpdate(); + state.RequireForUpdate(m_EntityQuery); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + m_EntityTypeHandle.Update(ref state); + m_InputBufferTypeHandle.Update(ref state); + m_InputDataType.Update(ref state); + + var networkTime = m_TimeQuery.GetSingleton(); + var jobData = new ApplyInputDataFromBufferJob + { + Tick = networkTime.ServerTick, + StepLength = networkTime.SimulationStepBatchSize, + InputBufferTypeHandle = m_InputBufferTypeHandle, + InputDataType = m_InputDataType + }; + state.Dependency = jobData.Schedule(m_EntityQuery, state.Dependency); + } + } + + /// + /// Internal job (don't use directly), run inside the prediction loop and copy the + /// input data from an command buffer to an + /// component for the current simulated tick. + /// The job is responsible to recalculate any count, such that any events occurred + /// since last tick (or batch, see also ) are correctly reported as + /// set (see + /// + /// + /// + [BurstCompile] + public struct ApplyInputDataFromBufferJob : IJobChunk + where TInputComponentData : unmanaged, IInputComponentData + where TInputHelper : unmanaged, IInputEventHelper + { + internal NetworkTick Tick; + internal int StepLength; + internal ComponentTypeHandle InputDataType; + internal BufferTypeHandle> InputBufferTypeHandle; + + /// + /// Copy the command for current server tick to the input component. + /// + /// + /// + /// + /// + [BurstCompile] + public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) + { + var inputs = chunk.GetNativeArray(ref InputDataType); + var inputBuffers = chunk.GetBufferAccessor(ref InputBufferTypeHandle); + var helper = default(TInputHelper); + for (int i = 0, chunkEntityCount = chunk.Count; i < chunkEntityCount; ++i) + { + var inputBuffer = inputBuffers[i]; + inputBuffer.GetDataAtTick(Tick, out var inputDataElement); + // Sample tick and tick-StepLength, if tick is not in the buffer it will return the latest input + // closest to it, and the same input for tick-StepLength, which is the right result as it should + // assume the same tick is repeating + var prevSampledTick = Tick; + prevSampledTick.Subtract((uint)StepLength); + inputBuffer.GetDataAtTick(prevSampledTick, out var prevInputDataElement); + //reset the input data to match the current input and decrement the event counts + var inputData = inputDataElement.InternalInput; + helper.DecrementEvents(ref inputData, prevInputDataElement.InternalInput); + inputs[i] = inputData; + } + } + } +} diff --git a/Runtime/Command/InputCommandSystems.cs.meta b/Runtime/Command/InputCommandSystems.cs.meta new file mode 100644 index 0000000..4ed5b6e --- /dev/null +++ b/Runtime/Command/InputCommandSystems.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 67465ab18444417a92c0fe107b9817cb +timeCreated: 1689244593 \ No newline at end of file diff --git a/Runtime/Connection/DefaultDriverConstructor.cs b/Runtime/Connection/DefaultDriverConstructor.cs index 6fe515a..1326750 100644 --- a/Runtime/Connection/DefaultDriverConstructor.cs +++ b/Runtime/Connection/DefaultDriverConstructor.cs @@ -1,3 +1,4 @@ +using System; using Unity.Assertions; using Unity.Collections; using Unity.Entities; @@ -29,7 +30,7 @@ public static class DefaultDriverBuilder public static INetworkStreamDriverConstructor DefaultDriverConstructor => new IPCAndSocketDriverConstructor(); /// - /// Return a set of internal default settings. This will use the NetworkSimulator parameters set by Multiplayer PlayMode Tools. + /// Return a set of internal default settings. This will use the NetworkSimulator parameters set by PlayMode Tools. /// /// A new public static NetworkSettings GetNetworkSettings() @@ -48,7 +49,7 @@ public static NetworkSettings GetNetworkSettings() } /// - /// Return a set of internal default settings. This will use the NetworkSimulator parameters set by Multiplayer PlayMode Tools. + /// Return a set of internal default settings. This will use the NetworkSimulator parameters set by PlayMode Tools. /// /// Amount of players the server should allocate receive and send queue for. The estimation is that each player will receive 4 packets. /// Parameters that describe the network configuration. @@ -123,6 +124,7 @@ private static int QueueSizeFromPlayerCount(int playerCount) return playerCount * 4; } +#if !UNITY_WEBGL || UNITY_EDITOR /// /// Helper method for creating server NetworkDriver given the specified INetworkInterface /// The driver is configured with the internal defaults. See: . @@ -154,22 +156,20 @@ private static int QueueSizeFromPlayerCount(int playerCount) return driverInstance; } +#endif -#if !UNITY_CLIENT - static bool UseSocketDriver(NetDebug netDebug) + /// + /// Helper method to determine if the client world should prefer using a socket-based network interface + /// (UDP or WebSocket) or the . + /// IPC connection type is preferred only in case the is set to + /// client/server mode, a server world exist in the process and the are disable (in the editor or development build). + /// + /// + /// True when a client world should use a network driver which implements a socket based interface. + /// This method should not be used to configure server driver. Also, for server build, this method always return true. + public static bool ClientUseSocketDriver(NetDebug netDebug) { - bool FoundServerWorldInstance() - { - foreach (var otherWorld in World.All) - { - if (!otherWorld.IsCreated || !otherWorld.IsServer()) { continue; } - netDebug.DebugLog("Found server world instance. Prefer use IPC network interface"); - return true; - } - - return false; - } - +#if !UNITY_CLIENT #if UNITY_EDITOR || NETCODE_DEBUG //if the emulator is enabled we always force to use sockets. It also work with IPC but this is preferred choice. if (NetworkSimulatorSettings.Enabled) @@ -186,14 +186,24 @@ bool FoundServerWorldInstance() //PlayMode is client server the simulator is disabled. We are in client-server mode Assert.IsTrue(ClientServerBootstrap.RequestedPlayType == ClientServerBootstrap.PlayType.ClientAndServer); netDebug.DebugLog("Lookup for a server world instance in the same process"); - return !FoundServerWorldInstance(); - } + + if (ClientServerBootstrap.ServerWorld != null && ClientServerBootstrap.ServerWorld.IsCreated) + { + netDebug.DebugLog("Found server world instance. Prefer use IPC network interface"); + return false; + } #endif + return true; + } + /// - /// Register a NetworkDriver instance in :
- /// - a single NetworkDriver if the both client and server worlds are present in the same process
- /// - a single driver in all other cases
+ /// Register a NetworkDriver instance in the that uses either: + /// + ///
  • a single NetworkDriver if the both client and server worlds are present in the same process.
  • + ///
  • a single driver if you are targeting a standalone platform.
  • + ///
  • a single if you are targeting WebGL.
  • + ///
    /// These are configured using internal defaults. See: . ///
    /// Used for determining whether we are running in a client or server world. @@ -205,9 +215,12 @@ public static void RegisterClientDriver(World world, ref NetworkDriverStore driv } /// - /// Register a NetworkDriver instance in and stores it in :
    - /// - a single NetworkDriver if the both client and server worlds are present in the same process.
    - /// - a single driver in all other cases.
    + /// Register a NetworkDriver instance in the that uses either: + /// + ///
  • a single NetworkDriver if the both client and server worlds are present in the same process.
  • + ///
  • a single driver if you are targeting a standalone platform.
  • + ///
  • a single if you are targeting WebGL.
  • + ///
    /// These are configured using the NetworkSettings passed in. ///
    /// Used for determining whether we are running in a client or server world. @@ -216,20 +229,21 @@ public static void RegisterClientDriver(World world, ref NetworkDriverStore driv /// A list of the parameters that describe the network configuration. public static void RegisterClientDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings) { -#if !UNITY_CLIENT - if (UseSocketDriver(netDebug)) + if (ClientUseSocketDriver(netDebug)) { +#if !UNITY_WEBGL RegisterClientUdpDriver(world, ref driverStore, netDebug, settings); +#else + RegisterClientWebSocketDriver(world, ref driverStore, netDebug, settings); +#endif } else { RegisterClientIpcDriver(world, ref driverStore, netDebug, settings); } -#else - RegisterClientUdpDriver(world, ref driverStore, netDebug, settings); -#endif } +#if !UNITY_WEBGL || UNITY_EDITOR /// /// Register a NetworkDriver instance in . /// This are configured using the NetworkSettings passed in. @@ -242,11 +256,51 @@ public static void RegisterClientUdpDriver(World world, ref NetworkDriverStore d { Assert.IsTrue(ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.Server); Assert.IsTrue(world.IsClient()); - netDebug.DebugLog("Create client default socket network interface driver"); + netDebug.DebugLog("Create client default udp socket network interface driver"); var driverInstance = DefaultDriverBuilder.CreateClientNetworkDriver(new UDPNetworkInterface(), settings); driverStore.RegisterDriver(TransportType.Socket, driverInstance); } - +#endif + /// + /// Register a NetworkDriver instance in . + /// This are configured using the NetworkSettings passed in. The constructed driver + /// does not use a reliable pipeline stage (websocket are already reliable) and the + /// instance is a . + /// + /// Used for determining whether we are running in a client or server world. + /// Store for NetworkDriver. + /// For handling logging. + /// A list of the parameters that describe the network configuration. + public static void RegisterClientWebSocketDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, + NetworkSettings settings) + { + Assert.IsTrue(ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.Server); + Assert.IsTrue(world.IsClient()); + var driverInstance = new NetworkDriverStore.NetworkDriverInstance(); +#if UNITY_EDITOR || NETCODE_DEBUG + if (NetworkSimulatorSettings.Enabled) + { + driverInstance.simulatorEnabled = true; + driverInstance.driver = NetworkDriver.Create(new WebSocketNetworkInterface(), settings); + //Web socket does not require reliable pipeline, nor technically the fragmented stage but we keep that one + //for compatibility reason. + driverInstance.unreliablePipeline = driverInstance.driver.CreatePipeline(typeof(SimulatorPipelineStage)); + driverInstance.reliablePipeline = driverInstance.driver.CreatePipeline(typeof(SimulatorPipelineStage)); + driverInstance.unreliableFragmentedPipeline = driverInstance.driver.CreatePipeline(typeof(FragmentationPipelineStage), typeof(SimulatorPipelineStage)); + } + else +#endif + { + driverInstance.simulatorEnabled = false; + driverInstance.driver = NetworkDriver.Create(new WebSocketNetworkInterface(), settings); + //Web socket does not require reliable pipeline, nor technically the fragmented stage but we keep that one + //for compatibility reason. + driverInstance.unreliablePipeline = driverInstance.driver.CreatePipeline(typeof(NullPipelineStage)); + driverInstance.reliablePipeline = driverInstance.driver.CreatePipeline(typeof(NullPipelineStage)); + driverInstance.unreliableFragmentedPipeline = driverInstance.driver.CreatePipeline(typeof(FragmentationPipelineStage)); + } + driverStore.RegisterDriver(TransportType.Socket, driverInstance); + } /// /// Register an NetworkDriver instance in . /// This are configured using the NetworkSettings passed in. @@ -264,10 +318,14 @@ public static void RegisterClientIpcDriver(World world, ref NetworkDriverStore d driverStore.RegisterDriver(TransportType.IPC, driverInstance); } +#if !UNITY_WEBGL || UNITY_EDITOR /// - /// Register a NetworkDriver instance in :
    - /// both and NetworkDriver in the editor and only - /// a single driver in the build.
    + /// Register multiple NetworkDriver instances to the that uses different : + /// + ///
  • One driver that uses if the is Client/Server.
  • + ///
  • One driver that uses if the current build target is a standalone platorm (no WebGL) or dedicated server.
  • + ///
  • One driver that uses if the current build target is WebGL.
  • + ///
    /// These are configured using internal defaults. See: . ///
    /// Used for determining whether we are running in a client or server world. @@ -280,19 +338,27 @@ public static void RegisterServerDriver(World world, ref NetworkDriverStore driv } /// - /// Register a NetworkDriver instance in :
    - /// both and NetworkDriver in the editor and only - /// a single driver in the build.
    - /// These are configured using the NetworkSettings passed in. + /// Register a multiple NetworkDriver instances to hte :
    + /// + ///
  • One driver that uses if the is Client/Server.
  • + ///
  • One driver that uses if the current build target is a standalone platorm (no WebGL) or dedicated server.
  • + ///
  • One driver that uses if the current build target is WebGL.
  • + ///
    + /// These drivers are configured using the NetworkSettings passed in. ///
    /// Used for determining whether we are running in a client or server world. /// Store for NetworkDriver. /// For handling logging. /// A list of the parameters that describe the network configuration. + /// Not available for WebGL builds. Always available in the Editor. public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings) { RegisterServerIpcDriver(world, ref driverStore, netDebug, settings); +#if !UNITY_WEBGL RegisterServerUdpDriver(world, ref driverStore, netDebug, settings); +#else + RegisterServerWebSocketDriver(world, ref driverStore, netDebug, settings); +#endif } /// @@ -306,9 +372,9 @@ public static void RegisterServerDriver(World world, ref NetworkDriverStore driv /// Store for NetworkDriver. /// For handling logging. /// A list of the parameters that describe the network configuration. + /// Not available for WebGL builds. Always available in the Editor. public static void RegisterServerIpcDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings) { -#if UNITY_EDITOR || !UNITY_SERVER Assert.IsTrue(world.IsServer()); if (ClientServerBootstrap.RequestedPlayType == ClientServerBootstrap.PlayType.Server) { @@ -318,7 +384,6 @@ public static void RegisterServerIpcDriver(World world, ref NetworkDriverStore d netDebug.DebugLog("Create server default IPC network interface driver"); var ipcDriver = CreateServerNetworkDriver(new IPCNetworkInterface(), settings); driverStore.RegisterDriver(TransportType.IPC, ipcDriver); -#endif } /// @@ -329,6 +394,7 @@ public static void RegisterServerIpcDriver(World world, ref NetworkDriverStore d /// Store for NetworkDriver. /// For handling logging. /// A list of the parameters that describe the network configuration. + /// Not available for WebGL builds. Always available in the Editor. public static void RegisterServerUdpDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings) { Assert.IsTrue(world.IsServer()); @@ -337,6 +403,36 @@ public static void RegisterServerUdpDriver(World world, ref NetworkDriverStore d driverStore.RegisterDriver(TransportType.Socket, socketDriver); } + /// + /// Register a NetworkDriver instance in . + /// This are configured using the NetworkSettings passed in. The constructed driver + /// does not use a reliable pipeline stage (websocket are already reliable) and the + /// instance is a . + /// + /// Used for determining whether we are running in a client or server world. + /// Store for NetworkDriver. + /// For handling logging. + /// A list of the parameters that describe the network configuration. + /// Not available for WebGL build. Always available in the Editor. + public static void RegisterServerWebSocketDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, + NetworkSettings settings) + { + Assert.IsTrue(ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.Client); + Assert.IsTrue(world.IsServer()); + netDebug.DebugLog("Create server websocket network interface driver"); + var driverInstance = new NetworkDriverStore.NetworkDriverInstance + { + driver = NetworkDriver.Create(new WebSocketNetworkInterface(), settings) + }; + //Web socket does not require reliable pipeline, nor technically the fragmented stage but we keep that one + //for compatibility reason. + driverInstance.unreliablePipeline = driverInstance.driver.CreatePipeline(typeof(NullPipelineStage)); + driverInstance.reliablePipeline = driverInstance.driver.CreatePipeline(typeof(NullPipelineStage)); + driverInstance.unreliableFragmentedPipeline = driverInstance.driver.CreatePipeline(typeof(FragmentationPipelineStage)); + driverStore.RegisterDriver(TransportType.Socket, driverInstance); + } +#endif + /// /// Create the default network pipelines (reliable, unreliable, unreliable fragmented) for the client. /// @@ -396,10 +492,14 @@ public static void RegisterClientDriver(World world, ref NetworkDriverStore driv RegisterClientDriver(world, ref driverStore, netDebug, settings); } +#if !UNITY_WEBGL || UNITY_EDITOR /// - /// Register a NetworkDriver instance in :
    - /// both and NetworkDriver in the editor and only - /// a single driver in the build.
    + /// Register a multiple NetworkDriver instances to hte :
    + /// + ///
  • One driver that uses if the is Client/Server.
  • + ///
  • For all targets apart WebGL, one driver instance using a . For WebGL and in the Editor, one driver instance using the + ///
  • . + ///
    /// These are configured using the default settings. See . ///
    /// Used for determining whether we are running in a client or server world. @@ -408,12 +508,14 @@ public static void RegisterClientDriver(World world, ref NetworkDriverStore driv /// /// /// Amount of players the server should allocate receive and send queue for. The estimation is that each player will receive 4 packets. + /// Not available for WebGL builds. Always available in the Editor. public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref FixedString4096Bytes certificate, ref FixedString4096Bytes privateKey, int playerCount = 0) { var settings = GetNetworkServerSettings(playerCount: playerCount); settings = settings.WithSecureServerParameters(certificate: ref certificate, privateKey: ref privateKey); RegisterServerDriver(world, ref driverStore, netDebug, settings); } +#endif #endif /// /// Register a NetworkDriver instance in and stores it in :
    @@ -428,26 +530,29 @@ public static void RegisterServerDriver(World world, ref NetworkDriverStore driv public static void RegisterClientDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref RelayServerData relayData) { var settings = GetNetworkSettings(); -#if !UNITY_CLIENT - if (UseSocketDriver(netDebug)) -#endif + if (ClientUseSocketDriver(netDebug)) { settings = settings.WithRelayParameters(ref relayData); } RegisterClientDriver(world, ref driverStore, netDebug, settings); } +#if UNITY_EDITOR || !UNITY_WEBGL /// - /// Register a NetworkDriver instance in :
    - /// both and NetworkDriver in the editor and only - /// a single driver in the build.
    - /// These are configured using the default settings. See . + /// Register multiple NetworkDriver instances to the that uses different : + /// + ///
  • One driver that uses if the is Client/Server.
  • + ///
  • One driver that uses if the current build target is a standalone platorm (no WebGL) or dedicated server.
  • + ///
  • One driver that uses if the current build target is WebGL.
  • + ///
    + /// These are configured using internal defaults. See: . ///
    /// Used for determining whether we are running in a client or server world. /// Store for NetworkDriver. /// For handling logging. /// Server information to make a connection using a relay server. /// Amount of players the server should allocate receive and send queue for. The estimation is that each player will receive 4 packets. + /// Not available for WebGL builds. Always available in the Editor. public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref RelayServerData relayData, int playerCount = 0) { var settings = GetNetworkServerSettings(playerCount: playerCount); @@ -455,26 +560,28 @@ public static void RegisterServerDriver(World world, ref NetworkDriverStore driv settings = settings.WithRelayParameters(ref relayData); RegisterServerUdpDriver(world, ref driverStore, netDebug, settings); } +#endif } /// - /// The default NetCode driver constructor. It creates: + /// The default NetCode driver constructor, initialise the server world to use multiple and the client world using + /// a single , depending on the current and current platform. + /// In particular: /// - On the server: both and NetworkDriver in the editor and only /// a single driver in the build.
    /// - On the client:
    /// - a single NetworkDriver if the both client and server worlds are present in the same process.
    /// - a single driver in all other cases.
    /// In the Editor and Development build, if the network simulator is enabled, force on the client to use the network driver. - /// - /// To let the client use the IPC network interface In ClientServer mode it is mandatory to always create the server world first. - /// ///
    + /// To let the client use the IPC network interface In ClientServer mode it is mandatory to always create the server world first.
    public struct IPCAndSocketDriverConstructor : INetworkStreamDriverConstructor { /// /// Create and register a new suitable for connecting client to server to the destination . /// The network driver instance will use socket or IPC network interfaces based on the and the - /// presence of a server instance in the same process. + /// presence of a server instance in the same process.
    + /// For WebGL builds, client use by default the . ///
    /// The destination world in which the driver will be created /// An instance of a where the driver will be registered @@ -484,9 +591,11 @@ public void CreateClientDriver(World world, ref NetworkDriverStore driverStore, DefaultDriverBuilder.RegisterClientDriver(world, ref driverStore, netDebug); } + /// /// Create and register one or more network drivers that can be used to listen for incoming connection into the destination . - /// By default, a that uses a socket network interface is always created. + /// By default, a that uses a socket network interface is always created. For WebGL builds in particular, + /// the server use the for communicating with the clients.
    /// In the Editor or in a Client/Server player build, if the mode is set to /// , a second that use an IPC network interface will be also created and /// that will be used for minimizing the latency for the in-proc client connection. @@ -496,7 +605,13 @@ public void CreateClientDriver(World world, ref NetworkDriverStore driverStore, /// The singleton, for logging errors and debug information public void CreateServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug) { +#if UNITY_EDITOR || !UNITY_WEBGL DefaultDriverBuilder.RegisterServerDriver(world, ref driverStore, netDebug); +#else + throw new NotSupportedException( + "Creating a server driver for a WebGL build is not supported. You can't listen on a WebSocket in the browser." + + " WebGL builds should be ideally client-only (has UNITY_CLIENT define) and in case a Client/Server build is made, only client worlds should be created."); +#endif } } } diff --git a/Runtime/Connection/NetworkId.cs b/Runtime/Connection/NetworkId.cs index 648b854..1bfe7d6 100644 --- a/Runtime/Connection/NetworkId.cs +++ b/Runtime/Connection/NetworkId.cs @@ -40,6 +40,7 @@ internal struct RpcSetNetworkId : IComponentData, IRpcCommandSerializer public static Color GetColor(int networkId) { var color4 = Get(networkId); return new Color(color4.x, color4.y, color4.z); } -#endif } } diff --git a/Runtime/Connection/NetworkSimulatorSettings.cs b/Runtime/Connection/NetworkSimulatorSettings.cs index 8faba92..362f085 100644 --- a/Runtime/Connection/NetworkSimulatorSettings.cs +++ b/Runtime/Connection/NetworkSimulatorSettings.cs @@ -1,4 +1,4 @@ -#if UNITY_EDITOR || NETCODE_DEBUG +#if UNITY_EDITOR || NETCODE_DEBUG using System; using System.IO; using Unity.Networking.Transport; @@ -18,21 +18,16 @@ public static class NetworkSimulatorSettings public static bool Enabled => MultiplayerPlayModePreferences.SimulatorEnabled; /// Values to use in simulation. Set values via 'Multiplayer PlayTools Window'. public static SimulatorUtility.Parameters ClientSimulatorParameters => MultiplayerPlayModePreferences.ClientSimulatorParameters; -#elif !UNITY_DOTSRUNTIME +#else /// Are the UTP Network Simulator stages in use? Toggleable in development build. public static bool Enabled { get; private set; } /// Values to use in simulation. Set this to whatever you'd like in a development build. public static SimulatorUtility.Parameters ClientSimulatorParameters { get; private set; } -#else - /// Are the UTP Network Simulator stages in use? No, as always off for production and DOTSRuntime builds. - public static bool Enabled => false; - /// Ignore as in production build. Off. - public static SimulatorUtility.Parameters ClientSimulatorParameters => default; #endif static NetworkSimulatorSettings() { -#if !UNITY_EDITOR && !UNITY_DOTSRUNTIME +#if !UNITY_EDITOR CheckCommandLineArgs(); #endif } @@ -44,7 +39,7 @@ static NetworkSimulatorSettings() FuzzFactor = 0, PacketDelayMs = 100, PacketJitterMs = 10, PacketDropPercentage = 1, PacketDuplicationPercentage = 1 }; -#if !UNITY_EDITOR && !UNITY_DOTSRUNTIME +#if !UNITY_EDITOR /// /// Checks for the existence of `--loadNetworkSimulatorJsonFile`, which, if set, will set to true, and write . /// If no file is found, logs an error, and defaults to . Use `--createNetworkSimulatorJsonFile` to automatically generate the file instead. diff --git a/Runtime/Connection/NetworkStreamConnectionComponent.cs b/Runtime/Connection/NetworkStreamConnectionComponent.cs index 7ea040a..c30c652 100644 --- a/Runtime/Connection/NetworkStreamConnectionComponent.cs +++ b/Runtime/Connection/NetworkStreamConnectionComponent.cs @@ -91,6 +91,10 @@ public enum NetworkStreamDisconnectReason BadProtocolVersion, /// NetCode-specific: Denotes that we've detected a hash miss-match in an RPC, or an unknown RPC. Implies that this is an incompatible server/client pair. InvalidRpc, + /// + AuthenticationFailure, + /// + ProtocolError, } /// diff --git a/Runtime/Connection/NetworkStreamReceiveSystem.cs b/Runtime/Connection/NetworkStreamReceiveSystem.cs index 33ca6f9..365ab19 100644 --- a/Runtime/Connection/NetworkStreamReceiveSystem.cs +++ b/Runtime/Connection/NetworkStreamReceiveSystem.cs @@ -265,7 +265,6 @@ internal enum DriverState ComponentLookup m_ConnectionStateFromEntity; ComponentLookup m_GhostComponentFromEntity; ComponentLookup m_NetworkIdFromEntity; - ComponentLookup m_ClientServerTickRateFromEntity; ComponentLookup m_RequestDisconnectFromEntity; ComponentLookup m_InGameFromEntity; BufferLookup m_OutgoingRpcBufferFromEntity; @@ -289,7 +288,6 @@ public void OnCreate(ref SystemState state) m_ConnectionStateFromEntity = state.GetComponentLookup(false); m_GhostComponentFromEntity = state.GetComponentLookup(true); m_NetworkIdFromEntity = state.GetComponentLookup(true); - m_ClientServerTickRateFromEntity = state.GetComponentLookup(); m_RequestDisconnectFromEntity = state.GetComponentLookup(); m_InGameFromEntity = state.GetComponentLookup(); @@ -410,9 +408,7 @@ public void OnUpdate(ref SystemState state) if (driverListening) { m_GhostComponentFromEntity.Update(ref state); - // Schedule accept job - SystemAPI.TryGetSingleton(out var tickRate); var acceptJob = new ConnectionAcceptJob { driverStore = DriverStore, @@ -421,39 +417,32 @@ public void OnUpdate(ref SystemState state) freeNetworkIds = m_FreeNetworkIds, rpcQueue = m_RpcQueue, ghostFromEntity = m_GhostComponentFromEntity, - tickRate = tickRate, protocolVersion = SystemAPI.GetSingleton(), netDebug = netDebug, debugPrefix = debugPrefix }; - acceptJob.tickRate.ResolveDefaults(); + SystemAPI.TryGetSingleton(out var tickRate); + tickRate.ResolveDefaults(); + acceptJob.tickRate = tickRate; k_Scheduling.Begin(); state.Dependency = acceptJob.Schedule(state.Dependency); k_Scheduling.End(); } else { - if (!state.WorldUnmanaged.IsServer() && !SystemAPI.HasSingleton()) - { - var newEntity = state.EntityManager.CreateEntity(); - var tickRate = new ClientServerTickRate(); - tickRate.ResolveDefaults(); - state.EntityManager.AddComponentData(newEntity, tickRate); - } if (!m_RefreshTickRateQuery.IsEmptyIgnoreFilter) { - m_ClientServerTickRateFromEntity.Update(ref state); - var refreshJob = new RefreshClientServerTickRate + if (!SystemAPI.TryGetSingleton(out var tickRate)) + state.EntityManager.CreateSingleton(tickRate); + tickRate.ResolveDefaults(); + var requests = m_RefreshTickRateQuery.ToComponentDataArray(Allocator.Temp); + foreach (var req in requests) { - commandBuffer = commandBuffer, - netDebug = netDebug, - debugPrefix = debugPrefix, - tickRateEntity = SystemAPI.GetSingletonEntity(), - dataFromEntity = m_ClientServerTickRateFromEntity - }; - k_Scheduling.Begin(); - state.Dependency = refreshJob.ScheduleByRef(state.Dependency); - k_Scheduling.End(); + req.ApplyTo(ref tickRate); + netDebug.DebugLog($"Using {debugPrefix} SimulationTickRate={tickRate.SimulationTickRate} NetworkTickRate={tickRate.NetworkTickRate} MaxSimulationStepsPerFrame={tickRate.MaxSimulationStepsPerFrame} TargetFrameRateMode={tickRate.TargetFrameRateMode} PredictedPhysicsPerTick={tickRate.PredictedFixedStepSimulationTickRatio}"); + } + SystemAPI.SetSingleton(tickRate); + state.EntityManager.DestroyEntity(m_RefreshTickRateQuery); } m_FreeNetworkIds.Clear(); } @@ -567,7 +556,8 @@ public void Execute() netTickRate = tickRate.NetworkTickRate, simMaxSteps = tickRate.MaxSimulationStepsPerFrame, simMaxStepLength = tickRate.MaxSimulationStepBatchSize, - simTickRate = tickRate.SimulationTickRate + simTickRate = tickRate.SimulationTickRate, + fixStepTickRatio = (int)tickRate.PredictedFixedStepSimulationTickRatio }); netDebug.DebugLog(FixedString.Format("{0} Accepted new connection {1} NetworkId={2}", debugPrefix, connection.Value.ToFixedString(), nid)); } @@ -575,29 +565,6 @@ public void Execute() } } - [BurstCompile] - [StructLayout(LayoutKind.Sequential)] - partial struct RefreshClientServerTickRate : IJobEntity - { - public EntityCommandBuffer commandBuffer; - public NetDebug netDebug; - public FixedString128Bytes debugPrefix; - public Entity tickRateEntity; - public ComponentLookup dataFromEntity; - public void Execute(Entity entity, in ClientServerTickRateRefreshRequest req) - { - var tickRate = dataFromEntity[tickRateEntity]; - tickRate.MaxSimulationStepsPerFrame = req.MaxSimulationStepsPerFrame; - tickRate.NetworkTickRate = req.NetworkTickRate; - tickRate.SimulationTickRate = req.SimulationTickRate; - tickRate.MaxSimulationStepBatchSize = req.MaxSimulationStepBatchSize; - dataFromEntity[tickRateEntity] = tickRate; - var dbgMsg = FixedString.Format("Using SimulationTickRate={0} NetworkTickRate={1} MaxSimulationStepsPerFrame={2} TargetFrameRateMode={3}", tickRate.SimulationTickRate, tickRate.NetworkTickRate, tickRate.MaxSimulationStepsPerFrame, (int)tickRate.TargetFrameRateMode); - netDebug.DebugLog(FixedString.Format("{0} {1}", debugPrefix, dbgMsg)); - commandBuffer.RemoveComponent(entity); - } - } - [BurstCompile] partial struct HandleDriverEvents : IJobEntity { diff --git a/Runtime/Connection/NetworkTimeSystem.cs b/Runtime/Connection/NetworkTimeSystem.cs index 7c36f60..40a34e8 100644 --- a/Runtime/Connection/NetworkTimeSystem.cs +++ b/Runtime/Connection/NetworkTimeSystem.cs @@ -182,12 +182,8 @@ internal void UpdateWithLastSnapshot(uint currentTimeTs, NetworkTick snapshotTic /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation|WorldSystemFilterFlags.ThinClientSimulation)] -#if UNITY_DOTSRUNTIME - [UpdateInGroup(typeof(InitializationSystemGroup), OrderLast=true)] // FIXME: cannot get access to the dots runtime version of UpdateWorldTimeSystem here, so just put it last -#else [UpdateInGroup(typeof(InitializationSystemGroup))] [UpdateAfter(typeof(UpdateWorldTimeSystem))] -#endif public partial struct NetworkTimeSystem : ISystem, ISystemStartStop { /// diff --git a/Runtime/Connection/WarnAboutApplicationRunInBackground.cs b/Runtime/Connection/WarnAboutApplicationRunInBackground.cs new file mode 100644 index 0000000..3cbb18a --- /dev/null +++ b/Runtime/Connection/WarnAboutApplicationRunInBackground.cs @@ -0,0 +1,58 @@ +#if UNITY_EDITOR && !NETCODE_NDEBUG +#define NETCODE_DEBUG +#endif + +#if NETCODE_DEBUG +using Unity.Entities; + +namespace Unity.NetCode +{ + /// > + [RequireMatchingQueriesForUpdate] + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + public partial struct WarnAboutApplicationRunInBackground : ISystem, ISystemStartStop + { + /// + /// Require user to be connected to show this warning. + /// + /// + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + } + + /// + /// Handle raising the warning. + /// + /// + public void OnUpdate(ref SystemState state) + { + ref var netDebug = ref SystemAPI.GetSingletonRW().ValueRW; + if (netDebug.SuppressApplicationRunInBackgroundWarning || netDebug.HasWarnedAboutApplicationRunInBackground) + return; + + // @FIXME: Singleplayer via two world support needs to suppress this. + if (!UnityEngine.Application.runInBackground) + { + netDebug.HasWarnedAboutApplicationRunInBackground = true; + UnityEngine.Debug.LogError($"[{state.WorldUnmanaged.Name}] Netcode detected that you don't have Application.runInBackground enabled during multiplayer gameplay. This will lead to your multiplayer stalling (and disconnecting) if and when the application loses focus (e.g. by the player tabbing out). It is highly recommended to enable \"Run in Background\" via `Application.runInBackground = true;` when connecting, or project-wide via 'Project Settings > Resolution & Presentation > Run in Background'.\nSuppress this advice log via `NetDebug.SuppressApplicationRunInBackgroundWarning`."); + } + } + + /// Reset the warning as we've disconnected. + /// + public void OnStartRunning(ref SystemState state) + { + ref var netDebug = ref SystemAPI.GetSingletonRW().ValueRW; + netDebug.HasWarnedAboutApplicationRunInBackground = false; + } + + /// Does nothing. + /// + public void OnStopRunning(ref SystemState state) + { + } + } +} +#endif diff --git a/Runtime/Connection/WarnAboutApplicationRunInBackground.cs.meta b/Runtime/Connection/WarnAboutApplicationRunInBackground.cs.meta new file mode 100644 index 0000000..37a6bef --- /dev/null +++ b/Runtime/Connection/WarnAboutApplicationRunInBackground.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 98fcf1a6e5734771967347209a17c4c9 +timeCreated: 1688648218 \ No newline at end of file diff --git a/Runtime/Debug/DebugGhostDrawer.cs b/Runtime/Debug/DebugGhostDrawer.cs index e4c779b..cd99dd1 100644 --- a/Runtime/Debug/DebugGhostDrawer.cs +++ b/Runtime/Debug/DebugGhostDrawer.cs @@ -1,9 +1,7 @@ -#if (UNITY_EDITOR || NETCODE_DEBUG) && !UNITY_DOTSRUNTIME +#if (UNITY_EDITOR || NETCODE_DEBUG) using System; using System.Collections.Generic; using JetBrains.Annotations; -using Unity.Entities; -using Unity.Profiling; namespace Unity.NetCode { @@ -17,11 +15,8 @@ public class DebugGhostDrawer static DebugGhostDrawer s_Instance; public static List CustomDrawers = new List(2); - public static World FirstServerWorld; - public static World FirstClientWorld; static ulong s_LastNextSequenceNumber; - static ProfilerMarker s_RefreshWorldCachesMarker = new ProfilerMarker(nameof(s_RefreshWorldCachesMarker)); /// /// Replaces the existing DrawAction with the same name, if it already exists. @@ -35,40 +30,7 @@ public static void RegisterDrawAction(CustomDrawer newDrawAction) CustomDrawers.Sort(); } - public static void RefreshWorldCaches() - { - using (s_RefreshWorldCachesMarker.Auto()) - { - if (FirstServerWorld != null && !FirstServerWorld.IsCreated) FirstServerWorld = default; - if (FirstClientWorld != null && !FirstClientWorld.IsCreated) FirstClientWorld = default; - - if (World.NextSequenceNumber != s_LastNextSequenceNumber) - { - if (!HasRequiredWorlds) - { - for (var i = 0; i < World.All.Count && !HasRequiredWorlds; i++) - { - var world = World.All[i]; - if (FirstServerWorld == default && world.IsServer()) - { - FirstServerWorld = world; - continue; - } - - if (FirstClientWorld == default) - { - if (world.IsClient()) - FirstClientWorld = world; - } - } - - s_LastNextSequenceNumber = World.NextSequenceNumber; - } - } - } - } - - public static bool HasRequiredWorlds => FirstServerWorld != default && FirstClientWorld != default; + public static bool HasRequiredWorlds => ClientServerBootstrap.ServerWorld != default && ClientServerBootstrap.ClientWorld != default; /// > public class CustomDrawer : IComparer, IComparable diff --git a/Runtime/Debug/NetDebug.cs b/Runtime/Debug/NetDebug.cs index b706db2..99b489f 100644 --- a/Runtime/Debug/NetDebug.cs +++ b/Runtime/Debug/NetDebug.cs @@ -84,8 +84,6 @@ private static void _wrapper_GetTimestampWithTick(NetworkTick tick, out FixedStr public static void InitDebugPacketIfNotCreated(ref NetDebugPacket m_NetDebugPacket, ref FixedString512Bytes logFolder, ref FixedString128Bytes worldName, int connectionId) { -// TODO: Burst (1.7.3) does not provide a BurstCompiler.IsEnabled for DOTS Runtime. Remove once a newer version adds this property -#if !UNITY_DOTSRUNTIME if (BurstCompiler.IsEnabled) { CheckInteropClassInitialized(_bfp_InitDebugPacketIfNotCreated.Data); @@ -93,15 +91,12 @@ public static void InitDebugPacketIfNotCreated(ref NetDebugPacket m_NetDebugPack fp.Invoke(ref m_NetDebugPacket, ref logFolder, ref worldName, connectionId); return; } -#endif _InitDebugPacketIfNotCreated(ref m_NetDebugPacket, ref logFolder, ref worldName, connectionId); } public static void GetTimestamp(out FixedString32Bytes timestamp) { -// TODO: Burst (1.7.3) does not provide a BurstCompiler.IsEnabled for DOTS Runtime. Remove once a newer version adds this property -#if !UNITY_DOTSRUNTIME if (BurstCompiler.IsEnabled) { CheckInteropClassInitialized(_bfp_GetTimestamp.Data); @@ -109,15 +104,12 @@ public static void GetTimestamp(out FixedString32Bytes timestamp) fp.Invoke(out timestamp); return; } -#endif _GetTimestamp(out timestamp); } public static void GetTimestampWithTick(NetworkTick serverTick, out FixedString128Bytes timestampWithTick) { -// TODO: Burst (1.7.3) does not provide a BurstCompiler.IsEnabled for DOTS Runtime. Remove once a newer version adds this property -#if !UNITY_DOTSRUNTIME if (BurstCompiler.IsEnabled) { CheckInteropClassInitialized(_bfp_GetTimestampWithTick.Data); @@ -125,7 +117,6 @@ public static void GetTimestampWithTick(NetworkTick serverTick, out FixedString1 fp.Invoke(serverTick, out timestampWithTick); return; } -#endif _GetTimestampWithTick(serverTick, out timestampWithTick); } @@ -201,6 +192,8 @@ public void Init(ref FixedString512Bytes logFolder, ref FixedString128Bytes worl m_NetDebugPacketLoggerHandle = new LoggerConfig() .OutputTemplate("{Message}") .MinimumLevel.Set(LogLevel.Verbose) + .CaptureStacktrace(false) + .RedirectUnityLogs(false) .WriteTo.File(fileName) .CreateLogger(parameters).Handle; } @@ -247,6 +240,8 @@ public struct DisconnectReasonEnumToString private static readonly FixedString32Bytes ClosedByRemote = "ClosedByRemote"; private static readonly FixedString32Bytes BadProtocolVersion = "BadProtocolVersion"; private static readonly FixedString32Bytes InvalidRpc = "InvalidRpc"; + private static readonly FixedString32Bytes AuthenticationFailure = "AuthenticationFailure"; + private static readonly FixedString32Bytes ProtocolError = "ProtocolError"; /// /// Translate the error code into a human friendly error message. @@ -265,6 +260,8 @@ public static FixedString32Bytes Convert(int index) case 3: return ClosedByRemote; case 4: return BadProtocolVersion; case 5: return InvalidRpc; + case 6: return AuthenticationFailure; + case 7: return ProtocolError; } return ""; } @@ -285,13 +282,7 @@ public struct NetDebug : IComponentData /// A string containg the log folder full path public static string LogFolderForPlatform() { -#if UNITY_DOTSRUNTIME - var args = Environment.GetCommandLineArgs(); - var optIndex = System.Array.IndexOf(args, "-logFile"); - if (optIndex >=0 && ++optIndex < (args.Length - 1) && !args[optIndex].StartsWith('-')) - return args[optIndex]; - //FIXME: should return the common application log path (if that exist defined somewhere) -#elif UNITY_ANDROID || UNITY_IOS +#if UNITY_ANDROID || UNITY_IOS var persistentLogPath = UnityEngine.Application.persistentDataPath; if (!string.IsNullOrEmpty(persistentLogPath)) return persistentLogPath; @@ -313,7 +304,6 @@ internal static FixedString512Bytes GetAndCreateLogFolder() return logPath; } - private ushort m_MaxRpcAgeFrames; private LogLevelType m_LogLevel; #if NETCODE_DEBUG @@ -347,15 +337,12 @@ private Logger GetOrCreateLogger() if (logger == null) { - logger = new LoggerConfig().MinimumLevel - .Set(m_CurrentLogLevel) -#if !UNITY_DOTSRUNTIME + logger = new LoggerConfig() + .MinimumLevel.Set(m_CurrentLogLevel) + .CaptureStacktrace(false) + .RedirectUnityLogs(false) //Use correct format that is compatible with current unity logging .WriteTo.UnityDebugLog(minLevel: m_CurrentLogLevel, outputTemplate: new FixedString512Bytes("{Message}")) -#else - .WriteTo.StdOut() - .WriteTo.File($"{NetDebug.GetAndCreateLogFolder()}/Netcode-{Guid.NewGuid()}.txt") -#endif .CreateLogger(); m_LoggerHandle = logger.Handle; } @@ -382,20 +369,28 @@ public void Dispose() m_LoggerHandle = default; } + /// + /// If you disable , users will experience client disconnects + /// when tabbing out of (or otherwise un-focusing) your game application. + /// It is therefore highly recommended to enable "Run in "Background" via ticking `Project Settings... Player... Resolution and Presentation... Run In Background`. + /// + /// + /// Setting to true will allow you to + /// toggle off "Run in Background" without triggering the advice log. + /// + public bool SuppressApplicationRunInBackgroundWarning { get; set; } + + /// Prevents log-spam for . + internal bool HasWarnedAboutApplicationRunInBackground { get; set; } + /// /// A NetCode RPC will trigger a warning if it hasn't been consumed or destroyed (which is a proxy for 'handled') after /// this many simulation frames (inclusive). /// . /// Set to 0 to opt out. /// - public ushort MaxRpcAgeFrames - { - get => m_MaxRpcAgeFrames; - set - { - m_MaxRpcAgeFrames = value; - } - } + public ushort MaxRpcAgeFrames { get; set; } + /// /// The current debug logging level. Default value is . /// diff --git a/Runtime/Debug/NetDebugSystem.cs b/Runtime/Debug/NetDebugSystem.cs index 19be098..82126ee 100644 --- a/Runtime/Debug/NetDebugSystem.cs +++ b/Runtime/Debug/NetDebugSystem.cs @@ -31,22 +31,19 @@ public partial struct NetDebugSystem : ISystem private void CreateNetDebugSingleton(EntityManager entityManager) { - var netDebugEntity = entityManager.CreateEntity(ComponentType.ReadWrite()); - entityManager.SetName(netDebugEntity, "NetDebug"); var netDebug = new NetDebug(); netDebug.Initialize(); - #if UNITY_EDITOR if (MultiplayerPlayModePreferences.ApplyLoggerSettings) netDebug.LogLevel = MultiplayerPlayModePreferences.TargetLogLevel; #endif - #if NETCODE_DEBUG m_ComponentTypeNameLookupData = new NativeParallelHashMap(1024, Allocator.Persistent); netDebug.ComponentTypeNameLookup = m_ComponentTypeNameLookupData.AsReadOnly(); #endif - entityManager.SetComponentData(netDebugEntity, netDebug); + entityManager.CreateSingleton(netDebug); } + public void OnCreate(ref SystemState state) { m_NetDebugQuery = state.GetEntityQuery(ComponentType.ReadWrite()); diff --git a/Runtime/Hybrid/GhostPresentationGameObject.cs b/Runtime/Hybrid/GhostPresentationGameObject.cs index 74021f1..401991f 100644 --- a/Runtime/Hybrid/GhostPresentationGameObject.cs +++ b/Runtime/Hybrid/GhostPresentationGameObject.cs @@ -58,6 +58,7 @@ internal struct GhostPresentationGameObjectState : ICleanupComponentData /// or do all spawning in the BeginSimulationCommandBufferSystem to make sure the game objects are created at the same time /// // This is right after GhostSpawnSystemGroup on a client and right after BeginSimulationEntityCommandBufferSystem on server + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] [RequireMatchingQueriesForUpdate] [UpdateInGroup(typeof(NetworkReceiveSystemGroup), OrderFirst = true)] public partial class GhostPresentationGameObjectSystem : SystemBase @@ -156,13 +157,9 @@ protected override void OnUpdate() /// This system will update the presentation GameObjects transform based on the current transform /// of the entity owning it. /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(TransformSystemGroup))] -#if !ENABLE_TRANSFORM_V1 [UpdateAfter(typeof(LocalToWorldSystem))] -#else - [UpdateAfter(typeof(TRSToLocalToWorldSystem))] -#endif - public partial class GhostPresentationGameObjectTransformSystem : SystemBase { private GhostPresentationGameObjectSystem m_GhostPresentationGameObjectSystem; diff --git a/Runtime/Hybrid/Unity.NetCode.Hybrid.asmdef b/Runtime/Hybrid/Unity.NetCode.Hybrid.asmdef index 00a8638..4ff8ff5 100644 --- a/Runtime/Hybrid/Unity.NetCode.Hybrid.asmdef +++ b/Runtime/Hybrid/Unity.NetCode.Hybrid.asmdef @@ -16,7 +16,6 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [ - "!UNITY_DOTSRUNTIME", "!UNITY_DISABLE_MANAGED_COMPONENTS" ], "versionDefines": [ diff --git a/Runtime/Physics/Hybrid/Unity.NetCode.Physics.Hybrid.asmdef b/Runtime/Physics/Hybrid/Unity.NetCode.Physics.Hybrid.asmdef index 25f0ea5..196f4a7 100644 --- a/Runtime/Physics/Hybrid/Unity.NetCode.Physics.Hybrid.asmdef +++ b/Runtime/Physics/Hybrid/Unity.NetCode.Physics.Hybrid.asmdef @@ -19,8 +19,7 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [ - "ENABLE_UNITY_NETCODE_PHYSICS", - "!UNITY_DOTSRUNTIME" + "ENABLE_UNITY_NETCODE_PHYSICS" ], "versionDefines": [ { diff --git a/Runtime/Physics/PhysicsWorldHistory.cs b/Runtime/Physics/PhysicsWorldHistory.cs index d540cb1..05201a8 100644 --- a/Runtime/Physics/PhysicsWorldHistory.cs +++ b/Runtime/Physics/PhysicsWorldHistory.cs @@ -303,6 +303,7 @@ public void GetCollisionWorldFromTick(NetworkTick tick, uint interpolationDelay, /// This system creates a PhysicsWorldHistorySingleton and from that you can /// get a physics collision world for a previous tick. /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderFirst = true)] [UpdateBefore(typeof(PredictedFixedStepSimulationSystemGroup))] [BurstCompile] diff --git a/Runtime/Physics/PredictedPhysicsSystemGroup.cs b/Runtime/Physics/PredictedPhysicsSystemGroup.cs index f8ab1e3..77892d0 100644 --- a/Runtime/Physics/PredictedPhysicsSystemGroup.cs +++ b/Runtime/Physics/PredictedPhysicsSystemGroup.cs @@ -185,8 +185,23 @@ public void OnUpdate(ref SystemState state) #if NETCODE_DEBUG else if (!m_DidPrintError) { - // If debug, print a warning once telling users what to do - SystemAPI.GetSingleton().LogError("The default physics world on the client contains a dynamic physics object which is not a ghost. This is not supported, in order to have client-only physics you must setup a custom physics world for it."); + // If debug, print a warning once telling users what to do, + // and show them the first problem entity (for easy debugging). + var erredEntities = m_Query.ToEntityArray(Allocator.Temp); + FixedString512Bytes error = $"[{state.WorldUnmanaged.Name}] The default physics world contains {erredEntities.Length} dynamic physics objects which are not ghosts. This is not supported! In order to have client-only physics, you must setup a custom physics world:"; + foreach (var erredEntity in erredEntities) + { + FixedString512Bytes tempFs = "\n- "; + tempFs.Append(erredEntity.ToFixedString()); + tempFs.Append(' '); + state.EntityManager.GetName(erredEntity, out var entityName); + tempFs.Append(entityName); + + var formatError = error.Append(tempFs); + if (formatError == FormatError.Overflow) + break; + } + SystemAPI.GetSingleton().LogError(error); m_DidPrintError = true; state.RequireForUpdate(); } @@ -198,6 +213,7 @@ public void OnUpdate(ref SystemState state) /// /// System to make sure prediction switching smoothing happens after physics motion smoothing and overwrites the results /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(TransformSystemGroup))] [UpdateBefore(typeof(SwitchPredictionSmoothingSystem))] [UpdateAfter(typeof(SmoothRigidBodiesGraphicalMotion))] diff --git a/Runtime/Rpc/RpcSystem.cs b/Runtime/Rpc/RpcSystem.cs index 028fbf5..4e2564f 100644 --- a/Runtime/Rpc/RpcSystem.cs +++ b/Runtime/Rpc/RpcSystem.cs @@ -268,7 +268,7 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo } if (rpcIndex == ushort.MaxValue) - netDebug.DebugLog(FixedString.Format("[{0}] {1} in disconnected state but allowing RPC protocol version message to get processed", worldName, connections[i].Value.ToFixedString())); + netDebug.DebugLog($"[{worldName}] {connections[i].Value.ToFixedString()} in disconnected state but allowing RPC protocol version message to get processed"); else continue; } @@ -302,7 +302,7 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo else if (rpcHash != 0 && !hashToIndex.TryGetValue(rpcHash, out rpcIndex)) { netDebug.LogError( - FixedString.Format("[{0}] RpcSystem received rpc with invalid hash ({1}) from {2}", worldName, rpcHash, connections[i].Value.ToFixedString())); + $"[{worldName}] RpcSystem received rpc with invalid hash ({rpcHash}) from {connections[i].Value.ToFixedString()}"); commandBuffer.AddComponent(unfilteredChunkIndex, entities[i], new NetworkStreamRequestDisconnect { Reason = NetworkStreamDisconnectReason.InvalidRpc }); break; @@ -353,7 +353,7 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo { //If this is the server, we must disconnect the connection netDebug.LogError( - FixedString.Format("[{0}] RpcSystem received invalid rpc (index {1} out of range) from {2}", worldName, rpcIndex, connections[i].Value.ToFixedString())); + $"[{worldName}] RpcSystem received invalid rpc (index {rpcIndex} out of range) from {connections[i].Value.ToFixedString()}"); commandBuffer.AddComponent(unfilteredChunkIndex, entities[i], new NetworkStreamRequestDisconnect { Reason = NetworkStreamDisconnectReason.InvalidRpc }); break; @@ -361,7 +361,7 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo else if (connections[i].ProtocolVersionReceived == 0) { netDebug.LogError( - FixedString.Format("[{0}] RpcSystem received illegal rpc as it has not yet received the protocol version ({1})", worldName, connections[i].Value.ToFixedString())); + $"[{worldName}] RpcSystem received illegal rpc as it has not yet received the protocol version ({connections[i].Value.ToFixedString()})"); commandBuffer.AddComponent(unfilteredChunkIndex, entities[i], new NetworkStreamRequestDisconnect { Reason = NetworkStreamDisconnectReason.InvalidRpc }); break; @@ -378,77 +378,76 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo var ack = acks[i]; while (sendBuffer.Length > 0) { - if (driver.BeginSend(concurrentDriver.reliablePipeline, connections[i].Value, out var tmp) == 0) + int result; + if ((result = driver.BeginSend(concurrentDriver.reliablePipeline, connections[i].Value, out var tmp)) < 0) { - tmp.WriteByte((byte) NetworkStreamProtocol.Rpc); - tmp.WriteUInt(localTime); - uint returnTime = ack.LastReceivedRemoteTime; - if (returnTime != 0) - returnTime += (localTime - ack.LastReceiveTimestamp); - tmp.WriteUInt(returnTime); - var headerLength = tmp.Length; - - // If sending failed we stop and wait until next frame - if (sendBuffer.Length + headerLength > tmp.Capacity) + netDebug.DebugLog($"[{worldName}] RPCSystem failed to send message. Will retry later, but this could mean too many messages are being sent. Error: {result}!"); + break; + } + tmp.WriteByte((byte) NetworkStreamProtocol.Rpc); + tmp.WriteUInt(localTime); + uint returnTime = ack.LastReceivedRemoteTime; + if (returnTime != 0) + returnTime += (localTime - ack.LastReceiveTimestamp); + tmp.WriteUInt(returnTime); + var headerLength = tmp.Length; + + // If sending failed we stop and wait until next frame + if (sendBuffer.Length + headerLength > tmp.Capacity) + { + var sendArray = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray(sendBuffer.GetUnsafePtr(), sendBuffer.Length, Allocator.Invalid); +#if ENABLE_UNITY_COLLECTIONS_CHECKS + var safety = NativeArrayUnsafeUtility.GetAtomicSafetyHandle(sendBuffer.AsNativeArray()); + NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref sendArray, safety); +#endif + var reader = new DataStreamReader(sendArray); + if (dynamicAssemblyList == 1) + reader.ReadULong(); + else + reader.ReadUShort(); + var len = reader.ReadUShort() + msgHeaderLen; + if (len + headerLength > tmp.Capacity) { - var sendArray = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray(sendBuffer.GetUnsafePtr(), sendBuffer.Length, Allocator.Invalid); - #if ENABLE_UNITY_COLLECTIONS_CHECKS - var safety = NativeArrayUnsafeUtility.GetAtomicSafetyHandle(sendBuffer.AsNativeArray()); - NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref sendArray, safety); - #endif - var reader = new DataStreamReader(sendArray); + sendBuffer.Clear(); + // Could not fit a single message in the packet, this is a serious error + throw new InvalidOperationException($"[{worldName}] An RPC was too big to be sent, reduce the size of your RPCs"); + } + tmp.WriteBytesUnsafe((byte*) sendBuffer.GetUnsafePtr(), len); + // Try to fit a few more messages in this packet + while (true) + { + var curTmpDataLength = tmp.Length - headerLength; + var subArray = sendArray.GetSubArray(curTmpDataLength, sendArray.Length - curTmpDataLength); + reader = new DataStreamReader(subArray); if (dynamicAssemblyList == 1) reader.ReadULong(); else reader.ReadUShort(); - var len = reader.ReadUShort() + msgHeaderLen; - if (len + headerLength > tmp.Capacity) - { - sendBuffer.Clear(); - // Could not fit a single message in the packet, this is a serious error - throw new InvalidOperationException("An RPC was too big to be sent, reduce the size of your RPCs"); - } - tmp.WriteBytesUnsafe((byte*) sendBuffer.GetUnsafePtr(), len); - // Try to fit a few more messages in this packet - while (true) - { - var curTmpDataLength = tmp.Length - headerLength; - var subArray = sendArray.GetSubArray(curTmpDataLength, sendArray.Length - curTmpDataLength); - reader = new DataStreamReader(subArray); - if (dynamicAssemblyList == 1) - reader.ReadULong(); - else - reader.ReadUShort(); - len = reader.ReadUShort() + msgHeaderLen; - if (tmp.Length + len > tmp.Capacity) - break; - tmp.WriteBytesUnsafe((byte*) subArray.GetUnsafeReadOnlyPtr(), len); - } - } - else - tmp.WriteBytesUnsafe((byte*) sendBuffer.GetUnsafePtr(), sendBuffer.Length); - // If sending failed we stop and wait until next frame - var result = 0; - if ((result = driver.EndSend(tmp)) <= 0) - { - netDebug.LogWarning(FixedString.Format("An error occured during EndSend. ErrorCode: {0}", result)); - break; + len = reader.ReadUShort() + msgHeaderLen; + if (tmp.Length + len > tmp.Capacity) + break; + tmp.WriteBytesUnsafe((byte*) subArray.GetUnsafeReadOnlyPtr(), len); } - var tmpDataLength = tmp.Length - headerLength; - if (tmpDataLength < sendBuffer.Length) - { - // Compact the buffer, removing the rpcs we did send - for (int cpy = tmpDataLength; cpy < sendBuffer.Length; ++cpy) - sendBuffer[cpy - tmpDataLength] = sendBuffer[cpy]; - sendBuffer.ResizeUninitialized(sendBuffer.Length - tmpDataLength); - } - else - sendBuffer.Clear(); } else + tmp.WriteBytesUnsafe((byte*) sendBuffer.GetUnsafePtr(), sendBuffer.Length); + + // If sending failed we stop and wait until next frame + if ((result = driver.EndSend(tmp)) <= 0) + { + netDebug.LogWarning($"[{worldName}] An error occured during RpcSystem EndSend. ErrorCode: {result}!"); + break; + } + var tmpDataLength = tmp.Length - headerLength; + if (tmpDataLength < sendBuffer.Length) { - netDebug.DebugLog(FixedString.Format("{0} RPCSystem failed to send message. Will retry later, but this could mean too many messages are being sent.", worldName)); + // Compact the buffer, removing the rpcs we did send + for (int cpy = tmpDataLength; cpy < sendBuffer.Length; ++cpy) + sendBuffer[cpy - tmpDataLength] = sendBuffer[cpy]; + sendBuffer.ResizeUninitialized(sendBuffer.Length - tmpDataLength); } + else + sendBuffer.Clear(); } } } diff --git a/Runtime/Simulator/MultiplayerPlayModePreferences.cs b/Runtime/Simulator/MultiplayerPlayModePreferences.cs index 23a7649..860ee2a 100644 --- a/Runtime/Simulator/MultiplayerPlayModePreferences.cs +++ b/Runtime/Simulator/MultiplayerPlayModePreferences.cs @@ -6,11 +6,17 @@ using UnityEditor; using UnityEngine; +#if UNITY_USE_MULTIPLAYER_ROLES +using Unity.Multiplayer; +using Unity.Multiplayer.Editor; +#endif + namespace Unity.NetCode { /// Developer preferences for the `MultiplayerPlayModeWindow`. Only applicable in editor. public static class MultiplayerPlayModePreferences { + public const bool DefaultSimulatorEnabled = true; public const SimulatorView DefaultSimulatorView = SimulatorView.PingView; const int k_MaxPacketDelayMs = 2000; @@ -20,12 +26,14 @@ public static class MultiplayerPlayModePreferences static string s_PrefsKeyPrefix = $"MultiplayerPlayMode_{Application.productName}_"; static string s_PlayModeTypeKey = s_PrefsKeyPrefix + "PlayMode_Type"; + static string s_SimulatorEnabledKey = s_PrefsKeyPrefix + "SimulatorEnabled"; static string s_RequestedSimulatorViewKey = s_PrefsKeyPrefix + "SimulatorView"; static string s_SimulatorPreset = s_PrefsKeyPrefix + "SimulatorPreset"; static string s_PacketDelayMsKey = s_PrefsKeyPrefix + "PacketDelayMs"; static string s_PacketJitterMsKey = s_PrefsKeyPrefix + "PacketJitterMs"; static string s_PacketDropPercentageKey = s_PrefsKeyPrefix + "PacketDropRate"; + static string s_PacketFuzzPercentageKey = s_PrefsKeyPrefix + "PacketFuzzRate"; static string s_RequestedNumThinClientsKey = s_PrefsKeyPrefix + "NumThinClients"; static string s_StaggerThinClientCreationKey = s_PrefsKeyPrefix + "StaggerThinClientCreation"; @@ -35,17 +43,34 @@ public static class MultiplayerPlayModePreferences static string s_LagSpikeDurationSelectionKey = s_PrefsKeyPrefix + "LagSpikeDurationSelection"; - public static bool SimulatorEnabled => RequestedSimulatorView != SimulatorView.Disabled; static string s_ApplyLoggerSettings = s_PrefsKeyPrefix + "NetDebugLogger_ApplyOverload"; static string s_LoggerLevelType = s_PrefsKeyPrefix + "NetDebugLogger_LogLevelType"; static string s_TargetShouldDumpPackets = s_PrefsKeyPrefix + "NetDebugLogger_ShouldDumpPackets"; static string s_ShowAllSimulatorPresets = s_PrefsKeyPrefix + "ShowAllSimulatorPresets"; - /// Editor "mode". Displays different data, and can be used to disable the simulator entirely. + /// Stores whether or not the user wishes to use the client simulator UTP module. + /// + public static bool SimulatorEnabled + { + get => EditorPrefs.GetBool(s_SimulatorEnabledKey, DefaultSimulatorEnabled); + set => EditorPrefs.SetBool(s_SimulatorEnabledKey, value); + } + + /// Editor "mode". Stores the preferred mode that the Simulator is in. public static SimulatorView RequestedSimulatorView { get => (SimulatorView) EditorPrefs.GetInt(s_RequestedSimulatorViewKey, (int) DefaultSimulatorView); - set => EditorPrefs.SetInt(s_RequestedSimulatorViewKey, (int) value); + set + { +#pragma warning disable CS0618 + if (value == SimulatorView.Disabled) +#pragma warning restore CS0618 + { + SimulatorEnabled = false; + return; + } + EditorPrefs.SetInt(s_RequestedSimulatorViewKey, (int) value); + } } /// @@ -53,14 +78,65 @@ public static SimulatorView RequestedSimulatorView { Mode = ApplyMode.AllPackets, MaxPacketSize = NetworkParameterConstants.MTU, MaxPacketCount = k_DefaultSimulatorMaxPacketCount, PacketDelayMs = PacketDelayMs, PacketJitterMs = PacketJitterMs, - PacketDropPercentage = PacketDropPercentage, PacketDuplicationPercentage = 0, FuzzFactor = 0, + PacketDropPercentage = PacketDropPercentage, FuzzFactor = PacketFuzzPercentage, PacketDuplicationPercentage = 0, }; +#if UNITY_USE_MULTIPLAYER_ROLES + private static ClientServerBootstrap.PlayType MultiplayerRoleFlagsToPlayType(MultiplayerRoleFlags roleFlags) + { + switch (roleFlags) + { + case MultiplayerRoleFlags.Server: + return ClientServerBootstrap.PlayType.Server; + case MultiplayerRoleFlags.Client: + return ClientServerBootstrap.PlayType.Client; + case MultiplayerRoleFlags.ClientAndServer: + return ClientServerBootstrap.PlayType.ClientAndServer; + default: + throw new ArgumentOutOfRangeException(nameof(roleFlags), roleFlags, null); + } + } + + private static MultiplayerRoleFlags PlayTypeToMultiplayerRoleFlags(ClientServerBootstrap.PlayType playType) + { + switch (playType) + { + case ClientServerBootstrap.PlayType.Server: + return MultiplayerRoleFlags.Server; + case ClientServerBootstrap.PlayType.Client: + return MultiplayerRoleFlags.Client; + case ClientServerBootstrap.PlayType.ClientAndServer: + return MultiplayerRoleFlags.ClientAndServer; + default: + throw new ArgumentOutOfRangeException(nameof(playType), playType, null); + } + } +#endif + /// Denotes what type of worlds are created by when entering playmode in the editor. public static ClientServerBootstrap.PlayType RequestedPlayType { - get => (ClientServerBootstrap.PlayType) EditorPrefs.GetInt(s_PlayModeTypeKey, (int) ClientServerBootstrap.PlayType.ClientAndServer); - set => EditorPrefs.SetInt(s_PlayModeTypeKey, (int) value); + get + { +#if UNITY_USE_MULTIPLAYER_ROLES + if (Unity.Multiplayer.Editor.EditorMultiplayerManager.enableMultiplayerRoles) + { + return MultiplayerRoleFlagsToPlayType(Unity.Multiplayer.Editor.EditorMultiplayerManager.activeMultiplayerRoleMask); + } +#endif + return (ClientServerBootstrap.PlayType) EditorPrefs.GetInt(s_PlayModeTypeKey, (int) ClientServerBootstrap.PlayType.ClientAndServer); + } + set + { +#if UNITY_USE_MULTIPLAYER_ROLES + if (Unity.Multiplayer.Editor.EditorMultiplayerManager.enableMultiplayerRoles) + { + Unity.Multiplayer.Editor.EditorMultiplayerManager.activeMultiplayerRoleMask = PlayTypeToMultiplayerRoleFlags(value); + return; + } +#endif + EditorPrefs.SetInt(s_PlayModeTypeKey, (int) value); + } } private static string s_SimulateDedicatedServer = s_PrefsKeyPrefix + "SimulateDedicatedServer"; @@ -91,6 +167,13 @@ public static int PacketDropPercentage set => EditorPrefs.SetInt(s_PacketDropPercentageKey, math.clamp(value, 0, 100)); } + /// + public static int PacketFuzzPercentage + { + get => math.clamp(EditorPrefs.GetInt(s_PacketFuzzPercentageKey, 0), 0, 100); + set => EditorPrefs.SetInt(s_PacketFuzzPercentageKey, math.clamp(value, 0, 100)); + } + /// Denotes how many thin client worlds are created in the (and at runtime, the PlayMode window). public static int RequestedNumThinClients { @@ -182,16 +265,18 @@ public static void ApplySimulatorPresetToPrefs(SimulatorPreset preset) PacketDelayMs = preset.PacketDelayMs; PacketJitterMs = preset.PacketJitterMs; PacketDropPercentage = math.clamp(preset.PacketLossPercent, 0, 100); + PacketFuzzPercentage = math.clamp(preset.PacketFuzzPercent, 0, 100); } } } - /// For the Multiplayer PlayMode Window. + /// For the PlayMode Tools Window. public enum SimulatorView { - Disabled, - PingView, - PerPacketView, + [Obsolete("RemovedAfter Entities 1.0")] + Disabled = -1, + PingView = 0, + PerPacketView = 1, } } #endif diff --git a/Runtime/Simulator/SimulatorPreset.cs b/Runtime/Simulator/SimulatorPreset.cs index 94a49fa..f7a020a 100644 --- a/Runtime/Simulator/SimulatorPreset.cs +++ b/Runtime/Simulator/SimulatorPreset.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Unity.Networking.Transport.Utilities; namespace Unity.NetCode { @@ -38,9 +39,10 @@ public struct SimulatorPreset /// To append to. public static void AppendBaseSimulatorPresets(List list) { - list.Add(new SimulatorPreset(k_CustomProfileKey, -1, -1, -1, k_CustomProfileTooltip)); - list.Add(new SimulatorPreset("Custom / No Internet", 1000, 1000, 100, "Simulate the server becoming completely unreachable.")); - list.Add(new SimulatorPreset("Custom / Unplayable Internet", 300, 400, 30, "Simulate barely having a connection at all, to observe what your users will experience when the internet is good enough to connect (sometimes), but not good enough to play.\n\nIt may take multiple attempts for the driver to connect.\n\nWe recommend detecting a \"minimum threshold of playable\", and to exclude (and inform) users when below this threshold.")); + list.Add(new SimulatorPreset(k_CustomProfileKey, -1, -1, -1, 0, k_CustomProfileTooltip)); + list.Add(new SimulatorPreset("Custom / No Internet", 1000, 1000, 100, 0,"Simulate the server becoming completely unreachable.")); + list.Add(new SimulatorPreset("Custom / Unplayable Internet", 300, 400, 30, 0, "Simulate barely having a connection at all, to observe what your users will experience when the internet is good enough to connect (sometimes), but not good enough to play.\n\nIt may take multiple attempts for the driver to connect.\n\nWe recommend detecting a \"minimum threshold of playable\", and to exclude (and inform) users when below this threshold.")); + list.Add(new SimulatorPreset("Custom / MitM (Man-in-the-Middle) Packet Corruption", 200, 400, 2, 1, "Simulate a malicious user attempting to catastrophically err your client, or (more likely) the server.")); BuildProfiles(list, true, "Broadband [WIFI] / ", 1, 1, 1, k_MobileWifiDisclaimer); } @@ -78,7 +80,7 @@ public static void AppendAdditionalMobileSimulatorProfiles(List /// To append to. public static void AppendAdditionalPCSimulatorPresets(List list) { - list.Add(new SimulatorPreset("LAN [Local Area Network]", 1, 1, 1, "Playing on LAN is generally <1ms (i.e. simulator off), but we've included it for convenience.")); + list.Add(new SimulatorPreset("LAN [Local Area Network]", 1, 1, 1, 0, "Playing on LAN is generally <1ms (i.e. simulator off), but we've included it for convenience.")); } /// Builds sub-profiles for your profile. E.g. 4 regional options for your custom profile. @@ -96,15 +98,15 @@ public static void BuildProfiles(List list, bool showRegional, if (showRegional) { - list.Add(new SimulatorPreset(name + "Regional [5th Percentile]", packetDelayMs + 9, packetJitterMs + 1, packetLossPercent + 1, tooltip + k_Perfect)); - list.Add(new SimulatorPreset(name + "Regional [25th Percentile]", packetDelayMs + 15, packetJitterMs + 5, packetLossPercent + 1, tooltip + k_Decent)); - list.Add(new SimulatorPreset(name + "Regional [50th Percentile]", packetDelayMs + 65, packetJitterMs + 10, packetLossPercent + 2, tooltip + k_Average)); - list.Add(new SimulatorPreset(name + "Regional [95th Percentile]", packetDelayMs + 150, packetJitterMs + 10, packetLossPercent + 3, tooltip + k_Poor)); + list.Add(new SimulatorPreset(name + "Regional [5th Percentile]", packetDelayMs + 9, packetJitterMs + 1, packetLossPercent + 1, 0, tooltip + k_Perfect)); + list.Add(new SimulatorPreset(name + "Regional [25th Percentile]", packetDelayMs + 15, packetJitterMs + 5, packetLossPercent + 1, 0, tooltip + k_Decent)); + list.Add(new SimulatorPreset(name + "Regional [50th Percentile]", packetDelayMs + 65, packetJitterMs + 10, packetLossPercent + 2, 0, tooltip + k_Average)); + list.Add(new SimulatorPreset(name + "Regional [95th Percentile]", packetDelayMs + 150, packetJitterMs + 10, packetLossPercent + 3, 0, tooltip + k_Poor)); } - list.Add(new SimulatorPreset(name + "International [25th Percentile]", packetDelayMs + 60, packetJitterMs + 5, packetLossPercent + 2, tooltip + k_InternationalDecent)); - list.Add(new SimulatorPreset(name + "International [50th Percentile]", packetDelayMs + 120, packetJitterMs + 10, packetLossPercent + 2, tooltip + k_InternationalAverage)); - list.Add(new SimulatorPreset(name + "International [95th Percentile]", packetDelayMs + 200, packetJitterMs + 15, packetLossPercent + 5, tooltip + k_InternationalPoor)); + list.Add(new SimulatorPreset(name + "International [25th Percentile]", packetDelayMs + 60, packetJitterMs + 5, packetLossPercent + 2, 0, tooltip + k_InternationalDecent)); + list.Add(new SimulatorPreset(name + "International [50th Percentile]", packetDelayMs + 120, packetJitterMs + 10, packetLossPercent + 2, 0, tooltip + k_InternationalAverage)); + list.Add(new SimulatorPreset(name + "International [95th Percentile]", packetDelayMs + 200, packetJitterMs + 15, packetLossPercent + 5, 0, tooltip + k_InternationalPoor)); } #if UNITY_EDITOR @@ -115,7 +117,7 @@ public static void BuildProfiles(List list, bool showRegional, /// public static void DefaultInUseSimulatorPresets(out string presetGroupName, List appendPresets) { - appendPresets.Add(new SimulatorPreset(k_CustomProfileKey, -1, -1, -1, k_CustomProfileTooltip)); + appendPresets.Add(new SimulatorPreset(k_CustomProfileKey, -1, -1, -1, 0, k_CustomProfileTooltip)); if (MultiplayerPlayModePreferences.ShowAllSimulatorPresets) { presetGroupName = "All Presets"; @@ -157,6 +159,8 @@ public static void DefaultInUseSimulatorPresets(out string presetGroupName, List internal int PacketJitterMs; /// internal int PacketLossPercent; + /// + internal int PacketFuzzPercent; // TODO - Make use of bandwidth data in later commit. @@ -191,14 +195,30 @@ internal static bool TryGetPresetFromName(string name, List all /// /// /// + /// /// - public SimulatorPreset(string name, int packetDelayMs, int packetJitterMs, int packetLossPercent, string tooltip) + public SimulatorPreset(string name, int packetDelayMs, int packetJitterMs, int packetLossPercent, int packetFuzzPercent, string tooltip) { Name = name; Tooltip = tooltip; PacketDelayMs = packetDelayMs; PacketJitterMs = packetJitterMs; PacketLossPercent = packetLossPercent; + PacketFuzzPercent = packetFuzzPercent; + } + + /// + /// Construct a new preset. + /// + /// + /// + /// + /// + /// + [Obsolete("Use other constructor. (RemovedAfter Entities 1.1)")] + public SimulatorPreset(string name, int packetDelayMs, int packetJitterMs, int packetLossPercent, string tooltip) + : this(name, packetDelayMs, packetJitterMs, packetLossPercent, 0, tooltip) + { } } } diff --git a/Runtime/Snapshot/DefaultTranslationSmoothingAction.cs b/Runtime/Snapshot/DefaultTranslationSmoothingAction.cs index 8bc33d9..de40b89 100644 --- a/Runtime/Snapshot/DefaultTranslationSmoothingAction.cs +++ b/Runtime/Snapshot/DefaultTranslationSmoothingAction.cs @@ -1,5 +1,4 @@ using System; -using AOT; using Unity.Burst; using Unity.Collections.LowLevel.Unsafe; using Unity.Entities; @@ -72,7 +71,7 @@ class DeltaKey {} new PortableFunctionPointer(SmoothingAction); [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostPredictionSmoothing.SmoothingActionDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostPredictionSmoothing.SmoothingActionDelegate))] private static void SmoothingAction(IntPtr currentData, IntPtr previousData, IntPtr usrData) { ref var trans = ref UnsafeUtility.AsRef((void*)currentData); diff --git a/Runtime/Snapshot/GhostChunkSerializationState.cs b/Runtime/Snapshot/GhostChunkSerializationState.cs index ff3a984..e499436 100644 --- a/Runtime/Snapshot/GhostChunkSerializationState.cs +++ b/Runtime/Snapshot/GhostChunkSerializationState.cs @@ -291,7 +291,7 @@ static class ConnectionGhostStateExtensions { //Map the right index by unmasking the prespawn bit (if present) var index = (int)(cleanup.ghostId & ~PrespawnHelper.PrespawnGhostIdBase); - var isPrespawnGhost = PrespawnHelper.IsPrespawGhostId(cleanup.ghostId); + var isPrespawnGhost = PrespawnHelper.IsPrespawnGhostId(cleanup.ghostId); var list = isPrespawnGhost ? self.PrespawnList : self.List; ref var state = ref list.ElementAt(index); diff --git a/Runtime/Snapshot/GhostChunkSerializer.cs b/Runtime/Snapshot/GhostChunkSerializer.cs index be7a5bf..017fd54 100644 --- a/Runtime/Snapshot/GhostChunkSerializer.cs +++ b/Runtime/Snapshot/GhostChunkSerializer.cs @@ -381,7 +381,7 @@ private void ComponentScopeEnd(int serializerIdx) [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")] private static void ValidatePrespawnBaseline(Entity ghost, int ghostId, int ent, int baselinesCount) { - if(!PrespawnHelper.IsPrespawGhostId(ghostId)) + if(!PrespawnHelper.IsPrespawnGhostId(ghostId)) throw new InvalidOperationException("Invalid prespawn ghost id. All prespawn ghost ids must be < 0"); if (baselinesCount <= ent) throw new InvalidOperationException($"Could not find prespawn baseline data for entity {ghost.Index}:{ghost.Version}."); @@ -876,7 +876,7 @@ private static void ValidatePrespawnSpaceForDynamicData(int prespawnBaselineLeng if (baselineDynamicData != null) { //for prespawn ghosts only consider the fallback baseline if the tick is 0. - if (PrespawnHelper.IsPrespawGhostId(ghost.ghostId) && (*(uint*)baseline) == 0) + if (PrespawnHelper.IsPrespawnGhostId(ghost.ghostId) && (*(uint*)baseline) == 0) prevDynamicSize = ((uint*) baselineDynamicData)[0]; else prevDynamicSize = ((uint*) baselineDynamicData)[ent]; @@ -896,12 +896,12 @@ private static void ValidatePrespawnSpaceForDynamicData(int prespawnBaselineLeng if (hasPartialSends) { - GhostComponentSerializer.SendMask serializeMask = GhostComponentSerializer.SendMask.Interpolated | GhostComponentSerializer.SendMask.Predicted; + GhostSendType serializeMask = GhostSendType.AllClients; var sendToOwner = SendToOwnerType.All; var isOwner = (NetworkId == *(int*) (snapshot + typeData.PredictionOwnerOffset)); sendToOwner = isOwner ? SendToOwnerType.SendToOwner : SendToOwnerType.SendToNonOwner; if (typeData.PartialComponents != 0 && typeData.OwnerPredicted != 0) - serializeMask = isOwner ? GhostComponentSerializer.SendMask.Predicted : GhostComponentSerializer.SendMask.Interpolated; + serializeMask = isOwner ? GhostSendType.OnlyPredictedClients : GhostSendType.OnlyInterpolatedClients; var curMaskOffsetInBits = 0; //FIXME: problem: what about the enable bits state here for that component if it is not meant to be @@ -925,10 +925,18 @@ private static void ValidatePrespawnSpaceForDynamicData(int prespawnBaselineLeng GhostComponentSerializer.CopyToChangeMask((IntPtr)changeMasks, 0, curMaskOffsetInBits+subMaskOffset, 32); remainingChangeBits -=32; subMaskOffset += 32; + + // FIXME: We need to modify the test to ensure that the enableableMasks is a MIX of 1s and 0s, + // otherwise this code could be broken (by removing the wrong 1) and we wont know. + + // FIXME: Cut out the enable bit from the enableBitMask here (32). } if (remainingChangeBits > 0) GhostComponentSerializer.CopyToChangeMask((IntPtr)changeMasks, 0, curMaskOffsetInBits+subMaskOffset, remainingChangeBits); entityStartBit[(entityOffset*comp + entOffset)*2+1] = 0; + + // FIXME: Cut out the enable bit from the enableBitMask here. + // TODO: buffers could also reduce the required dynamic buffer size to save some memory on clients } curMaskOffsetInBits += changeBits; @@ -1118,17 +1126,17 @@ public static int TypeIndexToIndexInTypeArray(ArchetypeChunk chunk, int typeInde var array = chunk.GetEnableableBits(ref handle); var bitArray = new UnsafeBitArray(&array, 2 * sizeof(ulong)); - var uintOffset = enableableMaskOffset >> 5; - var maskOffset = enableableMaskOffset & 0x1f; + var uintOffset = enableableMaskOffset >> 5; // This is a shortcut for `floor(enableableMaskOffset / 32)`. + var maskOffset = enableableMaskOffset & 0x1f; // This is a shortcut for `enableableMaskOffset % 32`. snapshotSize /= 4; uint* enableableMasks = (uint*)(snapshot + sizeof(uint) + changeMaskUints * sizeof(uint)) + uintOffset; for (int i = startIndex; i < endIndex; ++i) { - if (maskOffset == 0) + if (maskOffset == 0) // First time writing, reset the entire 32 bits. *enableableMasks = 0U; var isSetOnServer = bitArray.IsSet(i); - if (bitArray.IsCreated && isSetOnServer) + if (bitArray.IsCreated && isSetOnServer) // FIXME: How can bitArray.IsCreated ever be false? (*enableableMasks) |= 1U << maskOffset; else (*enableableMasks) &= ~(1U << maskOffset); @@ -1306,7 +1314,7 @@ int UpdateGhostRelevancy(ArchetypeChunk chunk, int startIndex, byte* relevancyDa ghostState.Flags &= (~ConnectionStateData.GhostStateFlags.IsRelevant); // If this is a prespawned ghost the prespawn baseline cannot be used after being despawned (as it's gone on clients) - if (PrespawnHelper.IsPrespawGhostId(ghost[ent].ghostId)) + if (PrespawnHelper.IsPrespawnGhostId(ghost[ent].ghostId)) ghostState.Flags |= ConnectionStateData.GhostStateFlags.CantUsePrespawnBaseline; } if (ent >= startIndex) diff --git a/Runtime/Snapshot/GhostCollectionComponent.cs b/Runtime/Snapshot/GhostCollectionComponent.cs index 01930e9..23a0833 100644 --- a/Runtime/Snapshot/GhostCollectionComponent.cs +++ b/Runtime/Snapshot/GhostCollectionComponent.cs @@ -204,11 +204,11 @@ internal struct GhostCollectionPrefabSerializer : IBufferElementData /// public int NumChildComponents; /// - /// The total size in bytes of the serialized component data. + /// The total size in bytes of the entire ghost type, including space for enable bits and change masks. /// public int SnapshotSize; /// - /// The number of bits used by change mask bitarray. + /// The number of bits used by change mask bitarray for this entire ghost type. /// public int ChangeMaskBits; /// @@ -313,7 +313,7 @@ internal struct GhostCollectionComponentIndex : IBufferElementData /// Index in the GhostComponentSerializer.State collection, used to get the type of serializer to use. public int SerializerIndex; /// Current send mask for that component, used to not send/receive components in some configuration. - public GhostComponentSerializer.SendMask SendMask; + public GhostSendType SendMask; #if UNITY_EDITOR || NETCODE_DEBUG public int PredictionErrorBaseIndex; #endif diff --git a/Runtime/Snapshot/GhostCollectionSystem.cs b/Runtime/Snapshot/GhostCollectionSystem.cs index 126ccf7..2419839 100644 --- a/Runtime/Snapshot/GhostCollectionSystem.cs +++ b/Runtime/Snapshot/GhostCollectionSystem.cs @@ -769,16 +769,16 @@ private unsafe void AddComponents(ref AddComponentCtx ctx, ref GhostComponentSer ref var compState = ref ctx.ghostSerializerCollection.ElementAt(serializerIndex); var sendMask = componentInfo.SendMaskOverride >= 0 - ? (GhostComponentSerializer.SendMask) componentInfo.SendMaskOverride + ? (GhostSendType) componentInfo.SendMaskOverride : compState.SendMask; if (sendMask == 0) continue; var supportedModes = ghostMeta.SupportedModes; - if ((sendMask & GhostComponentSerializer.SendMask.Interpolated) == 0 && + if ((sendMask & GhostSendType.OnlyInterpolatedClients) == 0 && supportedModes == GhostPrefabBlobMetaData.GhostMode.Interpolated) continue; - if ((sendMask & GhostComponentSerializer.SendMask.Predicted) == 0 && + if ((sendMask & GhostSendType.OnlyPredictedClients) == 0 && supportedModes == GhostPrefabBlobMetaData.GhostMode.Predicted) continue; @@ -817,8 +817,7 @@ private unsafe void AddComponents(ref AddComponentCtx ctx, ref GhostComponentSer PredictionErrorBaseIndex = m_currentPredictionErrorCount #endif }); - if (sendMask != (GhostComponentSerializer.SendMask.Interpolated | - GhostComponentSerializer.SendMask.Predicted)) + if (sendMask != GhostSendType.AllClients) ghostType.PartialComponents = 1; if (compState.SendToOwner != SendToOwnerType.All) diff --git a/Runtime/Snapshot/GhostComponentSerializer.cs b/Runtime/Snapshot/GhostComponentSerializer.cs index ed94685..cbe5941 100644 --- a/Runtime/Snapshot/GhostComponentSerializer.cs +++ b/Runtime/Snapshot/GhostComponentSerializer.cs @@ -29,21 +29,26 @@ public unsafe struct GhostComponentSerializer /// /// A bitflag used to mark to which ghost type a component should be serialized to. /// + /// Duplicates , which should be used instead. [Flags] + [Obsolete("Due to changes to the source generator, this enum is now both redundant and deprecated, as it duplicates `GhostSendType`. Unfortunately, not UnityUpgradable to GhostSendType as enum names have changed. (RemovedAfter Entities 1.0)", false)] public enum SendMask { /// /// The component should be not replicated. /// + /// Maps to . None = 0, /// /// The component is replicated only to interpolated ghosts. /// + /// Maps to . Interpolated = 1, /// /// The component is replicated only to predicted ghosts. /// - Predicted = 2 + /// Maps to . + Predicted = 2, } /// @@ -163,7 +168,7 @@ public struct State : IBufferElementData /// Indicates for which type of ghosts the component should be replicated. The mask is set by code-gen base on the /// constraint. /// - public SendMask SendMask; + public GhostSendType SendMask; /// /// Store the if the attribute is present on the component. Otherwise is set /// to . @@ -223,7 +228,7 @@ public struct State : IBufferElementData /// public Unity.Profiling.ProfilerMarker ProfilerMarker; #endif - #if UNITY_EDITOR || NETCODE_DEBUG +#if UNITY_EDITOR || NETCODE_DEBUG /// /// String buffer, containing the list of all replicated field names. Empty for component type that can be only interpolated. /// (see ). @@ -242,6 +247,10 @@ public struct State : IBufferElementData /// For internal use only. The index inside the prediction error names cache (see ). /// internal int FirstNameIndex; + /// + /// For internal use only. The hash of the ghost variation type fullname. Used mostly for validation + /// + public ulong VariantTypeFullNameHash; #endif } diff --git a/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs b/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs index be2c0e0..2a5c64b 100644 --- a/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs +++ b/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs @@ -234,7 +234,7 @@ public struct GhostComponentSerializerCollectionData : IComponentData /// internal NativeParallelMultiHashMap SerializationStrategiesComponentTypeMap; /// - /// Map to look up the buffer type to use for an IInputComponentData type. + /// Map to look up the buffer type to use for an IInputComponentData type. Only used for baking purpose. /// internal NativeHashMap InputComponentBufferMap; /// @@ -366,18 +366,6 @@ public void AddInputComponent(ComponentType inputType, ComponentType bufferType) { InputComponentBufferMap.TryAdd(inputType, bufferType); } - - /// - /// Lookup a component type to use as a buffer for a given IInputComponentData. - /// - /// - /// - /// True if the component has an assosiated buffer to use, false if it does not. - public bool TryGetBufferForInputComponent(ComponentType inputType, out ComponentType bufferType) - { - return InputComponentBufferMap.TryGetValue(inputType, out bufferType); - } - internal void MapSerializerToStrategy(ref GhostComponentSerializer.State state, short serializerIndex) { foreach (var ssIndex in SerializationStrategiesComponentTypeMap.GetValuesForKey(state.ComponentType)) @@ -502,7 +490,7 @@ static ComponentTypeSerializationStrategy GetSafestFallbackVariantUponError(in N /// Finds all available variants for a given type, applying all variant rules at once. /// Since multiple variants can be present for any given component there are some important use cases that need to be /// handled. - /// Note that, for s, they'll return the variants available to their authoring struct. + /// Note that, for s, they'll return the variants available to their authoring struct. /// Note that the number of default variants returned may not be 1 (it could be more or less). /// /// Type to find the variant for. diff --git a/Runtime/Snapshot/GhostDistanceImportance.cs b/Runtime/Snapshot/GhostDistanceImportance.cs index 354aa1b..8a2c8e0 100644 --- a/Runtime/Snapshot/GhostDistanceImportance.cs +++ b/Runtime/Snapshot/GhostDistanceImportance.cs @@ -1,5 +1,4 @@ using System; -using AOT; using Unity.Burst; using Unity.Entities; using Unity.Mathematics; @@ -64,7 +63,7 @@ public struct GhostDistanceImportance new PortableFunctionPointer(Scale); [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(GhostImportance.ScaleImportanceDelegate))] + [AOT.MonoPInvokeCallback(typeof(GhostImportance.ScaleImportanceDelegate))] private static int Scale(IntPtr connectionDataPtr, IntPtr distanceDataPtr, IntPtr chunkTilePtr, int basePriority) { var distanceData = GhostComponentSerializer.TypeCast(distanceDataPtr); diff --git a/Runtime/Snapshot/GhostImportance.cs b/Runtime/Snapshot/GhostImportance.cs index edc99f2..3619982 100644 --- a/Runtime/Snapshot/GhostImportance.cs +++ b/Runtime/Snapshot/GhostImportance.cs @@ -1,6 +1,5 @@ using System; using System.Runtime.InteropServices; -using AOT; using Unity.Burst; using Unity.Collections; using Unity.Entities; @@ -55,7 +54,7 @@ public struct GhostImportance : IComponentData public ComponentType GhostImportancePerChunkDataType; [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(ScaleImportanceDelegate))] + [AOT.MonoPInvokeCallback(typeof(ScaleImportanceDelegate))] private static int NoScale(IntPtr connectionData, IntPtr importanceData, IntPtr chunkTile, int basePriority) { return basePriority; diff --git a/Runtime/Snapshot/GhostPredictionDebugSystem.cs b/Runtime/Snapshot/GhostPredictionDebugSystem.cs index 0153d36..0c34103 100644 --- a/Runtime/Snapshot/GhostPredictionDebugSystem.cs +++ b/Runtime/Snapshot/GhostPredictionDebugSystem.cs @@ -183,7 +183,7 @@ struct PredictionDebugJob : IJobChunk // FIXME: placeholder to show the idea behind prediction smoothing public ComponentType transformType; - const GhostComponentSerializer.SendMask requiredSendMask = GhostComponentSerializer.SendMask.Predicted; + const GhostSendType requiredSendMask = GhostSendType.OnlyPredictedClients; #pragma warning disable 649 [NativeSetThreadIndex] public int ThreadIndex; diff --git a/Runtime/Snapshot/GhostPredictionHistorySystem.cs b/Runtime/Snapshot/GhostPredictionHistorySystem.cs index e47435e..ad586bf 100644 --- a/Runtime/Snapshot/GhostPredictionHistorySystem.cs +++ b/Runtime/Snapshot/GhostPredictionHistorySystem.cs @@ -1,15 +1,11 @@ using System; using Unity.Assertions; using Unity.Entities; -using Unity.NetCode; -using Unity.Mathematics; -using Unity.Transforms; using Unity.Collections.LowLevel.Unsafe; using Unity.Collections; using Unity.NetCode.LowLevel.Unsafe; using Unity.Burst; using Unity.Burst.Intrinsics; -using Unity.Entities.LowLevel.Unsafe; using Unity.Jobs; namespace Unity.NetCode @@ -18,6 +14,7 @@ namespace Unity.NetCode // The header is followed by: // Entity[Capacity] the entity this history applies to (to prevent errors on structural changes) // ulong[Capacity*enabledBits] each enabled bit is stored as a contiguous array of all entities, aligned to ulong + // int[root components + Capacity * num_child_component] chunk (and child chunk) version numbers. // byte*[Capacity * sizeof(IComponentData)] the raw backup data for all replicated components in this ghost type. For buffers an uint pair (size, offset) is stored instead. // [Opt]byte*[BuffersDataSize] the raw buffers element data present in the chunk if present. The total buffers size is computed at runtime and the // backup state resized accordingly. All buffers contents start to a 16 bytes aligned offset: Align(b1Elem*b1ElemSize, 16), Align(b2Elem*b2ElemSize, 16) ... @@ -34,24 +31,31 @@ internal unsafe struct PredictionBackupState //the ghost component serialized size public int dataOffset; public int dataSize; + //chunk versions + public int chunkVersionsOffset; + public int chunkVersionsSize; //the capacity for the dynamic data. Dynamic Buffers are store after the component backup public int bufferDataCapacity; public int bufferDataOffset; - public static IntPtr AllocNew(int ghostTypeId, int enabledBits, int dataSize, int entityCapacity, int buffersDataCapacity, int predictionOwnerOffset) + public static IntPtr AllocNew(int ghostTypeId, int enabledBits, + int numComponents, int dataSize, int entityCapacity, int buffersDataCapacity, int predictionOwnerOffset) { var entitiesSize = (ushort)GetEntitiesSize(entityCapacity, out var _); var headerSize = GetHeaderSize(); // each enabled bit is a unique array big enough to fit all entities var enabledBitSize = (((entityCapacity+63)&(~63))/8 * enabledBits + 15) & (~15); - var state = (PredictionBackupState*)UnsafeUtility.Malloc(headerSize + enabledBitSize + entitiesSize + dataSize + buffersDataCapacity, 16, Allocator.Persistent); + var versionSize = (sizeof(int) * numComponents * entityCapacity + 15) & ~15; + var state = (PredictionBackupState*)UnsafeUtility.Malloc(headerSize + enabledBitSize + entitiesSize + versionSize + dataSize + buffersDataCapacity, 16, Allocator.Persistent); state->ghostType = ghostTypeId; state->entityCapacity = entityCapacity; state->entitiesOffset = headerSize; state->enabledBitOffset = headerSize + entitiesSize; state->ghostOwnerOffset = predictionOwnerOffset; state->enabledBits = enabledBits; - state->dataOffset = headerSize + entitiesSize + enabledBitSize; + state->chunkVersionsOffset = headerSize + entitiesSize + enabledBitSize; + state->chunkVersionsSize = versionSize; + state->dataOffset = state->chunkVersionsOffset + versionSize; state->dataSize = dataSize; state->bufferDataCapacity = buffersDataCapacity; state->bufferDataOffset = state->dataOffset + dataSize; @@ -75,12 +79,23 @@ public static int GetDataSize(int componentSize, int chunkCapacity) var ps = ((PredictionBackupState*) state); return (Entity*)(((byte*)state) + ps->entitiesOffset); } + public static bool MatchEntity(IntPtr state, int ent, in Entity entity) + { + var ps = ((PredictionBackupState*) state); + return ((Entity*)(((byte*)state) + ps->entitiesOffset))[ent] == entity; + } public static byte* GetData(IntPtr state) { var ps = ((PredictionBackupState*) state); return ((byte*) state) + ps->dataOffset; } + public static uint* GetChunkVersion(IntPtr state) + { + var ps = ((PredictionBackupState*) state); + return (uint*)((byte*)state + ps->chunkVersionsOffset); + } + public static int GetBufferDataCapacity(IntPtr state) { return ((PredictionBackupState*) state)->bufferDataCapacity; @@ -111,6 +126,11 @@ public static int GetGhostOwner(IntPtr state) //return an invalid owner (0) return 0; } + + public static uint* GetNextChildChunkVersion(uint* changeVersionPtr, int chunkCapacity) + { + return changeVersionPtr + chunkCapacity; + } } /// @@ -337,7 +357,7 @@ struct PredictionBackupJob : IJobChunk [ReadOnly] public EntityStorageInfoLookup childEntityLookup; [ReadOnly] public BufferTypeHandle linkedEntityGroupType; - const GhostComponentSerializer.SendMask requiredSendMask = GhostComponentSerializer.SendMask.Predicted; + const GhostSendType requiredSendMask = GhostSendType.OnlyPredictedClients; //Sum up all the dynamic buffers raw data content size. Each buffer content size is aligned to 16 bytes private int GetChunkBuffersDataSize(GhostCollectionPrefabSerializer typeData, ArchetypeChunk chunk, @@ -363,9 +383,10 @@ struct PredictionBackupJob : IJobChunk if (chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) { var bufferData = chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); + ref readonly var ghostSerialiser = ref GhostComponentCollection.ElementAtRO(serializerIdx); for (int i = 0; i < bufferData.Length; ++i) { - bufferTotalSize += bufferData.GetBufferCapacity(i) * GhostComponentCollection[serializerIdx].ComponentSize; + bufferTotalSize += bufferData.GetBufferCapacity(i) * ghostSerialiser.ComponentSize; } bufferTotalSize = GhostComponentSerializer.SnapshotSizeAligned(bufferTotalSize); } @@ -385,7 +406,8 @@ struct PredictionBackupJob : IJobChunk if ((GhostComponentIndex[baseOffset + comp].SendMask & requiredSendMask) == 0) continue; - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + ref readonly var ghostSerializer = ref GhostComponentCollection.ElementAtRO(serializerIdx); + if (!ghostSerializer.ComponentType.IsBuffer) continue; for (int ent = 0, chunkEntityCount = chunk.Count; ent < chunkEntityCount; ++ent) @@ -395,7 +417,7 @@ struct PredictionBackupJob : IJobChunk if (childEntityLookup.TryGetValue(childEnt, out var childChunk) && childChunk.Chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) { var bufferData = childChunk.Chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); - bufferTotalSize += bufferData.GetBufferCapacity(childChunk.IndexInChunk) * GhostComponentCollection[serializerIdx].ComponentSize; + bufferTotalSize += bufferData.GetBufferCapacity(childChunk.IndexInChunk) * ghostSerializer.ComponentSize; } bufferTotalSize = GhostComponentSerializer.SnapshotSizeAligned(bufferTotalSize); } @@ -451,6 +473,11 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE int dataSize = 0; int enabledBits = 0; // Sum up the size of all components rounded up + // RULES: + // - if the component/buffer send mask not match PredictedClient neither the data, nor the enable bits are present in the backup. + // - if the component/buffer replicated enablebits, the bits are present in the backup + // - if the component has no ghost fields the data not present in the backup + for (int comp = 0; comp < typeData.NumComponents; ++comp) { int compIdx = GhostComponentIndex[baseOffset + comp].ComponentIndex; @@ -462,21 +489,22 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if ((GhostComponentIndex[baseOffset + comp].SendMask&requiredSendMask) == 0) continue; - if (GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + ref readonly var ghostSerializer = ref GhostComponentCollection.ElementAtRO(serializerIdx); + if (ghostSerializer.SerializesEnabledBit != 0) ++enabledBits; - if (!GhostComponentCollection[serializerIdx].HasGhostFields) + if (!ghostSerializer.HasGhostFields) continue; - if (GhostComponentCollection[serializerIdx].ComponentType.TypeIndex == ghostOwnerTypeIndex) + if (ghostSerializer.ComponentType.TypeIndex == ghostOwnerTypeIndex) predictionOwnerOffset = dataSize; //for buffers we store a a pair of uint: // uint length: the num of elements // uint backupDataOffset: the start position in the backup buffer - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + if (!ghostSerializer.ComponentType.IsBuffer) dataSize += PredictionBackupState.GetDataSize( - GhostComponentCollection[serializerIdx].ComponentSize, chunk.Capacity); + ghostSerializer.ComponentSize, chunk.Capacity); else dataSize += PredictionBackupState.GetDataSize(GhostSystemConstants.DynamicBufferComponentSnapshotSize, chunk.Capacity); } @@ -487,7 +515,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE buffersDataCapacity = GetChunkBuffersDataSize(typeData, chunk, ghostChunkComponentTypesPtr, ghostChunkComponentTypesLength, GhostComponentIndex, GhostComponentCollection); // Chunk does not exist in the history, or has changed ghost type in which case we need to create a new one - state = PredictionBackupState.AllocNew(ghostTypeId, enabledBits, dataSize, chunk.Capacity, buffersDataCapacity, predictionOwnerOffset); + state = PredictionBackupState.AllocNew(ghostTypeId, enabledBits, typeData.NumComponents, dataSize, chunk.Capacity, buffersDataCapacity, predictionOwnerOffset); newPredictionState.Enqueue(new PredictionStateEntry{chunk = chunk, data = state}); } else @@ -503,7 +531,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE var dataSize = ((PredictionBackupState*)state)->dataSize; var enabledBits = ((PredictionBackupState*)state)->enabledBits; var ghostOwnerOffset = ((PredictionBackupState*)state)->ghostOwnerOffset; - var newState = PredictionBackupState.AllocNew(ghostTypeId, enabledBits, dataSize, chunk.Capacity, buffersDataCapacity, ghostOwnerOffset); + var newState = PredictionBackupState.AllocNew(ghostTypeId, enabledBits, typeData.NumComponents, dataSize, chunk.Capacity, buffersDataCapacity, ghostOwnerOffset); UnsafeUtility.Free((void*) state, Allocator.Persistent); state = newState; updatedPredictionState.Enqueue(new PredictionStateEntry{chunk = chunk, data = newState}); @@ -519,6 +547,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE byte* dataPtr = PredictionBackupState.GetData(state); byte* bufferBackupDataPtr = PredictionBackupState.GetBufferDataPtr(state); ulong* enabledBitPtr = PredictionBackupState.GetEnabledBits(state); + uint* changeVersionPtr = PredictionBackupState.GetChunkVersion(state); int numBaseComponents = typeData.NumComponents - typeData.NumChildComponents; int bufferBackupDataOffset = 0; @@ -532,12 +561,17 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE #endif if ((GhostComponentIndex[baseOffset + comp].SendMask&requiredSendMask) == 0) continue; - - var compSize = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer + uint chunkVersion = chunk.GetChangeVersion(ref ghostChunkComponentTypesPtr[compIdx]); + ref readonly var ghostSerializer = ref GhostComponentCollection.ElementAtRO(serializerIdx); + var compSize = ghostSerializer.ComponentType.IsBuffer ? GhostSystemConstants.DynamicBufferComponentSnapshotSize - : GhostComponentCollection[serializerIdx].ComponentSize; + : ghostSerializer.ComponentSize; - if (GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + //store the change version for this component for the root entity. There is only one entry + //per component for this chunk for root entities. + changeVersionPtr[comp] = chunkVersion; + + if (ghostSerializer.SerializesEnabledBit != 0) { var handle = ghostChunkComponentTypesPtr[compIdx]; var bitArray = chunk.GetEnableableBits(ref handle); @@ -549,14 +583,17 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE // Note that `HasGhostFields` reads the `SnapshotSize` of this type, BUT we're saving the entire component. // The reason we use this is: Why bother memcopy-ing the entire component state, if we're never actually going to be writing any data back? // I.e. Only the GhostFields will be written back anyway. - if (!GhostComponentCollection[serializerIdx].HasGhostFields) + if (!ghostSerializer.HasGhostFields) continue; if (!chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) { UnsafeUtility.MemClear(dataPtr, chunk.Count * compSize); + //reset the change version to 0. The component data is not present. And it case it will, it must + //considered changed + changeVersionPtr[comp] = 0; } - else if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + else if (!ghostSerializer.ComponentType.IsBuffer) { var compData = (byte*) chunk.GetDynamicComponentDataArrayReinterpret(ref ghostChunkComponentTypesPtr[compIdx], compSize).GetUnsafeReadOnlyPtr(); UnsafeUtility.MemCpy(dataPtr, compData, chunk.Count * compSize); @@ -564,7 +601,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE else { var bufferData = chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); - var bufElemSize = GhostComponentCollection[serializerIdx].ComponentSize; + var bufElemSize = ghostSerializer.ComponentSize; //Use local variable to iterate and set the buffer offset and length. The dataptr must be //advanced "per chunk" to the next correct position var tempDataPtr = dataPtr; @@ -587,6 +624,11 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if (typeData.NumChildComponents > 0) { var linkedEntityGroupAccessor = chunk.GetBufferAccessor(ref linkedEntityGroupType); + //for child component we store a one version entry, per component type for each entity in the chunk + //the layout looks like + //ChildComp1 ChildComp2 + //e1, e2 .. en | e1, e2 .. en + var childChangeVersions = changeVersionPtr + numBaseComponents; for (int comp = numBaseComponents; comp < typeData.NumComponents; ++comp) { int compIdx = GhostComponentIndex[baseOffset + comp].ComponentIndex; @@ -599,57 +641,67 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if ((GhostComponentIndex[baseOffset + comp].SendMask&requiredSendMask) == 0) continue; - if (GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + ref readonly var ghostSerializer = ref GhostComponentCollection.ElementAtRO(serializerIdx); + if (ghostSerializer.SerializesEnabledBit != 0) { - for (int ent = 0, chunkEntityCount = chunk.Count; ent < chunkEntityCount; ++ent) + for (int rootEnt = 0, chunkEntityCount = chunk.Count; rootEnt < chunkEntityCount; ++rootEnt) { ulong isSet = 0; - var linkedEntityGroup = linkedEntityGroupAccessor[ent]; + var linkedEntityGroup = linkedEntityGroupAccessor[rootEnt]; var childEnt = linkedEntityGroup[GhostComponentIndex[baseOffset + comp].EntityIndex].Value; if (childEntityLookup.TryGetValue(childEnt, out var childChunk)) { var arr = childChunk.Chunk.GetEnableableBits(ref handle); var bits = new UnsafeBitArray(&arr, sizeof(v128)); isSet = bits.IsSet(childChunk.IndexInChunk) ? 1u : 0u; + childChangeVersions[rootEnt] = childChunk.Chunk.GetChangeVersion(ref ghostChunkComponentTypesPtr[compIdx]); } - enabledBitPtr[ent>>6] &= ~(1ul<<(ent&0x3f)); - enabledBitPtr[ent>>6] |= (isSet<<(ent&0x3f)); + enabledBitPtr[rootEnt>>6] &= ~(1ul<<(rootEnt&0x3f)); + enabledBitPtr[rootEnt>>6] |= (isSet<<(rootEnt&0x3f)); } enabledBitPtr = PredictionBackupState.GetNextEnabledBits(enabledBitPtr, chunk.Capacity); } - var isBuffer = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer; - var compSize = isBuffer ? GhostSystemConstants.DynamicBufferComponentSnapshotSize : GhostComponentCollection[serializerIdx].ComponentSize; + var isBuffer = ghostSerializer.ComponentType.IsBuffer; + var compSize = isBuffer ? GhostSystemConstants.DynamicBufferComponentSnapshotSize : ghostSerializer.ComponentSize; - if (!GhostComponentCollection[serializerIdx].HasGhostFields) + if (!ghostSerializer.HasGhostFields) continue; - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + if (!ghostSerializer.ComponentType.IsBuffer) { //use a temporary for the iteration here. Otherwise when the dataptr is offset for the chunk, we //end up in the wrong position var tempDataPtr = dataPtr; - for (int ent = 0, chunkEntityCount = chunk.Count; ent < chunkEntityCount; ++ent) + + for (int rootEnt = 0, chunkEntityCount = chunk.Count; rootEnt < chunkEntityCount; ++rootEnt) { - var linkedEntityGroup = linkedEntityGroupAccessor[ent]; + var linkedEntityGroup = linkedEntityGroupAccessor[rootEnt]; var childEnt = linkedEntityGroup[GhostComponentIndex[baseOffset + comp].EntityIndex].Value; if (childEntityLookup.TryGetValue(childEnt, out var childChunk) && childChunk.Chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) { var compData = (byte*) childChunk.Chunk.GetDynamicComponentDataArrayReinterpret(ref ghostChunkComponentTypesPtr[compIdx], compSize).GetUnsafeReadOnlyPtr(); UnsafeUtility.MemCpy(tempDataPtr, compData + childChunk.IndexInChunk * compSize, compSize); + //store the change version for the component + childChangeVersions[rootEnt] = childChunk.Chunk.GetChangeVersion(ref ghostChunkComponentTypesPtr[compIdx]); } else + { UnsafeUtility.MemClear(tempDataPtr, compSize); - + //reset the change version to 0. The component data is not present. And it case it will, it must + //considered changed + childChangeVersions[rootEnt] = 0; + } tempDataPtr += compSize; } } else { - var bufElemSize = GhostComponentCollection[serializerIdx].ComponentSize; + var bufElemSize = ghostSerializer.ComponentSize; var tempDataPtr = dataPtr; - for (int ent = 0, chunkEntityCount = chunk.Count; ent < chunkEntityCount; ++ent) + + for (int rootEnt = 0, chunkEntityCount = chunk.Count; rootEnt < chunkEntityCount; ++rootEnt) { - var linkedEntityGroup = linkedEntityGroupAccessor[ent]; + var linkedEntityGroup = linkedEntityGroupAccessor[rootEnt]; var childEnt = linkedEntityGroup[GhostComponentIndex[baseOffset + comp].EntityIndex].Value; if (childEntityLookup.TryGetValue(childEnt, out var childChunk) && childChunk.Chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) { @@ -661,11 +713,17 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if (size > 0) UnsafeUtility.MemCpy(bufferBackupDataPtr + bufferBackupDataOffset, (byte*) bufferPtr, size * bufElemSize); bufferBackupDataOffset += size * bufElemSize; + //store the change version for the component. Will be used by GhostSendSystem when restoring + //components from the backup. + childChangeVersions[rootEnt] = childChunk.Chunk.GetChangeVersion(ref ghostChunkComponentTypesPtr[compIdx]); } else { //reset the entry to 0. Don't use memcpy in this case (is faster this way) ((long*) tempDataPtr)[0] = 0; + //reset the change version to 0. The component data is not present. And it case it will, it must + //considered changed + childChangeVersions[rootEnt] = 0; } tempDataPtr += compSize; @@ -675,6 +733,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); + childChangeVersions = PredictionBackupState.GetNextChildChunkVersion(childChangeVersions, chunk.Capacity); } } } diff --git a/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs b/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs index 7d20e78..0472858 100644 --- a/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs +++ b/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs @@ -277,7 +277,7 @@ struct PredictionSmoothingJob : IJobChunk [ReadOnly] public NativeParallelHashMap smoothingActions; public NetworkTick tick; - const GhostComponentSerializer.SendMask requiredSendMask = GhostComponentSerializer.SendMask.Predicted; + const GhostSendType requiredSendMask = GhostSendType.OnlyPredictedClients; public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask) { diff --git a/Runtime/Snapshot/GhostPredictionSwitchingQueues.cs b/Runtime/Snapshot/GhostPredictionSwitchingQueues.cs index e7bb4ec..ce6a221 100644 --- a/Runtime/Snapshot/GhostPredictionSwitchingQueues.cs +++ b/Runtime/Snapshot/GhostPredictionSwitchingQueues.cs @@ -35,6 +35,14 @@ public struct ConvertPredictionEntry public float TransitionDurationSeconds; } +#if UNITY_EDITOR + internal struct PredictionSwitchingAnalyticsData : IComponentData + { + public long NumTimesSwitchedToPredicted; + public long NumTimesSwitchedToInterpolated; + } +#endif + /// System that applies the prediction switching on the queued entities (via ). [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] @@ -47,6 +55,9 @@ public partial struct GhostPredictionSwitchingSystem : ISystem public void OnCreate(ref SystemState state) { +#if UNITY_EDITOR + SetupAnalyticsSingleton(state.EntityManager); +#endif m_ConvertToInterpolatedQueue = new NativeQueue(Allocator.Persistent); m_ConvertToPredictedQueue = new NativeQueue(Allocator.Persistent); @@ -66,11 +77,22 @@ public void OnDestroy(ref SystemState state) m_ConvertToInterpolatedQueue.Dispose(); } +#if UNITY_EDITOR + static void SetupAnalyticsSingleton(EntityManager entityManager) + { + entityManager.CreateSingleton(); + } +#endif + [BurstCompile] public void OnUpdate(ref SystemState state) { if (m_ConvertToPredictedQueue.Count + m_ConvertToInterpolatedQueue.Count > 0) { +#if UNITY_EDITOR + UpdateAnalyticsSwitchCount(); +#endif + var netDebug = SystemAPI.GetSingleton(); var ghostUpdateVersion = SystemAPI.GetSingleton(); var prefabs = SystemAPI.GetSingletonBuffer().ToNativeArray(Allocator.Temp); @@ -105,6 +127,15 @@ public void OnUpdate(ref SystemState state) } } +#if UNITY_EDITOR + void UpdateAnalyticsSwitchCount() + { + ref var analyticsData = ref SystemAPI.GetSingletonRW().ValueRW; + analyticsData.NumTimesSwitchedToPredicted += m_ConvertToPredictedQueue.Count; + analyticsData.NumTimesSwitchedToInterpolated += m_ConvertToInterpolatedQueue.Count; + } +#endif + /// /// Convert an interpolated ghost to a predicted ghost. The ghost must support both interpolated and predicted mode, /// and it cannot be owner predicted. The new components added as a result of this operation will have the inital diff --git a/Runtime/Snapshot/GhostPredictionSystemGroup.cs b/Runtime/Snapshot/GhostPredictionSystemGroup.cs index 810884c..0c36d36 100644 --- a/Runtime/Snapshot/GhostPredictionSystemGroup.cs +++ b/Runtime/Snapshot/GhostPredictionSystemGroup.cs @@ -157,7 +157,6 @@ unsafe class NetcodeClientPredictionRateManager : IRateManager private EntityQuery m_GhostChildQuery; private NetworkTick m_LastFullPredictionTick; - readonly PredictedFixedStepSimulationSystemGroup m_PredictedFixedStepSimulationSystemGroup; private int m_TickIdx; private NetworkTick m_TargetTick; @@ -198,8 +197,6 @@ internal NetcodeClientPredictionRateManager(ComponentSystemGroup group) m_AppliedPredictedTicksQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); m_UniqueInputTicksQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); - m_PredictedFixedStepSimulationSystemGroup = group.World.GetExistingSystemManaged(); - var builder = new EntityQueryDesc { All = new[]{ComponentType.ReadWrite(), ComponentType.ReadOnly()}, @@ -236,7 +233,6 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) } m_TargetTick = m_CurrentTime.ServerTick; - m_ClientServerTickRateQuery.TryGetSingleton(out var clientServerTickRate); clientServerTickRate.ResolveDefaults(); m_FixedTimeStep = clientServerTickRate.SimulationFixedTimeStep; @@ -246,21 +242,6 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) m_TargetTick.Decrement(); m_ElapsedTime -= m_FixedTimeStep * networkTime.ServerTickFraction; } - - if (m_PredictedFixedStepSimulationSystemGroup != null) - { - var simulationTickRate = clientServerTickRate.SimulationTickRate; - var timestep = m_PredictedFixedStepSimulationSystemGroup.RateManager.Timestep; - var timestepFPS = (int)math.ceil(1f / timestep); - if (timestepFPS % simulationTickRate != 0) - { - m_PredictedFixedStepSimulationSystemGroup.RateManager.Timestep = 1f / simulationTickRate; - UnityEngine.Debug.LogWarning($"1 / {nameof(PredictedFixedStepSimulationSystemGroup)}.{nameof(ComponentSystemGroup.RateManager)}.{nameof(IRateManager.Timestep)}(ms): {timestepFPS}(FPS) " + - $"must be an integer multiple of {nameof(ClientServerTickRate)}.{nameof(ClientServerTickRate.SimulationTickRate)}:{simulationTickRate}(FPS).\n" + - $"Timestep will default to 1 / SimulationTickRate: {m_PredictedFixedStepSimulationSystemGroup.RateManager.Timestep} to fix this issue for now."); - } - } - // We must simulate the last full tick since the history backup is applied there appliedPredictedTicks.TryAdd(m_TargetTick, m_TargetTick); // We must simulate at the tick we used as last full tick last time since smoothing and error reporting is happening there @@ -431,16 +412,41 @@ unsafe class NetcodePredictionFixedRateManager : IRateManager { public float Timestep { - get; - set; + get => m_TimeStep; + set + { + m_TimeStep = value; +#if UNITY_EDITOR || NETCODE_DEBUG + m_DeprecatedTimeStep = value; +#endif + } } int m_RemainingUpdates; + float m_TimeStep; + //used to track invalid usage of the TimeStep setter. +#if UNITY_EDITOR || NETCODE_DEBUG + float m_DeprecatedTimeStep; + public float DeprecatedTimeStep + { + get=> m_DeprecatedTimeStep; + set => m_DeprecatedTimeStep = value; + } + +#endif DoubleRewindableAllocators* m_OldGroupAllocators = null; - public NetcodePredictionFixedRateManager(float fixedDeltaTime) + public NetcodePredictionFixedRateManager(float defaultTimeStep) { - Timestep = fixedDeltaTime; + SetTimeStep(defaultTimeStep); + } + + public void SetTimeStep(float timeStep) + { + m_TimeStep = timeStep; +#if UNITY_EDITOR || NETCODE_DEBUG + m_DeprecatedTimeStep = 0f; +#endif } public bool ShouldGroupUpdate(ComponentSystemGroup group) @@ -452,16 +458,16 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) group.World.RestoreGroupAllocator(m_OldGroupAllocators); --m_RemainingUpdates; } - else + else if(m_TimeStep > 0f) { - // Add epsilon to acount for floating point inaccuracy - m_RemainingUpdates = (int)((group.World.Time.DeltaTime + 0.001f) / Timestep); + // Add epsilon to account for floating point inaccuracy + m_RemainingUpdates = (int)((group.World.Time.DeltaTime + 0.001f) / m_TimeStep); } if (m_RemainingUpdates == 0) return false; group.World.PushTime(new TimeData( - elapsedTime: group.World.Time.ElapsedTime - (m_RemainingUpdates-1)*Timestep, - deltaTime: Timestep)); + elapsedTime: group.World.Time.ElapsedTime - (m_RemainingUpdates-1)*m_TimeStep, + deltaTime: m_TimeStep)); m_OldGroupAllocators = group.World.CurrentGroupAllocators; group.World.SetGroupAllocator(group.RateGroupAllocators); return true; @@ -486,6 +492,7 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group) /// Pragmatically: This group contains most of the game simulation (or, at least, all simulation that should be "predicted" /// (i.e. simulation that is the same on both client and server)). On the server, all prediction logic is treated as /// authoritative game state (although thankfully it only needs to be simulated once, as it's authoritative). + /// Note: This SystemGroup is intentionally added to non-netcode worlds, to help enable single-player testing. /// /// To reiterate: Because child systems in this group are updated so frequently (multiple times per frame on the client, /// and for all predicted ghosts on the server), this group is usually the most expensive on both builds. @@ -499,24 +506,9 @@ public partial class PredictedSimulationSystemGroup : ComponentSystemGroup {} /// - /// Temporary type for upgradability, to be removed before 1.0 - /// - [Obsolete("'GhostPredictionSystemGroup' has been renamed to 'PredictedSimulationSystemGroup'. (UnityUpgradable) -> PredictedSimulationSystemGroup")] - [DisableAutoCreation] - public partial class GhostPredictionSystemGroup : ComponentSystemGroup - {} - /// - /// Temporary type for upgradability, to be removed before 1.0 - /// - [Obsolete("'FixedStepGhostPredictionSystemGroup' has been renamed to 'PredictedFixedStepSimulationSystemGroup'. (UnityUpgradable) -> PredictedFixedStepSimulationSystemGroup")] - [DisableAutoCreation] - public partial class FixedStepGhostPredictionSystemGroup : ComponentSystemGroup - {} - - - /// - /// A fixed update group inside the ghost prediction. This is equivalent to but for prediction. - /// The fixed update group can have a higher update frequency than the rest of the prediction, and it does not do partial ticks. + /// A fixed update group inside the ghost prediction. This is equivalent to but for prediction. + /// The fixed update group can have a higher update frequency than the rest of the prediction, and it does not do partial ticks. + /// Note: This SystemGroup is intentionally added to non-netcode worlds, to help enable single-player testing. /// [WorldSystemFilter(WorldSystemFilterFlags.Default)] [UpdateInGroup(typeof(PredictedSimulationSystemGroup), OrderFirst = true)] @@ -524,28 +516,60 @@ public partial class PredictedFixedStepSimulationSystemGroup : ComponentSystemGr { private BeginFixedStepSimulationEntityCommandBufferSystem m_BeginFixedStepSimulationEntityCommandBufferSystem; private EndFixedStepSimulationEntityCommandBufferSystem m_EndFixedStepSimulationEntityCommandBufferSystem; + /// /// Set the timestep used by this group, in seconds. The default value is 1/60 seconds. - /// This value will be clamped to the range [0.0001f ... 10.0f]. /// public float Timestep { - get => RateManager != null ? RateManager.Timestep : 0; + get + { + return RateManager?.Timestep ?? 0f; + } + [Obsolete("The PredictedFixedStepSimulationSystemGroup.TimeStep setter has been deprecated and will be removed (RemovedAfter Entities 1.0)." + + "Please use the ClientServerTickRate.PredictedFixedStepSimulationTickRatio to set the desired rate for this group. " + + "Any TimeStep value set using the RateManager directly will be overwritten with the setting provided in the ClientServerTickRate", false)] set { - if (RateManager != null) - RateManager.Timestep = value; + if (RateManager != null) RateManager.Timestep = value; } } + /// + /// Set the current time step as ratio at which the this group run in respect to the simulation/prediction loop. Default value is 1, + /// that it, the group run at the same fixed rate as the . + /// + /// The ClientServerTickRate used for the simulation. + internal void ConfigureTimeStep(in ClientServerTickRate tickRate) + { + if(RateManager == null) + return; + tickRate.Validate(); + var fixedTimeStep = tickRate.PredictedFixedStepSimulationTimeStep; + var rateManager = ((NetcodePredictionFixedRateManager)RateManager); +#if UNITY_EDITOR || NETCODE_DEBUG + if (rateManager.DeprecatedTimeStep != 0f) + { + var timestep = RateManager.Timestep; + if (math.distance(timestep, fixedTimeStep) > 1e-4f) + { + UnityEngine.Debug.LogWarning($"The PredictedFixedStepSimulationSystemGroup.TimeStep is {timestep}ms ({math.ceil(1f/timestep)}FPS) but should be equals to ClientServerTickRate.PredictedFixedStepSimulationTimeStep: {fixedTimeStep}ms ({math.ceil(1f/fixedTimeStep)}FPS).\n" + + "The current timestep will be changed to match the ClientServerTickRate settings. You should never set the rate of this system directly with neither the PredictedFixedStepSimulationSystemGroup.TimeStep nor the RateManager.TimeStep method.\n " + + "Instead, you must always configure the desired rate by changing the ClientServerTickRate.PredictedFixedStepSimulationTickRatio property."); + } + } +#endif + rateManager.SetTimeStep(tickRate.PredictedFixedStepSimulationTimeStep); + } + /// /// Default constructor which sets up a fixed rate manager. /// [UnityEngine.Scripting.Preserve] public PredictedFixedStepSimulationSystemGroup() { - float defaultFixedTimestep = 1.0f / 60.0f; - SetRateManagerCreateAllocator(new NetcodePredictionFixedRateManager(defaultFixedTimestep)); + //we are passing 0 as time step so the group does not run until a proper setting is setup. + SetRateManagerCreateAllocator(new NetcodePredictionFixedRateManager(0f)); } protected override void OnCreate() { diff --git a/Runtime/Snapshot/GhostReceiveSystem.cs b/Runtime/Snapshot/GhostReceiveSystem.cs index 9e50607..264509b 100644 --- a/Runtime/Snapshot/GhostReceiveSystem.cs +++ b/Runtime/Snapshot/GhostReceiveSystem.cs @@ -203,7 +203,7 @@ public void OnCreate(ref SystemState state) m_GhostCleanupQuery = state.GetEntityQuery(builder); builder.Reset(); - builder.WithAll(); + builder.WithAll(); m_SubSceneQuery = state.GetEntityQuery(builder); m_CompressionModel = StreamCompressionModel.Default; @@ -328,7 +328,7 @@ struct ReadStreamJob : IJob public Entity GhostSpawnEntity; public NativeArray GhostCompletionCount; public NativeList TempDynamicData; - public NativeList PrespawnSceneStateArray; + public NativeList PrespawnSceneStateArray; public NetDebug NetDebug; #if NETCODE_DEBUG @@ -684,7 +684,7 @@ bool DeserializeEntity(NetworkTick serverTick, ref DataStreamReader dataStream, if (data.BaselineTick == serverTick) { //restrieve spawn tick only for non-prespawn ghosts - if (!PrespawnHelper.IsPrespawGhostId(ghostId)) + if (!PrespawnHelper.IsPrespawnGhostId(ghostId)) { serverSpawnTick = new NetworkTick{SerializedData = dataStream.ReadPackedUInt(CompressionModel)}; #if NETCODE_DEBUG @@ -743,7 +743,7 @@ bool DeserializeEntity(NetworkTick serverTick, ref DataStreamReader dataStream, snapshotDataComponent.LatestIndex = (snapshotDataComponent.LatestIndex + 1) % GhostSystemConstants.SnapshotHistorySize; SnapshotDataFromEntity[gent] = snapshotDataComponent; // If this is a prespawned ghost with no baseline tick set use the prespawn baseline - if (!data.BaselineTick.IsValid && PrespawnHelper.IsPrespawGhostId(GhostFromEntity[gent].ghostId)) + if (!data.BaselineTick.IsValid && PrespawnHelper.IsPrespawnGhostId(GhostFromEntity[gent].ghostId)) { CheckPrespawnBaselineIsPresent(gent, ghostId); var prespawnBaselineBuffer = PrespawnBaselineBufferFromEntity[gent]; @@ -866,7 +866,7 @@ bool DeserializeEntity(NetworkTick serverTick, ref DataStreamReader dataStream, var bufferPtr = (byte*)buf.GetUnsafeReadOnlyPtr(); baselineDynamicDataSize = ((uint*) bufferPtr)[baselineDynamicDataIndex]; } - else if (PrespawnHelper.IsPrespawGhostId(ghostId) && PrespawnBaselineBufferFromEntity.HasBuffer(gent)) + else if (PrespawnHelper.IsPrespawnGhostId(ghostId) && PrespawnBaselineBufferFromEntity.HasBuffer(gent)) { CheckPrespawnBaselinePtrsAreValid(data, baselineData, ghostId, baselineDynamicDataPtr); baselineDynamicDataSize = ((uint*)(baselineDynamicDataPtr))[0]; @@ -910,7 +910,7 @@ bool DeserializeEntity(NetworkTick serverTick, ref DataStreamReader dataStream, } else { - bool isPrespawn = PrespawnHelper.IsPrespawGhostId(ghostId); + bool isPrespawn = PrespawnHelper.IsPrespawnGhostId(ghostId); if (existingGhost) { // The ghost entity map is out of date, clean it up @@ -1342,7 +1342,7 @@ public void OnUpdate(ref SystemState state) var connections = m_ConnectionsQuery.ToEntityListAsync(state.WorldUpdateAllocator, out var connectionHandle); var prespawnSceneStateArray = - m_SubSceneQuery.ToComponentDataListAsync(state.WorldUpdateAllocator, + m_SubSceneQuery.ToComponentDataListAsync(state.WorldUpdateAllocator, out var prespawnHandle); ref readonly var ghostDespawnQueues = ref SystemAPI.GetSingletonRW().ValueRO; UpdateLookupsForReadStreamJob(ref state); diff --git a/Runtime/Snapshot/GhostSendSystem.cs b/Runtime/Snapshot/GhostSendSystem.cs index d4d2958..431f156 100644 --- a/Runtime/Snapshot/GhostSendSystem.cs +++ b/Runtime/Snapshot/GhostSendSystem.cs @@ -263,48 +263,48 @@ internal void Initialize() [BurstCompile] public partial struct GhostSendSystem : ISystem { - private NativeParallelHashMap m_GhostRelevancySet; + NativeParallelHashMap m_GhostRelevancySet; - private EntityQuery ghostQuery; - private EntityQuery ghostSpawnQuery; - private EntityQuery ghostDespawnQuery; - private EntityQuery prespawnSharedComponents; + EntityQuery ghostQuery; + EntityQuery ghostSpawnQuery; + EntityQuery ghostDespawnQuery; + EntityQuery prespawnSharedComponents; - private EntityQuery connectionQuery; + EntityQuery connectionQuery; - private NativeQueue m_FreeGhostIds; - private NativeArray m_AllocatedGhostIds; - private NativeList m_DestroyedPrespawns; - private NativeQueue m_DestroyedPrespawnsQueue; - private NativeReference m_DespawnAckedByAllTick; + NativeQueue m_FreeGhostIds; + NativeArray m_AllocatedGhostIds; + NativeList m_DestroyedPrespawns; + NativeQueue m_DestroyedPrespawnsQueue; + NativeReference m_DespawnAckedByAllTick; #if UNITY_EDITOR NativeArray m_UpdateLen; NativeArray m_UpdateCounts; #endif - private NativeList m_ConnectionStates; - private NativeParallelHashMap m_ConnectionStateLookup; - private StreamCompressionModel m_CompressionModel; - private NativeParallelHashMap m_SceneSectionHashLookup; + NativeList m_ConnectionStates; + NativeParallelHashMap m_ConnectionStateLookup; + StreamCompressionModel m_CompressionModel; + NativeParallelHashMap m_SceneSectionHashLookup; - private PortableFunctionPointer m_NoScaleFunction; + PortableFunctionPointer m_NoScaleFunction; - private NativeList m_ConnectionRelevantCount; - private NativeList m_ConnectionsToProcess; + NativeList m_ConnectionRelevantCount; + NativeList m_ConnectionsToProcess; #if NETCODE_DEBUG EntityQuery m_PacketLogEnableQuery; ComponentLookup m_PrefabDebugNameFromEntity; FixedString512Bytes m_LogFolder; #endif - private NativeParallelHashMap m_GhostMap; - private NativeQueue m_FreeSpawnedGhostQueue; + NativeParallelHashMap m_GhostMap; + NativeQueue m_FreeSpawnedGhostQueue; - private Unity.Profiling.ProfilerMarker m_PrioritizeChunksMarker; - private Unity.Profiling.ProfilerMarker m_GhostGroupMarker; - static readonly Unity.Profiling.ProfilerMarker k_Scheduling = new Unity.Profiling.ProfilerMarker("GhostSendSystem_Scheduling"); + Profiling.ProfilerMarker m_PrioritizeChunksMarker; + Profiling.ProfilerMarker m_GhostGroupMarker; + static readonly Profiling.ProfilerMarker k_Scheduling = new Profiling.ProfilerMarker("GhostSendSystem_Scheduling"); - private GhostPreSerializer m_GhostPreSerializer; + GhostPreSerializer m_GhostPreSerializer; ComponentLookup m_NetworkIdFromEntity; ComponentLookup m_SnapshotAckFromEntity; ComponentLookup m_GhostTypeFromEntity; @@ -403,8 +403,8 @@ public void OnCreate(ref SystemState state) state.EntityManager.SetName(spawnedGhostMap, "SpawnedGhostEntityMapSingleton"); SystemAPI.SetSingleton(new SpawnedGhostEntityMap{Value = m_GhostMap.AsReadOnly(), SpawnedGhostMapRW = m_GhostMap, ServerDestroyedPrespawns = m_DestroyedPrespawns, m_ServerAllocatedGhostIds = m_AllocatedGhostIds}); - m_PrioritizeChunksMarker = new Unity.Profiling.ProfilerMarker("PrioritizeChunks"); - m_GhostGroupMarker = new Unity.Profiling.ProfilerMarker("GhostGroup"); + m_PrioritizeChunksMarker = new Profiling.ProfilerMarker("PrioritizeChunks"); + m_GhostGroupMarker = new Profiling.ProfilerMarker("GhostGroup"); #if NETCODE_DEBUG m_PacketLogEnableQuery = state.GetEntityQuery(ComponentType.ReadOnly()); @@ -622,9 +622,9 @@ struct SerializeJob : IJobParallelForDefer [ReadOnly] public BufferLookup GhostTypeCollectionFromEntity; [ReadOnly] public BufferLookup GhostComponentIndexFromEntity; [ReadOnly] public BufferLookup GhostCollectionFromEntity; - [NativeDisableContainerSafetyRestriction] private DynamicBuffer GhostComponentCollection; - [NativeDisableContainerSafetyRestriction] private DynamicBuffer GhostTypeCollection; - [NativeDisableContainerSafetyRestriction] private DynamicBuffer GhostComponentIndex; + [NativeDisableContainerSafetyRestriction] DynamicBuffer GhostComponentCollection; + [NativeDisableContainerSafetyRestriction] DynamicBuffer GhostTypeCollection; + [NativeDisableContainerSafetyRestriction] DynamicBuffer GhostComponentIndex; public ConcurrentDriverStore concurrentDriverStore; [ReadOnly] public NativeList despawnChunks; [ReadOnly] public NativeList ghostChunks; @@ -698,8 +698,8 @@ struct SerializeJob : IJobParallelForDefer ConnectionStateData.GhostStateList ghostStateData; int connectionIdx; - public Unity.Profiling.ProfilerMarker prioritizeChunksMarker; - public Unity.Profiling.ProfilerMarker ghostGroupMarker; + public Profiling.ProfilerMarker prioritizeChunksMarker; + public Profiling.ProfilerMarker ghostGroupMarker; public uint FirstSendImportanceMultiplier; public int MinSendImportance; @@ -777,10 +777,9 @@ public unsafe void Execute(int idx) serializeResult = sendEntities(ref driver, ref dataStream, ghostChunkComponentTypesPtr, ghostChunkComponentTypesLength); if (serializeResult == SerializeEnitiesResult.Ok) { - result = 0; if ((result = driver.EndSend(dataStream)) < (int)Networking.Transport.Error.StatusCode.Success) { - netDebug.LogWarning(FixedString.Format("An error occurred during EndSend. ErrorCode: {0}", result)); + netDebug.LogWarning($"Failed to send a snapshot to a client with EndSend error: {result}!"); } } else @@ -808,14 +807,14 @@ public unsafe void Execute(int idx) } else { - netDebug.LogError(FixedString.Format("Failed to send a snapshot to a client with error {0}", result)); + netDebug.LogError($"Failed to send a snapshot to a client with BeginSend error: {result}!"); } targetSnapshotSize += targetSnapshotSize; } } - private unsafe SerializeEnitiesResult sendEntities(ref NetworkDriver.Concurrent driver, ref DataStreamWriter dataStream, DynamicComponentTypeHandle* ghostChunkComponentTypesPtr, int ghostChunkComponentTypesLength) + unsafe SerializeEnitiesResult sendEntities(ref NetworkDriver.Concurrent driver, ref DataStreamWriter dataStream, DynamicComponentTypeHandle* ghostChunkComponentTypesPtr, int ghostChunkComponentTypesLength) { #if NETCODE_DEBUG FixedString512Bytes debugLog = default; @@ -1131,7 +1130,7 @@ uint EncodeGhostId(int ghostId) } #if NETCODE_DEBUG FixedString512Bytes debugLog = default; - FixedString32Bytes msg = "\t[Despawn IDs]\n"; + FixedString64Bytes msg = "\t[Despawn IDs]\n"; #endif uint despawnLen = 0; ghostStateData.AckedDespawnTick = ackTick; @@ -1341,7 +1340,8 @@ uint EncodeGhostId(int ghostId) #endif return despawnLen; } - private int FindGhostTypeIndex(Entity ent) + + int FindGhostTypeIndex(Entity ent) { var GhostCollection = GhostCollectionFromEntity[GhostCollectionSingleton]; int ghostType; @@ -1439,7 +1439,7 @@ NativeList GatherGhostChunks(out int maxCount, out int totalCount) return serialChunks; } - private static unsafe IntPtr GetComponentPtrInChunk( + static unsafe IntPtr GetComponentPtrInChunk( EntityStorageInfo storageInfo, DynamicComponentTypeHandle connectionDataTypeHandle, int typeSize) @@ -1449,7 +1449,7 @@ NativeList GatherGhostChunks(out int maxCount, out int totalCount) return (IntPtr)ptr; } - private bool TryGetChunkStateOrNew(ArchetypeChunk ghostChunk, out GhostChunkSerializationState chunkState) + bool TryGetChunkStateOrNew(ArchetypeChunk ghostChunk, out GhostChunkSerializationState chunkState) { if (chunkSerializationData.TryGetValue(ghostChunk, out chunkState)) { @@ -1464,13 +1464,13 @@ private bool TryGetChunkStateOrNew(ArchetypeChunk ghostChunk, out GhostChunkSeri return AddNewChunk(ghostChunk, ref chunkState); } - private void RemoveGhostChunk(ArchetypeChunk ghostChunk, GhostChunkSerializationState chunkState) + void RemoveGhostChunk(ArchetypeChunk ghostChunk, GhostChunkSerializationState chunkState) { chunkState.FreeSnapshotData(); chunkSerializationData.Remove(ghostChunk); } - private bool AddNewChunk(ArchetypeChunk ghostChunk, ref GhostChunkSerializationState chunkState) + bool AddNewChunk(ArchetypeChunk ghostChunk, ref GhostChunkSerializationState chunkState) { var ghosts = ghostChunk.GetNativeArray(ref ghostComponentType); if (!TryGetChunkGhostType(ghostChunk, ghosts, out var chunkGhostType)) @@ -1498,7 +1498,7 @@ private bool AddNewChunk(ArchetypeChunk ghostChunk, ref GhostChunkSerializationS return true; } - private bool TryGetChunkGhostType(ArchetypeChunk ghostChunk, NativeArray ghosts, out int chunkGhostType) + bool TryGetChunkGhostType(ArchetypeChunk ghostChunk, NativeArray ghosts, out int chunkGhostType) { chunkGhostType = ghosts[0].ghostType; // Pre spawned ghosts might not have a proper ghost type index yet, we calculate it here for pre spawns @@ -1515,7 +1515,7 @@ private bool TryGetChunkGhostType(ArchetypeChunk ghostChunk, NativeArray= 0) + if (PrespawnHelper.IsPrespawnGhostId(ghost.ghostId) && PrespawnIdRanges.GhostIdRangeIndex(ghost.ghostId) >= 0) PrespawnDespawn.Enqueue(ghost.ghostId); } } #if UNITY_EDITOR || NETCODE_DEBUG - private void UpdateNetStats(ref GhostStatsCollectionSnapshot netStats, NetworkTick serverTick) + void UpdateNetStats(ref GhostStatsCollectionSnapshot netStats, NetworkTick serverTick) { #if UNITY_2022_2_14F1_OR_NEWER int maxThreadCount = JobsUtility.ThreadIndexCount; diff --git a/Runtime/Snapshot/GhostSerializationHelper.cs b/Runtime/Snapshot/GhostSerializationHelper.cs index 0cf0c7c..5f49d06 100644 --- a/Runtime/Snapshot/GhostSerializationHelper.cs +++ b/Runtime/Snapshot/GhostSerializationHelper.cs @@ -63,6 +63,7 @@ private void CheckValidSnapshotOffset(int compSnapshotSize) [BurstCompile] internal void CopyComponentToSnapshot(ArchetypeChunk chunk, int ent, in GhostComponentSerializer.State serializer) { + if(!serializer.HasGhostFields) return; var compSize = serializer.ComponentSize; var compData = (byte*) chunk.GetDynamicComponentDataArrayReinterpret(ref typeHandle, compSize).GetUnsafeReadOnlyPtr(); CheckValidSnapshotOffset(serializer.SnapshotSize); diff --git a/Runtime/Snapshot/GhostSpawnClassificationSystem.cs b/Runtime/Snapshot/GhostSpawnClassificationSystem.cs index fa8b8b9..d220946 100644 --- a/Runtime/Snapshot/GhostSpawnClassificationSystem.cs +++ b/Runtime/Snapshot/GhostSpawnClassificationSystem.cs @@ -123,6 +123,25 @@ public bool HasClassifiedPredictedSpawn /// internal int SectionIndex; } + + /// + /// Contains all the system that classify spawned ghost. Runs after the system. + /// Your custom classification system should be updated into this group. + /// + /// [UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))] + /// public partial struct MyCustomClassificationSystemGroup + /// { + /// ... + /// } + /// + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation, WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateBefore(typeof(GhostInputSystemGroup))] + public partial class GhostSpawnClassificationSystemGroup : ComponentSystemGroup + { + } + /// /// The default GhostSpawnClassificationSystem will set the SpawnType to the default specified in the /// GhostAuthoringComponent, unless some other classification has already set the SpawnType. This system @@ -132,8 +151,7 @@ public bool HasClassifiedPredictedSpawn /// The reason to put predictive spawn systems after the default is so the owner predicted logic has run. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(GhostSimulationSystemGroup))] - [UpdateAfter(typeof(GhostReceiveSystem))] + [UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))] [CreateAfter(typeof(GhostCollectionSystem))] [CreateAfter(typeof(GhostReceiveSystem))] [BurstCompile] @@ -196,8 +214,7 @@ public void Execute(DynamicBuffer ghosts, in DynamicBuffer [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(GhostSimulationSystemGroup), OrderLast = true)] - [UpdateAfter(typeof(GhostSpawnClassificationSystem))] + [UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup), OrderLast = true)] [BurstCompile] internal partial struct DefaultGhostSpawnClassificationSystem : ISystem { diff --git a/Runtime/Snapshot/GhostSpawnSystem.cs b/Runtime/Snapshot/GhostSpawnSystem.cs index 131b2b7..c2368f9 100644 --- a/Runtime/Snapshot/GhostSpawnSystem.cs +++ b/Runtime/Snapshot/GhostSpawnSystem.cs @@ -158,7 +158,7 @@ public unsafe void OnUpdate(ref SystemState state) } } stateEntityManager.SetComponentData(entity, new GhostInstance {ghostId = ghost.GhostID, ghostType = ghost.GhostType, spawnTick = ghost.ServerSpawnTick}); - if (PrespawnHelper.IsPrespawGhostId(ghost.GhostID)) + if (PrespawnHelper.IsPrespawnGhostId(ghost.GhostID)) ConfigurePrespawnGhost(ref stateEntityManager, entity, ghost); var newBuffer = stateEntityManager.GetBuffer(entity); newBuffer.ResizeUninitialized(snapshotSize * GhostSystemConstants.SnapshotHistorySize); @@ -255,7 +255,7 @@ unsafe Entity AddToDelayedSpawnQueue(ref EntityManager entityManager, NativeQueu var entity = entityManager.CreateEntity(); entityManager.AddComponentData(entity, new GhostInstance { ghostId = ghost.GhostID, ghostType = ghost.GhostType, spawnTick = ghost.ServerSpawnTick }); entityManager.AddComponent(entity); - if (PrespawnHelper.IsPrespawGhostId(ghost.GhostID)) + if (PrespawnHelper.IsPrespawnGhostId(ghost.GhostID)) ConfigurePrespawnGhost(ref entityManager, entity, ghost); var newBuffer = entityManager.AddBuffer(entity); @@ -324,7 +324,7 @@ unsafe bool TrySpawnFromDelayedQueue(ref EntityManager entityManager, in Delayed } } entityManager.SetComponentData(entity, entityManager.GetComponentData(ghost.oldEntity)); - if (PrespawnHelper.IsPrespawGhostId(ghost.ghostId)) + if (PrespawnHelper.IsPrespawnGhostId(ghost.ghostId)) { entityManager.AddComponentData(entity, entityManager.GetComponentData(ghost.oldEntity)); entityManager.AddSharedComponent(entity, entityManager.GetSharedComponent(ghost.oldEntity)); diff --git a/Runtime/Snapshot/GhostUpdateSystem.cs b/Runtime/Snapshot/GhostUpdateSystem.cs index 7995fc0..040c1c6 100644 --- a/Runtime/Snapshot/GhostUpdateSystem.cs +++ b/Runtime/Snapshot/GhostUpdateSystem.cs @@ -7,11 +7,9 @@ using Unity.Burst.Intrinsics; using Unity.Jobs.LowLevel.Unsafe; using Unity.Collections.LowLevel.Unsafe; -using Unity.Entities.LowLevel.Unsafe; using Unity.Jobs; using Unity.NetCode.LowLevel.Unsafe; using Unity.Mathematics; -using static Unity.Entities.SystemAPI; namespace Unity.NetCode { @@ -29,6 +27,7 @@ struct GhostPredictionGroupTickState : IComponentData ///
    [UpdateInGroup(typeof(GhostSimulationSystemGroup))] [UpdateAfter(typeof(GhostReceiveSystem))] + [UpdateBefore(typeof(GhostSpawnClassificationSystemGroup))] [UpdateBefore(typeof(GhostInputSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [BurstCompile] @@ -159,6 +158,11 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE var PredictedGhostArray = chunk.GetNativeArray(ref PredictedGhostType); bool canBeStatic = typeData.StaticOptimization; bool isPrespawn = chunk.Has(ref prespawnGhostIndexType); + var restoreFromBackupRange = new NativeList(ghostComponents.Length, Allocator.Temp); + var chunkEntities = chunk.GetNativeArray(entityType); + var hasBackupState = predictionStateBackup.TryGetValue(chunk, out var predictionBackuptState); + hasBackupState = hasBackupState && (*(PredictionBackupState*)predictionBackuptState).entityCapacity == chunk.Capacity; + // Find the ranges of entities which have data to apply, store the data to apply in an array while doing so for (int ent = firstGhost; ent < ghostComponents.Length; ++ent) { @@ -234,8 +238,11 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if (predictionStartTick != snapshotTick && predictionStartTick != lastPredictedTick) { // If we cannot restore the backup and continue prediction, we roll back and resimulate - if (!RestorePredictionBackup(chunk, ent, typeData, ghostChunkComponentTypesPtr, ghostChunkComponentTypesLength)) + if(!hasBackupState || !PredictionBackupState.MatchEntity(predictionBackuptState, ent, chunkEntities[ent])) predictionStartTick = snapshotTick; + else + // If we cannot restore the backup and continue prediction, we roll back and resimulate + restoreFromBackupRange.Add(ent); } AddPredictionStartTick(targetTick, predictionStartTick); @@ -290,9 +297,12 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE // This is a predicted snapshot which does not have any state at all to roll back to, just let it continue from it's last state if possible var predictionStartTick = lastPredictedTick; // Try to restore from backup if last tick was a partial tick - if (!predictionStartTick.IsValid && predictionStateBackupTick.IsValid && - RestorePredictionBackup(chunk, ent, typeData, ghostChunkComponentTypesPtr, ghostChunkComponentTypesLength)) + if (!predictionStartTick.IsValid && predictionStateBackupTick.IsValid && hasBackupState && + PredictionBackupState.MatchEntity(predictionBackuptState, ent, chunkEntities[ent])) + { predictionStartTick = predictionStateBackupTick; + restoreFromBackupRange.Add(ent); + } if (!predictionStartTick.IsValid) { // There was no last state to continue from, so do not run prediction at all @@ -308,7 +318,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if (nextRange.y != 0) entityRange.Add(nextRange); - var requiredSendMask = predicted ? GhostComponentSerializer.SendMask.Predicted : GhostComponentSerializer.SendMask.Interpolated; + var requiredSendMask = predicted ? GhostSendType.OnlyPredictedClients : GhostSendType.OnlyInterpolatedClients; int numBaseComponents = typeData.NumComponents - typeData.NumChildComponents; // This buffer allowing us to MemCmp changes, which allows us to support change filtering. @@ -316,6 +326,13 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE byte* tempChangeBuffer = stackalloc byte[tempChangeBufferSize]; NativeArray tempChangeBufferLarge = default; + if(restoreFromBackupRange.Length > 0) + { + k_RestoreFromBackup.Begin(); + RestorePredictionBackup(chunk, predictionBackuptState, restoreFromBackupRange, typeData, ghostChunkComponentTypesPtr, ghostChunkComponentTypesLength); + k_RestoreFromBackup.End(); + } + var enableableMaskOffset = 0; for (int comp = 0; comp < numBaseComponents; ++comp) { @@ -325,9 +342,10 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE if (compIdx >= ghostChunkComponentTypesLength) throw new System.InvalidOperationException("Component index out of range"); #endif - var snapshotSize = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer + var ghostSerializer = GhostComponentCollection[serializerIdx]; + var snapshotSize = ghostSerializer.ComponentType.IsBuffer ? GhostComponentSerializer.SnapshotSizeAligned(GhostSystemConstants.DynamicBufferComponentSnapshotSize) - : GhostComponentSerializer.SnapshotSizeAligned(GhostComponentCollection[serializerIdx].SnapshotSize); + : GhostComponentSerializer.SnapshotSizeAligned(ghostSerializer.SnapshotSize); if (!chunk.Has(ref ghostChunkComponentTypesPtr[compIdx]) || (GhostComponentIndex[typeData.FirstComponent + comp].SendMask&requiredSendMask) == 0) { snapshotDataOffset += snapshotSize; @@ -335,42 +353,35 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } var componentHasChanges = false; - var compSize = GhostComponentCollection[serializerIdx].ComponentSize; - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + var compSize = ghostSerializer.ComponentSize; + if (!ghostSerializer.ComponentType.IsBuffer) { - deserializerState.SendToOwner = GhostComponentCollection[serializerIdx].SendToOwner; - if (GhostComponentCollection[serializerIdx].HasGhostFields) + deserializerState.SendToOwner = ghostSerializer.SendToOwner; + if (ghostSerializer.HasGhostFields) { var roDynamicComponentTypeHandle = ghostChunkComponentTypesPtr[compIdx].CopyToReadOnly(); - var roCompArray = chunk.GetDynamicComponentDataArrayReinterpret(ref roDynamicComponentTypeHandle, compSize); - + // 1. Get Readonly version from chunk. We always reaad/write from/to this pointer. It is stable and does not change. + var compDataPtr = (byte*)chunk.GetDynamicComponentDataArrayReinterpret(ref roDynamicComponentTypeHandle, compSize).GetUnsafeReadOnlyPtr(); for (var rangeIdx = 0; rangeIdx < entityRange.Length; ++rangeIdx) { var range = entityRange[rangeIdx]; - - var snapshotData = (byte*) dataAtTick.GetUnsafeReadOnlyPtr(); + var snapshotData = (byte*)dataAtTick.GetUnsafeReadOnlyPtr(); snapshotData += snapshotDataAtTickSize * range.x; - // Fast path: If we already have changes, just fetch the RW version and write directly. if (componentHasChanges) { - var rwCompArray = chunk.GetDynamicComponentDataArrayReinterpret(ref ghostChunkComponentTypesPtr[compIdx], compSize); - var rwCompData = (byte*) rwCompArray.GetUnsafePtr(); - rwCompData += range.x * compSize; - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke((System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) snapshotData, snapshotDataOffset, snapshotDataAtTickSize, (System.IntPtr) rwCompData, compSize, range.y - range.x); + var rwCompData = compDataPtr + range.x * compSize; + ghostSerializer.CopyFromSnapshot.Ptr.Invoke((System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) snapshotData, snapshotDataOffset, snapshotDataAtTickSize, (System.IntPtr) rwCompData, compSize, range.y - range.x); continue; } - // 1. Get Readonly version from chunk. - var roCompData = (byte*) roCompArray.GetUnsafeReadOnlyPtr(); - roCompData += range.x * compSize; - + var roCompData = compDataPtr + range.x * compSize; // 2. Copy it into a temp buffer large enough to hold values (inside the range loop). var requiredNumBytes = (range.y - range.x) * compSize; CopyRODataIntoTempChangeBuffer(requiredNumBytes, ref tempChangeBuffer, ref tempChangeBufferSize, ref tempChangeBufferLarge, roCompData); // 3. Invoke CopyFromSnapshot with the ro buffer as destination (yes, hacky!). - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke((System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) snapshotData, snapshotDataOffset, snapshotDataAtTickSize, (System.IntPtr) roCompData, compSize, range.y - range.x); + ghostSerializer.CopyFromSnapshot.Ptr.Invoke((System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) snapshotData, snapshotDataOffset, snapshotDataAtTickSize, (System.IntPtr) roCompData, compSize, range.y - range.x); // 4. Compare the two buffers (for changes). k_ChangeFiltering.Begin(); @@ -385,15 +396,13 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE snapshotDataOffset += snapshotSize; } - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) { for (var rangeIdx = 0; rangeIdx < entityRange.Length; ++rangeIdx) { var range = entityRange[rangeIdx]; - var snapshotData = (byte*) dataAtTick.GetUnsafeReadOnlyPtr(); - snapshotData += snapshotDataAtTickSize * range.x; - var dataAtTickPtr = (SnapshotData.DataAtTick*) snapshotData; - + var dataAtTickPtr = (SnapshotData.DataAtTick*)dataAtTick.GetUnsafeReadOnlyPtr(); + dataAtTickPtr += range.x; UpdateEnableableMask(chunk, dataAtTickPtr, changeMaskUints, enableableMaskOffset, range, ghostChunkComponentTypesPtr, compIdx, ref componentHasChanges); } ++enableableMaskOffset; @@ -403,11 +412,10 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE else { var roDynamicComponentTypeHandle = ghostChunkComponentTypesPtr[compIdx].CopyToReadOnly(); - var roBufferAccessor = chunk.GetUntypedBufferAccessor(ref roDynamicComponentTypeHandle); - UnsafeUntypedBufferAccessor rwBufferAccessor = default; - var dynamicDataSize = GhostComponentCollection[serializerIdx].SnapshotSize; - var maskBits = GhostComponentCollection[serializerIdx].ChangeMaskBits; - deserializerState.SendToOwner = GhostComponentCollection[serializerIdx].SendToOwner; + var bufferAccessor = chunk.GetUntypedBufferAccessor(ref roDynamicComponentTypeHandle); + var dynamicDataSize = ghostSerializer.SnapshotSize; + var maskBits = ghostSerializer.ChangeMaskBits; + deserializerState.SendToOwner = ghostSerializer.SendToOwner; for (var rangeIdx = 0; rangeIdx < entityRange.Length; ++rangeIdx) { var range = entityRange[rangeIdx]; @@ -420,22 +428,24 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE var requiredOwnerMask = dataAtTick[ent].GhostOwner == deserializerState.GhostOwner ? SendToOwnerType.SendToOwner : SendToOwnerType.SendToNonOwner; - if ((GhostComponentCollection[serializerIdx].SendToOwner & requiredOwnerMask) == 0) + if ((ghostSerializer.SendToOwner & requiredOwnerMask) == 0) continue; } var dynamicDataBuffer = ghostSnapshotDynamicBufferArray[ent]; var dynamicDataAtTick = SetupDynamicDataAtTick(dataAtTick[ent], snapshotDataOffset, dynamicDataSize, maskBits, dynamicDataBuffer, out var bufLen); - var prevBufLen = roBufferAccessor.GetBufferLength(ent); - - componentHasChanges |= prevBufLen != bufLen; - if (componentHasChanges) + var prevBufLen = bufferAccessor.GetBufferLength(ent); + if(prevBufLen != bufLen) { - // FIXME: Only fetch this buffer if it's default. - rwBufferAccessor = chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); - rwBufferAccessor.ResizeUninitialized(ent, bufLen); - var rwBufData = rwBufferAccessor.GetUnsafePtr(ent); - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke( + if (!componentHasChanges) + { + componentHasChanges = true; + // Bump change version. + bufferAccessor = chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); + } + bufferAccessor.ResizeUninitialized(ent, bufLen); + var rwBufData = (byte*)bufferAccessor.GetUnsafePtr(ent); + ghostSerializer.CopyFromSnapshot.Ptr.Invoke( (System.IntPtr)UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) UnsafeUtility.AddressOf(ref dynamicDataAtTick), 0, dynamicDataSize, (IntPtr)rwBufData, compSize, bufLen); @@ -443,12 +453,12 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } var requiredNumBytes = bufLen * compSize; - var roBufData = (byte*) roBufferAccessor.GetUnsafeReadOnlyPtr(ent); + var roBufData = (byte*) bufferAccessor.GetUnsafeReadOnlyPtr(ent); CopyRODataIntoTempChangeBuffer(requiredNumBytes, ref tempChangeBuffer, ref tempChangeBufferSize, ref tempChangeBufferLarge, roBufData); // Again, hack to pass in the roBufData to be written into. // NOTE: We know that these two buffers will be the EXACT same size, due to the above assurances. - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke( + ghostSerializer.CopyFromSnapshot.Ptr.Invoke( (System.IntPtr)UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) UnsafeUtility.AddressOf(ref dynamicDataAtTick), 0, dynamicDataSize, (IntPtr)roBufData, compSize, bufLen); @@ -456,21 +466,25 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE k_ChangeFiltering.Begin(); if (UnsafeUtility.MemCmp(roBufData, tempChangeBuffer, requiredNumBytes) != 0) { - componentHasChanges = true; - // Bump change version. - chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); + if (!componentHasChanges) + { + componentHasChanges = true; + // Bump change version. + bufferAccessor = chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); + }; } k_ChangeFiltering.End(); } - var snapshotData = (byte*)dataAtTick.GetUnsafeReadOnlyPtr(); - snapshotData += snapshotDataAtTickSize * range.x; - var dataAtTickPtr = (SnapshotData.DataAtTick*) snapshotData; - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + //The following will update the enable bits for the whole chunk. So the data should be retrieved from the + //beginning of the range + var dataAtTickPtr = (SnapshotData.DataAtTick*)dataAtTick.GetUnsafeReadOnlyPtr(); + dataAtTickPtr += range.x; + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) UpdateEnableableMask(chunk, dataAtTickPtr, changeMaskUints, enableableMaskOffset, range, ghostChunkComponentTypesPtr, compIdx, ref componentHasChanges); } - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) { ++enableableMaskOffset; ValidateReadEnableBits(enableableMaskOffset, typeData.EnableableBits); @@ -491,19 +505,20 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE throw new System.InvalidOperationException("Component index out of range"); #endif - var snapshotSize = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer + var ghostSerializer = GhostComponentCollection[serializerIdx]; + var snapshotSize = ghostSerializer.ComponentType.IsBuffer ? GhostComponentSerializer.SnapshotSizeAligned(GhostSystemConstants.DynamicBufferComponentSnapshotSize) - : GhostComponentSerializer.SnapshotSizeAligned(GhostComponentCollection[serializerIdx].SnapshotSize); + : GhostComponentSerializer.SnapshotSizeAligned(ghostSerializer.SnapshotSize); if ((GhostComponentIndex[typeData.FirstComponent + comp].SendMask & requiredSendMask) == 0) { snapshotDataOffset += snapshotSize; continue; } - var compSize = GhostComponentCollection[serializerIdx].ComponentSize; - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + var compSize = ghostSerializer.ComponentSize; + if (!ghostSerializer.ComponentType.IsBuffer) { - deserializerState.SendToOwner = GhostComponentCollection[serializerIdx].SendToOwner; + deserializerState.SendToOwner = ghostSerializer.SendToOwner; for (var rangeIdx = 0; rangeIdx < entityRange.Length; ++rangeIdx) { var range = entityRange[rangeIdx]; @@ -518,9 +533,9 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE continue; // We fetch these via `GetUnsafeReadOnlyPtr` only for performance reasons. It's safe. - var snapshotData = (byte*)dataAtTick.GetUnsafeReadOnlyPtr(); - snapshotData += snapshotDataAtTickSize * ent; - if (GhostComponentCollection[serializerIdx].HasGhostFields) + var dataAtTickPtr = (SnapshotData.DataAtTick*)dataAtTick.GetUnsafeReadOnlyPtr(); + dataAtTickPtr += ent; + if (ghostSerializer.HasGhostFields) { // No fast-path here! // 1. Get Readonly version from chunk. @@ -534,7 +549,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE CopyRODataIntoTempChangeBuffer(requiredNumBytes, ref tempChangeBuffer, ref tempChangeBufferSize, ref tempChangeBufferLarge, roCompData); // 3. Invoke CopyFromSnapshot with the ro buffer as destination (yes, hacky!). - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke((System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) snapshotData, snapshotDataOffset, snapshotDataAtTickSize, (System.IntPtr) roCompData, compSize, 1); + ghostSerializer.CopyFromSnapshot.Ptr.Invoke((System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) dataAtTickPtr, snapshotDataOffset, snapshotDataAtTickSize, (System.IntPtr) roCompData, compSize, 1); // 4. MemCmp the two buffers. k_ChangeFiltering.Begin(); @@ -545,9 +560,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } k_ChangeFiltering.End(); } - - var dataAtTickPtr = (SnapshotData.DataAtTick*) snapshotData; - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) { var childRange = new int2 { x = childChunk.IndexInChunk, y = childChunk.IndexInChunk + 1 }; var unused = false; @@ -555,7 +568,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } } } - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) { ++enableableMaskOffset; ValidateReadEnableBits(enableableMaskOffset, typeData.EnableableBits); @@ -564,16 +577,16 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } else { - var dynamicDataSize = GhostComponentCollection[serializerIdx].SnapshotSize; - var maskBits = GhostComponentCollection[serializerIdx].ChangeMaskBits; - deserializerState.SendToOwner = GhostComponentCollection[serializerIdx].SendToOwner; + var dynamicDataSize = ghostSerializer.SnapshotSize; + var maskBits = ghostSerializer.ChangeMaskBits; + deserializerState.SendToOwner = ghostSerializer.SendToOwner; for (var rangeIdx = 0; rangeIdx < entityRange.Length; ++rangeIdx) { var range = entityRange[rangeIdx]; var maskOffset = enableableMaskOffset; - for (int ent = range.x; ent < range.y; ++ent) + for (int rootEntity = range.x; rootEntity < range.y; ++rootEntity) { - var linkedEntityGroup = linkedEntityGroupAccessor[ent]; + var linkedEntityGroup = linkedEntityGroupAccessor[rootEntity]; var childEntity = linkedEntityGroup[GhostComponentIndex[typeData.FirstComponent + comp].EntityIndex].Value; if (!childEntityLookup.Exists(childEntity)) continue; @@ -582,9 +595,9 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE continue; //Compute the required owner mask for the buffers and skip the copyfromsnapshot. The check must be done - if (dataAtTick[ent].GhostOwner > 0) + if (dataAtTick[rootEntity].GhostOwner > 0) { - var requiredOwnerMask = dataAtTick[ent].GhostOwner == deserializerState.GhostOwner + var requiredOwnerMask = dataAtTick[rootEntity].GhostOwner == deserializerState.GhostOwner ? SendToOwnerType.SendToOwner : SendToOwnerType.SendToNonOwner; if ((deserializerState.SendToOwner & requiredOwnerMask) == 0) @@ -594,30 +607,29 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE var roDynamicComponentTypeHandle = ghostChunkComponentTypesPtr[compIdx].CopyToReadOnly(); var roBufferAccessor = childChunk.Chunk.GetUntypedBufferAccessor(ref roDynamicComponentTypeHandle); - var dynamicDataBuffer = ghostSnapshotDynamicBufferArray[ent]; - var dynamicDataAtTick = SetupDynamicDataAtTick(dataAtTick[ent], snapshotDataOffset, dynamicDataSize, maskBits, dynamicDataBuffer, out var bufLen); - var prevBufLen = roBufferAccessor.GetBufferLength(ent); - + var dynamicDataBuffer = ghostSnapshotDynamicBufferArray[rootEntity]; + var dynamicDataAtTick = SetupDynamicDataAtTick(dataAtTick[rootEntity], snapshotDataOffset, dynamicDataSize, maskBits, dynamicDataBuffer, out var bufLen); + var prevBufLen = roBufferAccessor.GetBufferLength(childChunk.IndexInChunk); if (prevBufLen != bufLen) { var rwBufferAccessor = childChunk.Chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); - rwBufferAccessor.ResizeUninitialized(ent, bufLen); - var rwBufData = rwBufferAccessor.GetUnsafePtr(ent); + rwBufferAccessor.ResizeUninitialized(childChunk.IndexInChunk, bufLen); + var rwBufData = rwBufferAccessor.GetUnsafePtr(childChunk.IndexInChunk); - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke( + ghostSerializer.CopyFromSnapshot.Ptr.Invoke( (System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) UnsafeUtility.AddressOf(ref dynamicDataAtTick), 0, dynamicDataSize, (IntPtr) rwBufData, compSize, bufLen); } else { - var roBufData = (byte*) roBufferAccessor.GetUnsafeReadOnlyPtr(ent); + var roBufData = (byte*) roBufferAccessor.GetUnsafeReadOnlyPtr(childChunk.IndexInChunk); var requiredNumBytes = bufLen * compSize; CopyRODataIntoTempChangeBuffer(requiredNumBytes, ref tempChangeBuffer, ref tempChangeBufferSize, ref tempChangeBufferLarge, roBufData); // Again, hack to pass in the roBufData to be written into. // NOTE: We know that these two buffers will be the EXACT same size, due to the above assurances. - GhostComponentCollection[serializerIdx].CopyFromSnapshot.Ptr.Invoke( + ghostSerializer.CopyFromSnapshot.Ptr.Invoke( (System.IntPtr) UnsafeUtility.AddressOf(ref deserializerState), (System.IntPtr) UnsafeUtility.AddressOf(ref dynamicDataAtTick), 0, dynamicDataSize, (IntPtr) roBufData, compSize, bufLen); @@ -632,10 +644,10 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE k_ChangeFiltering.End(); } - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) { var snapshotData = (byte*) dataAtTick.GetUnsafeReadOnlyPtr(); - snapshotData += snapshotDataAtTickSize * ent; + snapshotData += snapshotDataAtTickSize * rootEntity; var dataAtTickPtr = (SnapshotData.DataAtTick*) snapshotData; var childRange = new int2 {x = childChunk.IndexInChunk, y = childChunk.IndexInChunk + 1}; @@ -644,7 +656,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE } } } - if (typeData.EnableableBits > 0 && GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + if (typeData.EnableableBits > 0 && ghostSerializer.SerializesEnabledBit != 0) { ++enableableMaskOffset; ValidateReadEnableBits(enableableMaskOffset, typeData.EnableableBits); @@ -698,7 +710,7 @@ private static void CopyRODataIntoTempChangeBuffer(int requiredCompDataLength, r } } - SnapshotData.DataAtTick SetupDynamicDataAtTick(in SnapshotData.DataAtTick dataAtTick, + static SnapshotData.DataAtTick SetupDynamicDataAtTick(in SnapshotData.DataAtTick dataAtTick, int snapshotOffset, int snapshotSize, int maskBits, in DynamicBuffer ghostSnapshotDynamicBuffer, out int buffernLen) { // Retrieve from the snapshot the buffer information and @@ -727,22 +739,16 @@ private static void CopyRODataIntoTempChangeBuffer(int requiredCompDataLength, r Tick = dataAtTick.Tick }; } - // TODO - Determine if we need change filtering on RestoreFromBackup. - bool RestorePredictionBackup(ArchetypeChunk chunk, int ent, in GhostCollectionPrefabSerializer typeData, DynamicComponentTypeHandle* ghostChunkComponentTypesPtr, int ghostChunkComponentTypesLength) - { - // Try to get the backup state - if (!predictionStateBackup.TryGetValue(chunk, out var state) || - (*(PredictionBackupState*)state).entityCapacity != chunk.Capacity) - return false; - // Verify that the backup is for the correct entity - Entity* entities = PredictionBackupState.GetEntities(state); - var entity = chunk.GetNativeArray(entityType)[ent]; - if (entity != entities[ent]) - return false; + void RestorePredictionBackup(ArchetypeChunk chunk, IntPtr state, NativeList toRestore, in GhostCollectionPrefabSerializer typeData, DynamicComponentTypeHandle* ghostChunkComponentTypesPtr, int ghostChunkComponentTypesLength) + { + // The prediction state is assured to exist if this method is called. But we add some checks here to ensure assumptions are correct. + Assertions.Assert.IsTrue(state != IntPtr.Zero); + // Also if we call this, toRestore length MUST be greater than 0 + Assertions.Assert.IsTrue(toRestore.Length > 0); int baseOffset = typeData.FirstComponent; - const GhostComponentSerializer.SendMask requiredSendMask = GhostComponentSerializer.SendMask.Predicted; + const GhostSendType requiredSendMask = GhostSendType.OnlyPredictedClients; byte* dataPtr = PredictionBackupState.GetData(state); ulong* enabledBitPtr = PredictionBackupState.GetEnabledBits(state); @@ -751,6 +757,7 @@ bool RestorePredictionBackup(ArchetypeChunk chunk, int ent, in GhostCollectionPr int numBaseComponents = typeData.NumComponents - typeData.NumChildComponents; var ghostOwner = PredictionBackupState.GetGhostOwner(state); var requiredOwnerMask = SendToOwnerType.All; + uint* chunkVersionPtr = PredictionBackupState.GetChunkVersion(state); if (ghostOwnerId != 0 && ghostOwner != 0) { requiredOwnerMask = ghostOwnerId == ghostOwner @@ -765,77 +772,113 @@ bool RestorePredictionBackup(ArchetypeChunk chunk, int ent, in GhostCollectionPr if (compIdx >= ghostChunkComponentTypesLength) throw new System.InvalidOperationException("Component index out of range"); #endif + //data is not present in the backup buffer (see rules in GhostPredictionHistorySystem.cs, line 460) if ((GhostComponentIndex[baseOffset + comp].SendMask&requiredSendMask) == 0) continue; - if (GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + var ghostSerializer = GhostComponentCollection[serializerIdx]; + var compSize = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer + ? GhostSystemConstants.DynamicBufferComponentSnapshotSize + : ghostSerializer.ComponentSize; + + if (!chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) + { + if(ghostSerializer.HasGhostFields) + dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); + if (ghostSerializer.SerializesEnabledBit != 0) + enabledBitPtr = PredictionBackupState.GetNextEnabledBits(enabledBitPtr, chunk.Capacity); + continue; + } + + //We just need to check the chunk version when restoring from the backup. If something touched this component, + //it has been touched no matter what. We should not "compensate" or change that semantic. + uint backupVersion = chunkVersionPtr[comp]; + k_ChangeFiltering.Begin(); + if (!chunk.DidChange(ref ghostChunkComponentTypesPtr[compIdx], backupVersion)) { - if (chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) + if(ghostSerializer.HasGhostFields) + dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); + if(ghostSerializer.SerializesEnabledBit != 0) + enabledBitPtr = PredictionBackupState.GetNextEnabledBits(enabledBitPtr, chunk.Capacity); + k_ChangeFiltering.End(); + continue; + } + else k_ChangeFiltering.End(); + + if (ghostSerializer.SerializesEnabledBit != 0) + { + for (var entIndex = 0; entIndex < toRestore.Length; entIndex++) { - bool isSet = (enabledBitPtr[ent>>6] & (1ul<<(ent&0x3f))) != 0; + var ent = toRestore[entIndex]; + bool isSet = (enabledBitPtr[ent >> 6] & (1ul << (ent & 0x3f))) != 0; chunk.SetComponentEnabled(ref ghostChunkComponentTypesPtr[compIdx], ent, isSet); } enabledBitPtr = PredictionBackupState.GetNextEnabledBits(enabledBitPtr, chunk.Capacity); } - - var compSize = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer - ? GhostSystemConstants.DynamicBufferComponentSnapshotSize - : GhostComponentCollection[serializerIdx].ComponentSize; - //If the component does not have any ghost fields (so nothing to restore) //we don't need to restore the data and we don't need to advance the //data ptr either. No space has been reserved for this component in the backup buffer, see the //GhostPredictionHistorySystem) - if (!GhostComponentCollection[serializerIdx].HasGhostFields) - { + if (!ghostSerializer.HasGhostFields) continue; - } - if (!chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) - { - dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); - continue; - } //Do not restore the backup if the component is never received by this client (PlayerGhostFilter setting) - if ((GhostComponentCollection[serializerIdx].SendToOwner & requiredOwnerMask) == 0) + //The component is present in the buffer, so we need to skip the data + if ((ghostSerializer.SendToOwner & requiredOwnerMask) == 0) { dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); continue; } - - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + if (!ghostSerializer.ComponentType.IsBuffer) { - var compData = (byte*)chunk.GetDynamicComponentDataArrayReinterpret(ref ghostChunkComponentTypesPtr[compIdx], compSize).GetUnsafeReadOnlyPtr(); - GhostComponentCollection[serializerIdx].RestoreFromBackup.Ptr.Invoke((System.IntPtr)(compData + ent * compSize), (System.IntPtr)(dataPtr + ent * compSize)); + var compData = (byte*)chunk.GetDynamicComponentDataArrayReinterpret(ref ghostChunkComponentTypesPtr[compIdx], compSize) + .GetUnsafePtr(); + //TODO batch restore from backup function call + for (var entIndex = 0; entIndex < toRestore.Length; entIndex++) + { + var ent = toRestore[entIndex]; + ghostSerializer.RestoreFromBackup.Ptr.Invoke((System.IntPtr)(compData + ent * compSize), (System.IntPtr)(dataPtr + ent * compSize)); + } } else { - var backupData = (int*)(dataPtr + ent * compSize); - var bufLen = backupData[0]; - var bufOffset = backupData[1]; - var elemSize = GhostComponentCollection[serializerIdx].ComponentSize; - var bufferDataPtr = bufferBackupDataPtr + bufOffset; -#if ENABLE_UNITY_COLLECTIONS_CHECKS - if ((bufOffset + bufLen*elemSize) > PredictionBackupState.GetBufferDataCapacity(state)) - throw new System.InvalidOperationException("Overflow reading data from dynamic snapshot memory buffer"); -#endif var bufferAccessor = chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); - - //IMPORTANT NOTE: The RestoreFromBackup restore only the serialized fields for a given struct. - //Differently from the component counterpart, when the dynamic snapshot buffer get resized the memory is not - //cleared (for performance reason) and some portion of the data could be left "uninitialized" with random values - //in case some of the element fields does not have a [GhostField] annotation. - //For such a reason we enforced a rule: BufferElementData MUST have all fields annotated with the GhostFieldAttribute. - //This solve the problem and we might relax that condition later. - - bufferAccessor.ResizeUninitialized(ent, bufLen); - var bufferPointer = (byte*)bufferAccessor.GetUnsafePtr(ent); - - for(int i=0;i PredictionBackupState.GetBufferDataCapacity(state)) + throw new System.InvalidOperationException("Overflow reading data from dynamic snapshot memory buffer"); +#endif + //IMPORTANT NOTE: The RestoreFromBackup restore only the serialized fields for a given struct. + //Differently from the component counterpart, when the dynamic snapshot buffer get resized the memory is not + //cleared (for performance reason) and some portion of the data could be left "uninitialized" with random values + //in case some of the element fields does not have a [GhostField] annotation. + //For such a reason we enforced a rule: BufferElementData MUST have all fields annotated with the GhostFieldAttribute. + //This solve the problem and we might relax that condition later. + bufferAccessor.ResizeUninitialized(ent, bufLen); + var bufferPointer = (byte*)bufferAccessor.GetUnsafePtr(ent); + //for buffers we could probably use just a memcpy. the rule is that all fields must have a [GhostField], + //so everything is replicated. But.. what about internal fields or properties? + //These aren't replicated, nor we complain about their presence in code-gen. + //However, given how buffer works, these are causing problem (because has random memory value when + //initialised) and usually they must be avoided. + //For such a reason, that would be probably the fast and more correct path. Although, we would also + //make some opinionated choice and that would be a change in current behaviour. + //That may be ok for 2.0, but in current 1.x we should avoid breaking user behaviours. I suspect though, + //this would not break anything anyway. + //TODO: batch this + for (int bufElement = 0; bufElement < bufLen; ++bufElement) + { + ghostSerializer.RestoreFromBackup.Ptr.Invoke((System.IntPtr)(bufferPointer), (System.IntPtr)(bufferDataPtr)); + bufferPointer += elemSize; + bufferDataPtr += elemSize; + } } } dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); @@ -843,6 +886,7 @@ bool RestorePredictionBackup(ArchetypeChunk chunk, int ent, in GhostCollectionPr if (typeData.NumChildComponents > 0) { var linkedEntityGroupAccessor = chunk.GetBufferAccessor(ref linkedEntityGroupType); + var childChunkVersionPtr = chunkVersionPtr + numBaseComponents; for (int comp = numBaseComponents; comp < typeData.NumComponents; ++comp) { int compIdx = GhostComponentIndex[baseOffset + comp].ComponentIndex; @@ -851,75 +895,89 @@ bool RestorePredictionBackup(ArchetypeChunk chunk, int ent, in GhostCollectionPr if (compIdx >= ghostChunkComponentTypesLength) throw new System.InvalidOperationException("Component index out of range"); #endif - if ((GhostComponentIndex[baseOffset + comp].SendMask&requiredSendMask) == 0) + //Not present in the backup buffer (see rules in GhostPredictionHistorySystem.cs, line 460) + if ((GhostComponentIndex[baseOffset + comp].SendMask & requiredSendMask) == 0) continue; - - var compSize = GhostComponentCollection[serializerIdx].ComponentType.IsBuffer + var ghostSerializer = GhostComponentCollection[serializerIdx]; + var compSize = ghostSerializer.ComponentType.IsBuffer ? GhostSystemConstants.DynamicBufferComponentSnapshotSize - : GhostComponentCollection[serializerIdx].ComponentSize; - - var linkedEntityGroup = linkedEntityGroupAccessor[ent]; - var childEnt = linkedEntityGroup[GhostComponentIndex[typeData.FirstComponent + comp].EntityIndex].Value; + : ghostSerializer.ComponentSize; - if (GhostComponentCollection[serializerIdx].SerializesEnabledBit != 0) + var readonlyHandle = ghostChunkComponentTypesPtr[compIdx].CopyToReadOnly(); + var childIndex = GhostComponentIndex[typeData.FirstComponent + comp].EntityIndex; + for (var entIndex = 0; entIndex < toRestore.Length; entIndex++) { - if (childEntityLookup.TryGetValue(childEnt, out var enabledChildChunk) && - enabledChildChunk.Chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) + var rootEnt = toRestore[entIndex]; + var linkedEntityGroup = linkedEntityGroupAccessor[rootEnt]; + var childEnt = linkedEntityGroup[childIndex].Value; + + if (!childEntityLookup.TryGetValue(childEnt, out var childChunk) || !childChunk.Chunk.Has(ref readonlyHandle)) + continue; + uint backupVersion = childChunkVersionPtr[rootEnt]; + k_ChangeFiltering.Begin(); + if (!childChunk.Chunk.DidChange(ref readonlyHandle, backupVersion)) { - bool isSet = (enabledBitPtr[ent>>6] & (1ul<<(ent&0x3f))) != 0; - enabledChildChunk.Chunk.SetComponentEnabled(ref ghostChunkComponentTypesPtr[compIdx], enabledChildChunk.IndexInChunk, isSet); + k_ChangeFiltering.End(); + continue; } - enabledBitPtr = PredictionBackupState.GetNextEnabledBits(enabledBitPtr, chunk.Capacity); - } - - //If the component does not have any ghost fields (so nothing to restore) - //we don't need to restore the data and we don't need to advance the - //data ptr either. No space has been reserved for this component in the backup buffer, see the - //GhostPredictionHistorySystem) - if(!GhostComponentCollection[serializerIdx].HasGhostFields) - continue; - - if ((GhostComponentCollection[serializerIdx].SendToOwner & requiredOwnerMask) == 0) - { - dataPtr = PredictionBackupState.GetNextData(dataPtr, compSize, chunk.Capacity); - continue; - } - - if (childEntityLookup.TryGetValue(childEnt, out var childChunk) && - childChunk.Chunk.Has(ref ghostChunkComponentTypesPtr[compIdx])) - { - if (!GhostComponentCollection[serializerIdx].ComponentType.IsBuffer) + else k_ChangeFiltering.End(); + if (ghostSerializer.SerializesEnabledBit != 0) { - var compData = (byte*)childChunk.Chunk.GetDynamicComponentDataArrayReinterpret(ref ghostChunkComponentTypesPtr[compIdx], compSize).GetUnsafeReadOnlyPtr(); - GhostComponentCollection[serializerIdx].RestoreFromBackup.Ptr.Invoke((System.IntPtr)(compData + childChunk.IndexInChunk * compSize), (System.IntPtr)(dataPtr + ent * compSize)); + bool isSet = (enabledBitPtr[rootEnt >> 6] & (1ul << (rootEnt & 0x3f))) != 0; + childChunk.Chunk.SetComponentEnabled(ref ghostChunkComponentTypesPtr[compIdx], childChunk.IndexInChunk, isSet); + } + //If the component does not have any ghost fields (so nothing to restore) + //we don't need to restore the data and we don't need to advance the + //data ptr either. No space has been reserved for this component in the backup buffer, see the + //GhostPredictionHistorySystem) + if (!ghostSerializer.HasGhostFields) + continue; + if ((ghostSerializer.SendToOwner & requiredOwnerMask) == 0) + continue; + if (!ghostSerializer.ComponentType.IsBuffer) + { + var compData = (byte*)childChunk.Chunk + .GetDynamicComponentDataArrayReinterpret(ref readonlyHandle, compSize) + .GetUnsafeReadOnlyPtr(); + ghostSerializer.RestoreFromBackup.Ptr.Invoke( + (System.IntPtr)(compData + childChunk.IndexInChunk * compSize), + (System.IntPtr)(dataPtr + rootEnt * compSize)); } else { - var backupData = (int*)(dataPtr + ent * compSize); + var backupData = (int*)(dataPtr + rootEnt * compSize); var bufLen = backupData[0]; var bufOffset = backupData[1]; - var elemSize = GhostComponentCollection[serializerIdx].ComponentSize; + var elemSize = ghostSerializer.ComponentSize; var bufferDataPtr = bufferBackupDataPtr + bufOffset; #if ENABLE_UNITY_COLLECTIONS_CHECKS - if ((bufOffset + bufLen*elemSize) > PredictionBackupState.GetBufferDataCapacity(state)) + if ((bufOffset + bufLen * elemSize) > PredictionBackupState.GetBufferDataCapacity(state)) throw new System.InvalidOperationException("Overflow reading data from dynamic snapshot memory buffer"); #endif - var bufferAccessor = childChunk.Chunk.GetUntypedBufferAccessor(ref ghostChunkComponentTypesPtr[compIdx]); + var bufferAccessor = childChunk.Chunk.GetUntypedBufferAccessor(ref readonlyHandle); bufferAccessor.ResizeUninitialized(childChunk.IndexInChunk, bufLen); var bufferPointer = (byte*)bufferAccessor.GetUnsafePtr(childChunk.IndexInChunk); - for(int i=0;i()) - clientTickRate = GetSingleton(); + if (SystemAPI.HasSingleton()) + clientTickRate = SystemAPI.GetSingleton(); - var networkTime = GetSingleton(); - var lastBackupTick = GetSingleton(); - var ghostHistoryPrediction = GetSingleton(); + var networkTime = SystemAPI.GetSingleton(); + var lastBackupTick = SystemAPI.GetSingleton(); + var ghostHistoryPrediction = SystemAPI.GetSingleton(); if (!networkTime.ServerTick.IsValid) return; @@ -1074,17 +1133,17 @@ public void OnUpdate(ref SystemState systemState) m_LinkedEntityGroupTypeHandle.Update(ref systemState); m_PreSpawnedGhostIndexTypeHandle.Update(ref systemState); m_EntityTypeHandle.Update(ref systemState); - var localNetworkId = GetSingleton().Value; + var localNetworkId = SystemAPI.GetSingleton().Value; var updateJob = new UpdateJob { - GhostCollectionSingleton = GetSingletonEntity(), + GhostCollectionSingleton = SystemAPI.GetSingletonEntity(), GhostComponentCollectionFromEntity = m_GhostComponentCollectionFromEntity, GhostTypeCollectionFromEntity = m_GhostTypeCollectionFromEntity, GhostComponentIndexFromEntity = m_GhostComponentIndexFromEntity, - GhostMap = GetSingleton().Value, + GhostMap = SystemAPI.GetSingleton().Value, #if UNITY_EDITOR || NETCODE_DEBUG - minMaxSnapshotTick = GetSingletonRW().ValueRO.Value, + minMaxSnapshotTick = SystemAPI.GetSingletonRW().ValueRO.Value, #endif interpolatedTargetTick = interpolationTick, @@ -1110,7 +1169,7 @@ public void OnUpdate(ref SystemState systemState) entityType = m_EntityTypeHandle, ghostOwnerId = localNetworkId, MaxExtrapolationTicks = clientTickRate.MaxExtrapolationTimeSimTicks, - netDebug = GetSingleton() + netDebug = SystemAPI.GetSingleton() }; //@TODO: Use BufferFromEntity var ghostComponentCollection = systemState.EntityManager.GetBuffer(updateJob.GhostCollectionSingleton); @@ -1150,7 +1209,7 @@ public void OnUpdate(ref SystemState systemState) systemState.Dependency = updateInterpolatedTickJob.Schedule(systemState.Dependency); k_Scheduling.End(); - GetSingletonRW().ValueRW.LastSystemVersion = systemState.LastSystemVersion; + SystemAPI.GetSingletonRW().ValueRW.LastSystemVersion = systemState.LastSystemVersion; } } } diff --git a/Runtime/Snapshot/Prespawn/AutoTrackPrespawnSection.cs b/Runtime/Snapshot/Prespawn/AutoTrackPrespawnSection.cs index 886c3cf..25482e8 100644 --- a/Runtime/Snapshot/Prespawn/AutoTrackPrespawnSection.cs +++ b/Runtime/Snapshot/Prespawn/AutoTrackPrespawnSection.cs @@ -53,7 +53,7 @@ public void OnCreate(ref SystemState state) .WithAll() .WithNone(); state.RequireForUpdate(state.GetEntityQuery(builder)); - m_InitializedSections = state.GetEntityQuery(ComponentType.ReadOnly()); + m_InitializedSections = state.GetEntityQuery(ComponentType.ReadOnly()); state.RequireForUpdate(m_InitializedSections); m_SectionLoadedFromEntity = state.GetComponentLookup(true); @@ -82,7 +82,7 @@ partial struct ClientPrespawnAck : IJobEntity [ReadOnly] public ComponentLookup sectionLoadedFromEntity; public NetDebug netDebug; public EntityCommandBuffer entityCommandBuffer; - public void Execute(Entity entity, ref SubSceneWithGhostClenup stateComponent) + public void Execute(Entity entity, ref SubSceneWithGhostCleanup stateComponent) { bool isLoaded = sectionLoadedFromEntity.HasComponent(entity); if (!isLoaded && stateComponent.Streaming != 0) @@ -111,13 +111,13 @@ public void Execute(Entity entity, ref SubSceneWithGhostClenup stateComponent) } [Conditional("NETCODE_DEBUG")] - private static void LogStopStreaming(in NetDebug netDebug, in SubSceneWithGhostClenup stateComponent) + private static void LogStopStreaming(in NetDebug netDebug, in SubSceneWithGhostCleanup stateComponent) { netDebug.DebugLog(FixedString.Format("Request stop streaming scene {0}", NetDebug.PrintHex(stateComponent.SubSceneHash))); } [Conditional("NETCODE_DEBUG")] - private static void LogStartStreaming(in NetDebug netDebug, in SubSceneWithGhostClenup stateComponent) + private static void LogStartStreaming(in NetDebug netDebug, in SubSceneWithGhostCleanup stateComponent) { netDebug.DebugLog(FixedString.Format("Request start streaming scene {0}", NetDebug.PrintHex(stateComponent.SubSceneHash))); diff --git a/Runtime/Snapshot/Prespawn/ClientPopulatePrespawnedGhostsSystem.cs b/Runtime/Snapshot/Prespawn/ClientPopulatePrespawnedGhostsSystem.cs index 7f9ced8..b4237ad 100644 --- a/Runtime/Snapshot/Prespawn/ClientPopulatePrespawnedGhostsSystem.cs +++ b/Runtime/Snapshot/Prespawn/ClientPopulatePrespawnedGhostsSystem.cs @@ -12,7 +12,7 @@ namespace Unity.NetCode { /// /// Responsible for assigning a unique to each pre-spawned ghost, - /// and and adding the ghosts to the spawned ghosts maps. + /// and adding the ghost to the spawned ghosts maps. /// Relies on the previous initializations step to determine the subscene subset to process. /// /// @@ -23,11 +23,11 @@ namespace Unity.NetCode /// /// ### The Full Prespawn Subscene Sync Protocol /// - /// The Client will eventually receive the subscene data and will store it into the `PrespawnHashElement` collection. + /// The Client will eventually receive the subscene data and will store it into the `PrespawnSceneLoaded` collection. /// The Client (in parallel, before or after) will serialize the prespawn baseline when a new scene is loaded. /// The Client should validate that: /// - The prespawn scenes are present on the server. - /// - That the count, subscene hash and baseline hash match the one on the server. + /// - That the prespawn ghost count, subscene hash and baseline hash match the one on the server. /// The Client will assign the ghost ids to the prespawns. /// The Client must notify the server what scene sections has been loaded and initialized. /// @@ -60,7 +60,6 @@ public void OnCreate(ref SystemState state) var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll() .WithNone(); - // Assumes that at this point all subscenes should be already loaded m_UninitializedScenes = state.GetEntityQuery(builder); builder.Reset(); builder.WithAll(); @@ -129,7 +128,6 @@ public void OnUpdate(ref SystemState state) return; } //Kick a job for each sub-scene that assign the ghost id to all scene prespawn ghosts. - var scenePadding = 0; var subscenes = m_UninitializedScenes.ToEntityArray(Allocator.Temp); var entityCommandBuffer = SystemAPI.GetSingleton().CreateCommandBuffer(state.WorldUnmanaged); //This temporary list is necessary because we forcibly re-assign the entity to spawn maps in case the ghost is already registered. @@ -148,7 +146,7 @@ public void OnUpdate(ref SystemState state) var assignPrespawnGhostIdJob = new AssignPrespawnGhostIdJob { entityType = m_EntityTypeHandle, - prespawnIdType = m_PreSpawnedGhostIndexHandle, + prespawnIndexType = m_PreSpawnedGhostIndexHandle, ghostComponentType = m_GhostComponentHandle, ghostStateTypeHandle = m_GhostCleanupComponentHandle, startGhostId = subsceneCollection[collectionIndex].FirstGhostId, @@ -156,7 +154,6 @@ public void OnUpdate(ref SystemState state) netDebug = netDebug }; state.Dependency = assignPrespawnGhostIdJob.ScheduleParallel(m_Prespawns, state.Dependency); - scenePadding += subScenesWithGhosts[sceneIndex].PrespawnCount; //Add a state component to track the scene lifetime. var sceneSectionData = default(SceneSectionData); #if UNITY_EDITOR @@ -169,7 +166,7 @@ public void OnUpdate(ref SystemState state) else #endif sceneSectionData = state.EntityManager.GetComponentData(subscenes[sceneIndex]); - entityCommandBuffer.AddComponent(subscenes[sceneIndex], new SubSceneWithGhostClenup + entityCommandBuffer.AddComponent(subscenes[sceneIndex], new SubSceneWithGhostCleanup { SubSceneHash = subScenesWithGhosts[sceneIndex].SubSceneHash, FirstGhostId = subsceneCollection[collectionIndex].FirstGhostId, @@ -257,7 +254,7 @@ public void Execute() [Conditional("NETCODE_DEBUG")] void LogAssignPrespawnGhostIds(ref NetDebug netDebug, in SubSceneWithPrespawnGhosts subScenesWithGhosts) { - netDebug.DebugLog(FixedString.Format("Assinging prespawn ghost ids for scene Hash:{0} Count:{1}", + netDebug.DebugLog(FixedString.Format("Assigning prespawn ghost ids for scene Hash:{0} Count:{1}", NetDebug.PrintHex(subScenesWithGhosts.SubSceneHash), subScenesWithGhosts.PrespawnCount)); } } diff --git a/Runtime/Snapshot/Prespawn/ClientTrackLoadedPrespawnSections.cs b/Runtime/Snapshot/Prespawn/ClientTrackLoadedPrespawnSections.cs index 7d1c11d..f632f83 100644 --- a/Runtime/Snapshot/Prespawn/ClientTrackLoadedPrespawnSections.cs +++ b/Runtime/Snapshot/Prespawn/ClientTrackLoadedPrespawnSections.cs @@ -23,7 +23,7 @@ public partial struct ClientTrackLoadedPrespawnSections : ISystem public void OnCreate(ref SystemState state) { var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll() + .WithAll() .WithNone(); m_UnloadedSubscenes = state.GetEntityQuery(builder); builder.Reset(); @@ -42,12 +42,12 @@ public void OnUpdate(ref SystemState state) if(unloadedScenes.Length == 0) return; - //Only process scenes for wich all prefabs has been already destroyed + //Only process scenes for which all prefabs have been already destroyed var ghostsToRemove = new NativeList(128, state.WorldUpdateAllocator); var entityCommandBuffer = new EntityCommandBuffer(Allocator.Temp); for(int i=0;i(unloadedScenes[i]); + var stateComponent = state.EntityManager.GetComponentData(unloadedScenes[i]); m_Prespawns.SetSharedComponentFilter(new SubSceneGhostComponentHash { Value = stateComponent.SubSceneHash }); if (m_Prespawns.IsEmpty) { @@ -63,7 +63,7 @@ public void OnUpdate(ref SystemState state) entityCommandBuffer.RemoveComponent(unloadedScenes[i]); entityCommandBuffer.RemoveComponent(unloadedScenes[i]); - entityCommandBuffer.RemoveComponent(unloadedScenes[i]); + entityCommandBuffer.RemoveComponent(unloadedScenes[i]); } } entityCommandBuffer.Playback(state.EntityManager); diff --git a/Runtime/Snapshot/Prespawn/PrespawnComponents.cs b/Runtime/Snapshot/Prespawn/PrespawnComponents.cs index 596b23b..14eca5a 100644 --- a/Runtime/Snapshot/Prespawn/PrespawnComponents.cs +++ b/Runtime/Snapshot/Prespawn/PrespawnComponents.cs @@ -146,7 +146,7 @@ internal struct PrespawnGhostIdRange : IBufferElementData /// /// Cleanup component added to all subscenes with ghost. Used for tracking when a subscene is unloaded on both client and server /// - internal struct SubSceneWithGhostClenup : ICleanupComponentData + internal struct SubSceneWithGhostCleanup : ICleanupComponentData { /// /// The sub-scene hash diff --git a/Runtime/Snapshot/Prespawn/PrespawnGhostInitializationSystem.cs b/Runtime/Snapshot/Prespawn/PrespawnGhostInitializationSystem.cs index 409f9cb..3fa36f2 100644 --- a/Runtime/Snapshot/Prespawn/PrespawnGhostInitializationSystem.cs +++ b/Runtime/Snapshot/Prespawn/PrespawnGhostInitializationSystem.cs @@ -110,11 +110,11 @@ public void OnUpdate(ref SystemState state) if (m_UninitializedScenes.IsEmptyIgnoreFilter) return; var collectionEntity = SystemAPI.GetSingletonEntity(); - var GhostPrefabTypes = state.EntityManager.GetBuffer(collectionEntity); + var ghostPrefabTypes = state.EntityManager.GetBuffer(collectionEntity); //No data loaded yet. This condition can be true for both client and server. //Server in particular can be in this state until at least one connection enter the in-game state. - //Client can hit this until the receive the prefab to process from the Server. - if(GhostPrefabTypes.Length == 0) + //Client can hit this until it receives the prefabs to process from the Server. + if(ghostPrefabTypes.Length == 0) return; var processedPrefabs = new NativeParallelHashMap(256, state.WorldUpdateAllocator); @@ -122,11 +122,11 @@ public void OnUpdate(ref SystemState state) var subScenesSections = m_UninitializedScenes.ToEntityArray(Allocator.Temp); var readySections = new NativeList(subScenesSections.Length, Allocator.Temp); - //Populate a map for faster retrival and used also by component stripping job - for (int i = 0; i < GhostPrefabTypes.Length; ++i) + //Populate a map for faster retrieval and used also by component stripping job + for (int i = 0; i < ghostPrefabTypes.Length; ++i) { - if(GhostPrefabTypes[i].GhostPrefab != Entity.Null) - processedPrefabs.Add(GhostPrefabTypes[i].GhostType, GhostPrefabTypes[i].GhostPrefab); + if(ghostPrefabTypes[i].GhostPrefab != Entity.Null) + processedPrefabs.Add(ghostPrefabTypes[i].GhostType, ghostPrefabTypes[i].GhostPrefab); } //Find out all the scenes that have all their prespawn ghost type resolved by the ghost collection. diff --git a/Runtime/Snapshot/Prespawn/PrespawnGhostJobs.cs b/Runtime/Snapshot/Prespawn/PrespawnGhostJobs.cs index 06dcc54..937de5d 100644 --- a/Runtime/Snapshot/Prespawn/PrespawnGhostJobs.cs +++ b/Runtime/Snapshot/Prespawn/PrespawnGhostJobs.cs @@ -204,7 +204,7 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE internal struct AssignPrespawnGhostIdJob : IJobChunk { [ReadOnly] public EntityTypeHandle entityType; - [ReadOnly] public ComponentTypeHandle prespawnIdType; + [ReadOnly] public ComponentTypeHandle prespawnIndexType; [NativeDisableParallelForRestriction] public ComponentTypeHandle ghostComponentType; [NativeDisableParallelForRestriction] @@ -220,7 +220,7 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo Assert.IsFalse(useEnabledMask); var entities = chunk.GetNativeArray(entityType); - var preSpawnedIds = chunk.GetNativeArray(ref prespawnIdType); + var preSpawnedIndices = chunk.GetNativeArray(ref prespawnIndexType); var ghostComponents = chunk.GetNativeArray(ref ghostComponentType); var ghostStates = chunk.GetNativeArray(ref ghostStateTypeHandle); @@ -232,11 +232,11 @@ public unsafe void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bo // Check if this entity has already been handled if (ghostComponents[index].ghostId != 0) { - netDebug.LogWarning($"{entity} already has ghostId={ghostComponents[index].ghostId} prespawn= {preSpawnedIds[index].Value}"); + netDebug.LogWarning($"{entity} already has ghostId={ghostComponents[index].ghostId} PreSpawnedGhostIndex={preSpawnedIndices[index].Value}"); continue; } - //Special encoding for prespawnId (sort of "namespace"). - var ghostId = PrespawnHelper.MakePrespawGhostId(preSpawnedIds[index].Value + startGhostId); + //Special encoding for prespawn index (sort of "namespace"). + var ghostId = PrespawnHelper.MakePrespawnGhostId(preSpawnedIndices[index].Value + startGhostId); if (ghostStates.IsCreated && ghostStates.Length > 0) ghostStates[index] = new GhostCleanup {ghostId = ghostId, despawnTick = NetworkTick.Invalid, spawnTick = NetworkTick.Invalid}; diff --git a/Runtime/Snapshot/Prespawn/PrespawnHelper.cs b/Runtime/Snapshot/Prespawn/PrespawnHelper.cs index 5743f4b..e328062 100644 --- a/Runtime/Snapshot/Prespawn/PrespawnHelper.cs +++ b/Runtime/Snapshot/Prespawn/PrespawnHelper.cs @@ -12,12 +12,12 @@ internal static class PrespawnHelper public const uint PrespawnGhostIdBase = 0x80000000; [MethodImpl(MethodImplOptions.AggressiveInlining)] - static public int MakePrespawGhostId(int ghostId) + static public int MakePrespawnGhostId(int ghostId) { return (int) (PrespawnGhostIdBase | ghostId); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - static public bool IsPrespawGhostId(int ghostId) + static public bool IsPrespawnGhostId(int ghostId) { return (ghostId & PrespawnGhostIdBase) != 0; } @@ -129,8 +129,8 @@ static public void PopulateSceneHashLookupTable(EntityQuery query, EntityManager if(present == 1) { newLoadedRanges.Add(new GhostIdInterval( - PrespawnHelper.MakePrespawGhostId(prespawnSceneLoaded[i].FirstGhostId), - PrespawnHelper.MakePrespawGhostId(prespawnSceneLoaded[i].FirstGhostId + prespawnSceneLoaded[i].PrespawnCount - 1))); + PrespawnHelper.MakePrespawnGhostId(prespawnSceneLoaded[i].FirstGhostId), + PrespawnHelper.MakePrespawnGhostId(prespawnSceneLoaded[i].FirstGhostId + prespawnSceneLoaded[i].PrespawnCount - 1))); } } } diff --git a/Runtime/Snapshot/Prespawn/ServerPopulatePrespawnedGhosts.cs b/Runtime/Snapshot/Prespawn/ServerPopulatePrespawnedGhosts.cs index 29bec09..183c26f 100644 --- a/Runtime/Snapshot/Prespawn/ServerPopulatePrespawnedGhosts.cs +++ b/Runtime/Snapshot/Prespawn/ServerPopulatePrespawnedGhosts.cs @@ -28,7 +28,7 @@ namespace Unity.NetCode /// /// The Server calculates the prespawn baselines. /// The Server assigns runtime ghost IDs to the prespawned ghosts. - /// The Server stores the `SubSceneHash`, `BaselineHash`, `AssignedFirstId`, and `PrespawnCount` inside the the `PrespawnHashElement` collection. + /// The Server stores the `SubSceneHash`, `BaselineHash`, `FirstGhostId`, and `PrespawnCount` inside the the `PrespawnSceneLoaded` collection. /// The Server creates a new ghost with a `PrespawnSceneLoaded` buffer that is serialized to the clients. /// /// @@ -53,7 +53,6 @@ public partial struct ServerPopulatePrespawnedGhostsSystem : ISystem [BurstCompile] public void OnCreate(ref SystemState state) { - // Assumes that at this point all subscenes should be already loaded var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll() .WithNone(); @@ -96,7 +95,7 @@ public void OnUpdate(ref SystemState state) // Add GhostCleanup to all ghosts // After some measurement this is the fastest way to achieve it. Is roughly 5/6x faster than // adding all the components change one by one via command buffer in a job - // with a decent amout of entities (> 3000) + // with a decent amount of entities (> 3000) for (int i = 0; i < subScenesWithGhosts.Length; ++i) { var sharedFilter = new SubSceneGhostComponentHash {Value = subScenesWithGhosts[i].SubSceneHash}; @@ -112,7 +111,6 @@ public void OnUpdate(ref SystemState state) var spawnedGhosts = new NativeList(totalPrespawns, state.WorldUpdateAllocator); //Kick a job for each sub-scene that assign the ghost id to all scene prespawn ghosts. //It also fill the array of prespawned ghosts that is going to be used to populate the ghost maps in the send/receive systems. - var scenePadding = 0; var subsceneCollection = state.EntityManager.GetBuffer(prespawnSceneListEntity); var entityCommandBuffer = SystemAPI.GetSingleton().CreateCommandBuffer(state.WorldUnmanaged); ref var spawnedGhostEntityMap = ref SystemAPI.GetSingletonRW().ValueRW; @@ -132,7 +130,7 @@ public void OnUpdate(ref SystemState state) var assignPrespawnGhostIdJob = new AssignPrespawnGhostIdJob { entityType = m_EntityTypeHandle, - prespawnIdType = m_PreSpawnedGhostIndexHandle, + prespawnIndexType = m_PreSpawnedGhostIndexHandle, ghostComponentType = m_GhostComponentHandle, ghostStateTypeHandle = m_GhostCleanupComponentHandle, startGhostId = startId, @@ -140,7 +138,6 @@ public void OnUpdate(ref SystemState state) netDebug = netDebug }; state.Dependency = assignPrespawnGhostIdJob.ScheduleParallel(m_Prespawns, state.Dependency); - scenePadding += subScenesWithGhosts[i].PrespawnCount; //add the subscene to the collection. This will be synchronized to the clients subsceneCollection.Add(new PrespawnSceneLoaded { @@ -164,7 +161,7 @@ public void OnUpdate(ref SystemState state) sceneSectionData = state.EntityManager.GetComponentData(subSceneEntities[i]); entityCommandBuffer.AddComponent(subSceneEntities[i]); - entityCommandBuffer.AddComponent(subSceneEntities[i], new SubSceneWithGhostClenup + entityCommandBuffer.AddComponent(subSceneEntities[i], new SubSceneWithGhostCleanup { SubSceneHash = subScenesWithGhosts[i].SubSceneHash, FirstGhostId = startId, @@ -265,7 +262,7 @@ private void LogAllocatedIdRange(ref NetDebug netDebug, PrespawnGhostIdRange ran [Conditional("NETCODE_DEBUG")] void LogAssignPrespawnGhostIds(ref NetDebug netDebug, in SubSceneWithPrespawnGhosts subScenesWithGhosts) { - netDebug.DebugLog(FixedString.Format("Assinging prespawn ghost ids for scene Hash:{0} Count:{1}", + netDebug.DebugLog(FixedString.Format("Assigning prespawn ghost ids for scene Hash:{0} Count:{1}", NetDebug.PrintHex(subScenesWithGhosts.SubSceneHash), subScenesWithGhosts.PrespawnCount)); } } diff --git a/Runtime/Snapshot/Prespawn/ServerTrackLoadedPrespawnSections.cs b/Runtime/Snapshot/Prespawn/ServerTrackLoadedPrespawnSections.cs index 6f64c26..b15e682 100644 --- a/Runtime/Snapshot/Prespawn/ServerTrackLoadedPrespawnSections.cs +++ b/Runtime/Snapshot/Prespawn/ServerTrackLoadedPrespawnSections.cs @@ -25,7 +25,7 @@ public partial struct ServerTrackLoadedPrespawnSections : ISystem public void OnCreate(ref SystemState state) { var builder = new EntityQueryBuilder(Allocator.Temp) - .WithAll() + .WithAll() .WithNone(); m_UnloadedSubscenes = state.GetEntityQuery(builder); builder.Reset(); @@ -46,14 +46,14 @@ public void OnUpdate(ref SystemState state) return; var entityCommandBuffer = SystemAPI.GetSingleton().CreateCommandBuffer(state.WorldUnmanaged); - //Only process scenes for wich all prefabs has been already destroyed + //Only process scenes for which all prefabs has been already destroyed var subsceneCollection = SystemAPI.GetSingletonBuffer(); var allocatedRanges = SystemAPI.GetSingletonBuffer(); var netDebug = SystemAPI.GetSingleton(); var unloadedGhostRange = new NativeList(state.WorldUpdateAllocator); for(int i=0;i(unloadedSections[i]); + var stateComponent = state.EntityManager.GetComponentData(unloadedSections[i]); m_Prespawns.SetSharedComponentFilter(new SubSceneGhostComponentHash { Value = stateComponent.SubSceneHash }); //If there are still some ghosts present, don't remove the scene from the scene list yet @@ -99,7 +99,7 @@ public void OnUpdate(ref SystemState state) } entityCommandBuffer.RemoveComponent(unloadedSections[i]); entityCommandBuffer.RemoveComponent(unloadedSections[i]); - entityCommandBuffer.RemoveComponent(unloadedSections[i]); + entityCommandBuffer.RemoveComponent(unloadedSections[i]); } if (unloadedGhostRange.Length == 0) @@ -131,7 +131,7 @@ public void Execute() var firstId = unloadedGhostRange[i].x; for (int idx = 0; idx < unloadedGhostRange[i].y; ++idx) { - var ghostId = PrespawnHelper.MakePrespawGhostId(firstId + idx); + var ghostId = PrespawnHelper.MakePrespawnGhostId(firstId + idx); var found = despawns.IndexOf(ghostId); if (found != -1) despawns.RemoveAtSwapBack(found); diff --git a/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs b/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs index f0dc115..c123fc4 100644 --- a/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs +++ b/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs @@ -290,6 +290,7 @@ public override int GetHashCode() /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [CreateAfter(typeof(GhostCollectionSystem))] + [CreateBefore(typeof(GhostReceiveSystem))] internal partial struct SnapshotLookupCacheSystem : ISystem { /// diff --git a/Runtime/Snapshot/SwitchPredictionSmoothingSystem.cs b/Runtime/Snapshot/SwitchPredictionSmoothingSystem.cs index 4c6d282..ff9ba79 100644 --- a/Runtime/Snapshot/SwitchPredictionSmoothingSystem.cs +++ b/Runtime/Snapshot/SwitchPredictionSmoothingSystem.cs @@ -45,6 +45,7 @@ public struct SwitchPredictionSmoothing : IComponentData /// When the transition is completed, the system removes the component. /// /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(TransformSystemGroup))] [UpdateBefore(typeof(LocalToWorldSystem))] [BurstCompile] diff --git a/Runtime/SourceGenerators/NetCodeSourceGenerator.dll b/Runtime/SourceGenerators/NetCodeSourceGenerator.dll index a08f4c8a9304bdac37001bb888d33ae807925562..e4b72bfff115dda024bc95b6bed562af9d054466 100644 GIT binary patch literal 275968 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%0D`%PKsRooy|ihDz3JfXM5 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

    L2SlsmbM<+;wWAlXX`2_8IH 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

  • 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 prn&_^Ig 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 HN(}t}jj=xO diff --git a/Runtime/SourceGenerators/NetCodeSourceGenerator.pdb b/Runtime/SourceGenerators/NetCodeSourceGenerator.pdb index 37390f2a2ace1e5c4008e897c0d2418777def8bb..e7ef0f0d8323b6a5644e7c56ad01dd5cacba6186 100644 GIT binary patch literal 35592 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 literal 45056 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 Ky3D(A`@aAXwJnJN diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/CodeGenerator.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/CodeGenerator.cs index 7bf9532..662bf73 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/CodeGenerator.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/CodeGenerator.cs @@ -104,7 +104,7 @@ static bool TryGetTypeTemplate(TypeInformation typeInfo, Context context, out Ty return false; } - context.diagnostic.LogInfo($"'{context.generatorName}' found Template for field '{typeInfo.FieldName}' with GhostField configuration '{description}': '{template}'."); + context.diagnostic.LogDebug($"'{context.generatorName}' found Template for field '{typeInfo.FieldName}' with GhostField configuration '{description}': '{template}'."); return true; } @@ -172,7 +172,7 @@ public static void GenerateRegistrationSystem(Context context) else if (typeInfo.GhostAttribute.SendTypeOptimization == GhostSendType.AllClients) replacements["GHOST_SEND_MASK"] = "GhostSendType.AllClients"; else - replacements["GHOST_SEND_MASK"] = "GhostComponentSerializer.SendMask.DontSend"; + replacements["GHOST_SEND_MASK"] = "GhostSendType.DontSend"; } else { @@ -287,7 +287,7 @@ void BuildGenerator(Context ctx, TypeInformation typeInfo, CommandSerializer par } var tmp = context.serializationStrategies[context.serializationStrategies.Count-1]; - tmp.InputBufferComponentTypeName = bufferTypeTree.TypeFullName.Replace("+", "."); + tmp.InputBufferComponentTypeName = bufferSymbol.ToDisplayString(); context.serializationStrategies[context.serializationStrategies.Count-1] = tmp; using (new Profiler.Auto("GenerateInputCommandData")) @@ -325,7 +325,7 @@ void BuildGenerator(Context ctx, TypeInformation typeInfo, CommandSerializer par // We must add the serialization strategy even if there are no ghost fields, as empty variants // still save the ghost component attributes. var bufferVariantHash = Helpers.ComputeVariantHash(bufferTypeTree.Symbol, bufferTypeTree.Symbol); - context.diagnostic.LogInfo($"Adding SerializationStrategy for input buffer {bufferTypeTree.TypeFullName}, which doesn't have any GhostFields, as we still need to store the GhostComponentAttribute data."); + context.diagnostic.LogDebug($"Adding SerializationStrategy for input buffer {bufferTypeTree.TypeFullName}, which doesn't have any GhostFields, as we still need to store the GhostComponentAttribute data."); context.serializationStrategies.Add(new CodeGenerator.Context.SerializationStrategyCodeGen { TypeInfo = typeInfo, @@ -347,30 +347,38 @@ private static bool GenerateInputBufferType(Context context, TypeInformation typ { // TODO - Code gen should handle throwing an exception for a zero-sized buffer with [GhostEnabledBit]. - // Add the generated code for the command type to the compilation syntax tree and - // fetch its symbol for further processing - var nameAndSource = context.batch[context.batch.Count - 1]; - var syntaxTree = CSharpSyntaxTree.ParseText(nameAndSource.Code, - options: context.executionContext.ParseOptions as CSharpParseOptions); - var newCompilation = context.executionContext.Compilation.AddSyntaxTrees(syntaxTree); + // Add the generated code for the command type symbol to the compilation for further processing + // first lookup from the metadata cache. If it is present there, we are done. + var bufferType = context.executionContext.Compilation.GetTypeByMetadataName("Unity.NetCode.InputBufferData`1"); + var inputType = typeTree.Symbol; + if (bufferType == null) + { + //Search in current compilation unit. This is slow path but only happen for the NetCode assembly itself (where we don't have any IInputComponentData, so fine). + var inputBufferType = context.executionContext.Compilation.GetSymbolsWithName("InputBufferData", SymbolFilter.Type).First() as INamedTypeSymbol; + bufferSymbol = inputBufferType.Construct(inputType); + } + else + { + bufferSymbol = bufferType.Construct(inputType); + } + if (bufferSymbol == null) + { + context.diagnostic.LogError($"Failed to construct input buffer symbol InputBufferData<{typeTree.TypeFullName}>!"); + bufferTypeTree = null; + bufferName = null; + return false; + } // FieldTypeName includes the namespace, strip that away when generating the buffer type name bufferName = $"{typeTree.FieldTypeName}InputBufferData"; if (typeTree.Namespace.Length != 0 && typeTree.FieldTypeName.Length > typeTree.Namespace.Length) bufferName = $"{typeTree.FieldTypeName.Substring(typeTree.Namespace.Length + 1)}InputBufferData"; // If the type is nested inside another class/type the parent name will be included in the type name separated by an underscore bufferName = bufferName.Replace('.', '_'); - bufferSymbol = newCompilation.GetSymbolsWithName(bufferName).FirstOrDefault() as INamedTypeSymbol; - if (bufferSymbol == null) - { - context.diagnostic.LogError($"Failed to fetch input buffer symbol as ${bufferName}!"); - bufferTypeTree = null; - return false; - } var typeBuilder = new TypeInformationBuilder(context.diagnostic, context.executionContext, TypeInformationBuilder.SerializationMode.Commands); // Parse input generated code as command data context.ResetState(); - context.generatorName = Roslyn.Extensions.GetTypeNameWithDeclaringTypename(bufferSymbol); + context.generatorName = bufferName; bufferTypeTree = typeBuilder.BuildTypeInformation(bufferSymbol, null); if (bufferTypeTree == null) { @@ -378,7 +386,7 @@ private static bool GenerateInputBufferType(Context context, TypeInformation typ return false; } context.types.Add(bufferTypeTree); - context.diagnostic.LogInfo($"Generating input buffer command data for ${bufferTypeTree.TypeFullName}!"); + context.diagnostic.LogDebug($"Generating input buffer command data for ${bufferTypeTree.TypeFullName}!"); return true; } @@ -426,7 +434,7 @@ private static void GenerateInputBufferGhostComponent(Context context, TypeInfor }); context.types.Add(bufferTypeTree); - context.diagnostic.LogInfo($"Generating ghost for input buffer {bufferTypeTree.TypeFullName}!"); + context.diagnostic.LogDebug($"Generating ghost for input buffer {bufferTypeTree.TypeFullName}!"); GenerateGhost(context, bufferTypeTree); } @@ -622,7 +630,8 @@ public struct SerializationStrategyCodeGen } public readonly List serializationStrategies; - public string variantType; + //Follow the Rolsyn convention for inner classes (so Namespace.ClassName[+DeclaringClass]+Class + public string variantTypeFullName; public ulong variantHash; public string generatorName; //Total number of changeMaskBits bits @@ -637,7 +646,7 @@ public void ResetState() changeMaskBitCount = 0; curChangeMaskBits = 0; ghostFieldHash = 0; - variantType = null; + variantTypeFullName = null; variantHash = 0; imports.Clear(); imports.Add("Unity.Entities"); diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/GhostCodeGen.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/GhostCodeGen.cs index 898abdd..85b3886 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/GhostCodeGen.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/GhostCodeGen.cs @@ -169,7 +169,7 @@ private void Validate(string content, string fragment) var name = match.Value; var nameEnd = name.IndexOf("__", 2, StringComparison.Ordinal); if (nameEnd < 0) - throw new InvalidOperationException($"Invalid key in GhostCodeGen fragment {fragment} while generating '{m_Context.generatedNs}.{m_Context.generatorName}'."); + m_Context.diagnostic.LogError($"Invalid key in GhostCodeGen fragment {fragment} while generating '{m_Context.generatedNs}.{m_Context.generatorName}'."); m_Context.diagnostic.LogError($"GhostCodeGen did not replace {name} in fragment {fragment} while generating '{m_Context.generatedNs}.{m_Context.generatorName}'."); } throw new InvalidOperationException($"GhostCodeGen failed for fragment {fragment} while generating '{m_Context.generatedNs}.{m_Context.generatorName}'."); diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/DiagnosticReporter.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/DiagnosticReporter.cs index 4405176..493abaa 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/DiagnosticReporter.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/DiagnosticReporter.cs @@ -13,6 +13,22 @@ public DiagnosticReporter(GeneratorExecutionContext ctx) context = ctx; } + public void LogDebug(string message, + [System.Runtime.CompilerServices.CallerFilePath] + string sourceFilePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] + int sourceLineNumber = 0) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticHelper.CreateInfoDescriptor(message), + DiagnosticHelper.GenerateExtenalLocation(sourceFilePath, sourceLineNumber))); + Debug.LogDebug(message); + } + public void LogDebug(string message, Location location) + { + Debug.LogDebug(message); + } + public void LogInfo(string message, [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSyntaxReceiver.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSyntaxReceiver.cs index 19348d5..cb72026 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSyntaxReceiver.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSyntaxReceiver.cs @@ -53,6 +53,9 @@ public void OnVisitSyntaxNode(SyntaxNode syntaxNode) if (structNode.Modifiers.Any(m => m.IsKind(SyntaxKind.PrivateKeyword))) return; + if(structNode.TypeParameterList != null) + return; + //Check for Variant attributes if (structNode.AttributeLists.Count > 0) { diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TemplateFileProvider.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TemplateFileProvider.cs index 4453c77..855844b 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TemplateFileProvider.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TemplateFileProvider.cs @@ -81,19 +81,20 @@ public void AddAdditionalTemplates(ImmutableArray additionalFile continue; } - if (!string.Equals(foundMatch.Template, templateId, StringComparison.Ordinal)) + if (!string.Equals(templateId, foundMatch.Template, StringComparison.Ordinal) && + !string.Equals(templateId, foundMatch.TemplateOverride, StringComparison.Ordinal)) { diagnostic.LogError($"NetCode AdditionalFile '{additionalText.Path}' (named '{templateId}') is a valid Template, but the Template definition in 'UserDefinedTemplates' ({foundMatch.Template}, of type {foundMatch.Type}) does not match the #templateID!"); continue; } - diagnostic.LogInfo($"NetCode AdditionalFile '{additionalText.Path}' (named '{templateId}') is a valid Template ({foundMatch.Template}, {foundMatch.Type})."); + diagnostic.LogDebug($"NetCode AdditionalFile '{additionalText.Path}' (named '{templateId}') is a valid Template ({foundMatch.Template}, {foundMatch.Type})."); customTemplates.Add(templateId, additionalText.GetText()); } else { - diagnostic.LogInfo($"Ignoring AdditionalFile '{additionalText.Path}' as it is not a NetCode type!"); + diagnostic.LogDebug($"Ignoring AdditionalFile '{additionalText.Path}' as it is not a NetCode type!"); } } @@ -101,14 +102,8 @@ public void AddAdditionalTemplates(ImmutableArray additionalFile foreach (var typeRegistryEntry in missingUserTypes) { var message = $"Unable to find the Template associated with '{typeRegistryEntry}'. Looking for '{typeRegistryEntry.Template}'. There are {additionalFiles.Length} additionalFiles:[{string.Join(",", additionalFiles.Select(x => x.Path))}]!"; - // DotsRuntime is not passing the correct additional file set to the generator, so we cannot assert here. - if (!Helpers.IsDotsRuntime) - diagnostic.LogError(message); - else - { - message = "IsDotsRuntime_SpecialCase: " + message; - diagnostic.LogInfo(message); - } + diagnostic.LogError(message); + } string GetKnownCustomUserTemplates() @@ -117,13 +112,44 @@ string GetKnownCustomUserTemplates() } } + public void PerformAdditionalTypeRegistryValidation(List customUserTypes) + { + + string GetKnownCustomUserTemplates() + { + return string.Join(",", customTemplates.Keys); + } + + // Ensure all of the users `TypeRegistryEntry.TemplateOverride`s are linked. + foreach (var typeRegistryEntry in customUserTypes) + { + var hasDefinedTemplateOverride = !string.IsNullOrWhiteSpace(typeRegistryEntry.TemplateOverride); + if (hasDefinedTemplateOverride) + { + if (!customTemplates.ContainsKey(typeRegistryEntry.TemplateOverride)) + { + diagnostic.LogError($"Unable to find the `TemplateOverride` associated with '{typeRegistryEntry}'. Known templates are {GetKnownCustomUserTemplates()}."); + } + } + } + } + static TypeRegistryEntry FindAndRemoveTypeRegistryEntry(List typeRegistryEntries, string templateId) { TypeRegistryEntry foundMatch = null; for (var i = 0; i < typeRegistryEntries.Count; i++) { var x = typeRegistryEntries[i]; - if (string.Equals(x.Template, templateId, StringComparison.Ordinal) || x.Template.EndsWith(templateId + NetCodeSourceGenerator.NETCODE_ADDITIONAL_FILE, StringComparison.Ordinal)) + if (string.Equals(x.Template, templateId, StringComparison.Ordinal) || + x.Template.EndsWith(templateId + NetCodeSourceGenerator.NETCODE_ADDITIONAL_FILE, StringComparison.Ordinal)) + { + foundMatch = x; + typeRegistryEntries.RemoveAt(i); + break; + } + if (!string.IsNullOrEmpty(x.TemplateOverride) && + (string.Equals(x.TemplateOverride, templateId, StringComparison.Ordinal) || + x.TemplateOverride.EndsWith(templateId + NetCodeSourceGenerator.NETCODE_ADDITIONAL_FILE, StringComparison.Ordinal))) { foundMatch = x; typeRegistryEntries.RemoveAt(i); diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/Profiler.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/Profiler.cs index bc40717..8a94cb6 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/Profiler.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/Profiler.cs @@ -71,9 +71,9 @@ static public void End() instance.Stop(); } - static public string PrintStats() + static public string PrintStats(bool fullTiming=false) { - return instance.CollectStats(); + return instance.CollectStats(fullTiming); } int GetChildId(string name) @@ -136,22 +136,24 @@ private void Stop() currentId = marker.parent; } - string CollectStats() + string CollectStats(bool fullTiming) { timers[0].ticks = Stopwatch.GetTimestamp() - timers[0].ticks; var builder = new System.Text.StringBuilder(); builder.AppendLine("Timing:"); //Timers is a tree stored in depth first order builder.Append($"{timers[0].name}: {(1000.0*(timers[0].ticks - timers[0].overheadTicks))/Stopwatch.Frequency} msec\n"); - for (int i = 1; i < timers.Count; ++i) + if (fullTiming) { - var node = timers[i]; - var s = $"{node.name}: {(1000.0*node.ticks)/Stopwatch.Frequency} msec ({node.count}) [{(1000.0*node.overheadTicks)/Stopwatch.Frequency}]\n"; - builder.Append(s.PadLeft(node.depth*2 + s.Length)); + for (int i = 1; i < timers.Count; ++i) + { + var node = timers[i]; + var s = $"{node.name}: {(1000.0*node.ticks)/Stopwatch.Frequency} msec ({node.count}) [{(1000.0*node.overheadTicks)/Stopwatch.Frequency}]\n"; + builder.Append(s.PadLeft(node.depth*2 + s.Length)); + } } - timers[0].ticks = Stopwatch.GetTimestamp(); return builder.ToString(); } } -} \ No newline at end of file +} diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/RoslynExtensions.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/RoslynExtensions.cs index 07b78a3..04b6e97 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/RoslynExtensions.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/RoslynExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -83,7 +84,7 @@ public static string GetFieldTypeName(this ITypeSymbol type) return "uptr"; default: //TODO: need full type specifier?? - return type.ToDisplayString(MinQualifiedTypeFormat); + return type.ToDisplayString(QualifiedTypeFormat); } } @@ -181,7 +182,7 @@ private static bool TryGetComponentTypeFromInterface(INamedTypeSymbol interfaceS var interfaceQualifiedName = interfaceSymbol.ToDisplayString(QualifiedTypeFormat); // Detecting the type here for interfaces inheriting ICommandData is important for - // IInputBufferData when parsing it as a component (type needs to be set to ComponentType.CommandData) or the + // InputBufferData when parsing it as a component (type needs to be set to ComponentType.CommandData) or the // default ghost component parameters will not be set properly if (interfaceQualifiedName == "Unity.NetCode.ICommandData" || interfaceSymbol.InheritsFromInterface("Unity.NetCode.ICommandData")) @@ -231,12 +232,6 @@ private static bool TryGetComponentTypeFromInterface(INamedTypeSymbol interfaceS miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - private static SymbolDisplayFormat MinQualifiedTypeFormat = new SymbolDisplayFormat( - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - private static SymbolDisplayFormat QualifiedTypeFormatNoSpecial = new SymbolDisplayFormat( typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, @@ -306,13 +301,52 @@ public static string GetFullTypeName(this ISymbol symbol) return string.Concat(ns, ".", fullName); } - //Return the symbol name prepended with the declaring class names, separated by + + //return an ECMA compliant fully qualified name: + // - The namespace.[XXX+]TypeName if the type is not generic + // - The namespace.[XXX+]TypeName`N[[NameWithNamespaceAndContainingType, assembly],..] if the type is generic + public static string GetMetadataQualifiedName(ISymbol symbol) + { + var sb = new StringBuilder(symbol.MetadataName); + var qualifiedNamespace = GetFullyQualifiedNamespace(symbol); + if (((INamedTypeSymbol)symbol).IsGenericType) + { + sb.Append('['); + foreach (var parameter in ((INamedTypeSymbol)symbol).TypeArguments) + { + sb.Append($"[{parameter.ToDisplayString(QualifiedTypeFormat)}, {parameter.ContainingAssembly.ToDisplayString()}]"); + sb.Append(','); + } + sb.Length -= 1; + sb.Append(']'); + } + while(symbol.ContainingType != null) + { + sb.Insert(0, $"{symbol.ContainingType.OriginalDefinition.ToDisplayString(NameOnlyFormat)}+"); + symbol = symbol.ContainingType; + } + if(!string.IsNullOrWhiteSpace(qualifiedNamespace)) + sb.Insert(0, $"{qualifiedNamespace}."); + return sb.ToString(); + } + //es: struct A { struct B { struct C} } } would return a string like A+B+C public static string GetTypeNameWithDeclaringTypename(ISymbol symbol) { var declaring = new List(3); if (((INamedTypeSymbol)symbol).IsGenericType) - declaring.Add(symbol.ToDisplayString(NameOnlyFormat)); + { + var n = new StringBuilder(); + n.Append(symbol.Name); + n.Append('<'); + foreach (var parameter in ((INamedTypeSymbol)symbol).TypeArguments) + { + n.Append(parameter.ToDisplayString(QualifiedTypeFormat)); + n.Append(','); + } + n.Length -= 1; + n.Append('>'); + declaring.Add(n.ToString()); + } else declaring.Add(symbol.Name); diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/SourceGeneratorHelpers.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/SourceGeneratorHelpers.cs index dd01b61..f0ff97b 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/SourceGeneratorHelpers.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Helpers/SourceGeneratorHelpers.cs @@ -21,7 +21,15 @@ internal static class Helpers static private ThreadLocal s_SupportTemplatesFromAdditionalFiles; static private ThreadLocal s_WriteLogToDisk; static private ThreadLocal s_CanWriteFiles; - static private ThreadLocal s_IsDotsRuntime; + static private ThreadLocal s_LogLevel; + + public enum LoggingLevel : int + { + Debug = 0, + Info = 1, + Warning = 2, + Error = 3, + } static public string ProjectPath { @@ -58,11 +66,7 @@ static public bool CanWriteFiles private set => s_CanWriteFiles.Value = value; } - static public bool IsDotsRuntime - { - get => s_IsDotsRuntime.Value; - private set => s_IsDotsRuntime.Value = value; - } + static public LoggingLevel CurrentLogLevel => s_LogLevel.Value; static Helpers() { @@ -72,18 +76,19 @@ static Helpers() s_SupportTemplatesFromAdditionalFiles = new ThreadLocal(); s_WriteLogToDisk = new ThreadLocal(); s_CanWriteFiles = new ThreadLocal(); - s_IsDotsRuntime = new ThreadLocal(); + s_LogLevel = new ThreadLocal(); } static public void SetupContext(GeneratorExecutionContext executionContext) { ProjectPath = null; - CanWriteFiles = false; - WriteLogToDisk = false; - IsDotsRuntime = executionContext.ParseOptions.PreprocessorSymbolNames.Contains("UNITY_DOTSRUNTIME"); + //by default we allow both writing files and logs to disk. It is possible to change the behavior via + //globalconfig + CanWriteFiles = true; + WriteLogToDisk = true; IsUnity2021_OrNewer = executionContext.ParseOptions.PreprocessorSymbolNames.Any(d => d == "UNITY_2022_1_OR_NEWER" || d == "UNITY_2021_3_OR_NEWER"); //Setup the current project folder directory by inspecting the context for global options or additional files, depending on the current Unity version - if (!IsUnity2021_OrNewer || IsDotsRuntime) + if (!IsUnity2021_OrNewer) { SupportTemplateFromAdditionalFiles = false; if (executionContext.AdditionalFiles.Any() && !string.IsNullOrEmpty(executionContext.AdditionalFiles[0].Path)) @@ -95,22 +100,30 @@ static public void SetupContext(GeneratorExecutionContext executionContext) if (executionContext.AdditionalFiles.Any() && !string.IsNullOrEmpty(executionContext.AdditionalFiles[0].Path)) ProjectPath = executionContext.AdditionalFiles[0].GetText()?.ToString(); } - //Parse global options. They are used by both tests, and Editor (2021_OR_NEWER) - if (executionContext.AnalyzerConfigOptions.GlobalOptions.TryGetValue(GlobalOptions.ProjectPath, out var projectpath)) - ProjectPath = projectpath; - if (executionContext.AnalyzerConfigOptions.GlobalOptions.TryGetValue(GlobalOptions.OutputPath, out var outputFolder)) - OutputFolder = outputFolder; - if (executionContext.AnalyzerConfigOptions.GlobalOptions.TryGetValue(GlobalOptions.TemplateFromAdditionalFiles, out var templateFromAdditionalFiles)) - SupportTemplateFromAdditionalFiles = templateFromAdditionalFiles == "1"; + //Parse global options and overrides default behaviour. They are used by both tests, and Editor (2021_OR_NEWER) + ProjectPath = executionContext.GetOptionsString(GlobalOptions.ProjectPath, ProjectPath); + OutputFolder = executionContext.GetOptionsString(GlobalOptions.OutputPath, OutputFolder); + SupportTemplateFromAdditionalFiles = GlobalOptions.GetOptionsFlag(executionContext, GlobalOptions.TemplateFromAdditionalFiles, SupportTemplateFromAdditionalFiles); - if (!string.IsNullOrEmpty(ProjectPath)) + //If the project path is not valid, for any reason, we can't write files and/or log to disk + if (string.IsNullOrEmpty(ProjectPath)) + { + WriteLogToDisk = false; + CanWriteFiles = false; + Debug.LogWarning("Unable to setup/find the project path. Forcibly disable writing logs and files to disk"); + } + else { Directory.CreateDirectory(GetOutputPath()); - CanWriteFiles = true; + CanWriteFiles = executionContext.GetOptionsFlag(GlobalOptions.WriteFilesToDisk, CanWriteFiles); + WriteLogToDisk = executionContext.GetOptionsFlag(GlobalOptions.WriteLogsToDisk, WriteLogToDisk); } - if (executionContext.AnalyzerConfigOptions.GlobalOptions.TryGetValue(GlobalOptions.WriteFilesToDisk, out var outputToDisk)) - CanWriteFiles = outputToDisk == "1"; + //The default log level is info. User can customise that via debug config. Info level is very light right now. + s_LogLevel.Value = LoggingLevel.Info; + var loggingLevel = executionContext.GetOptionsString(GlobalOptions.LoggingLevel); + if (!string.IsNullOrEmpty(loggingLevel) && Enum.TryParse(loggingLevel.ToLower(), out var logLevel)) + s_LogLevel.Value = logLevel; } public static string GetOutputPath() @@ -130,7 +143,9 @@ private static string FindProjectFolderFromAdditionalFile(string folder) public static ulong ComputeVariantHash(ITypeSymbol variantType, ITypeSymbol componentType) { - return ComputeVariantHash(Roslyn.Extensions.GetFullTypeName(variantType), Roslyn.Extensions.GetFullTypeName(componentType)); + return ComputeVariantHash( + Roslyn.Extensions.GetMetadataQualifiedName(variantType), + Roslyn.Extensions.GetMetadataQualifiedName(componentType)); } public static ulong ComputeVariantHash(string variantTypeFullname, string componentTypeFullName) @@ -173,7 +188,9 @@ public static void LaunchDebugger() public static void LaunchDebugger(GeneratorExecutionContext context, string assembly) { - if(context.Compilation.AssemblyName == assembly) + if(string.IsNullOrEmpty(assembly) + || string.IsNullOrEmpty(context.Compilation.AssemblyName) + || context.Compilation.AssemblyName.Equals(assembly, StringComparison.InvariantCultureIgnoreCase)) { LaunchDebugger(); } @@ -241,12 +258,22 @@ public static void LogException(Exception exception) Console.WriteLine($"Exception while writing to log: {flushEx.Message}"); } } + public static void LogDebug(string message) + { + if(Helpers.CurrentLogLevel > Helpers.LoggingLevel.Debug) + return; + LogToDebugStream("Debug", message); + } public static void LogInfo(string message) { + if(Helpers.CurrentLogLevel > Helpers.LoggingLevel.Info) + return; LogToDebugStream("Info", message); } public static void LogWarning(string message) { + if(Helpers.CurrentLogLevel > Helpers.LoggingLevel.Warning) + return; LogToDebugStream("Warning", message); } public static void LogError(string message) diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/IDiagnosticReporter.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/IDiagnosticReporter.cs index 7ec9606..37edc17 100644 --- a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/IDiagnosticReporter.cs +++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/IDiagnosticReporter.cs @@ -8,6 +8,13 @@ namespace Unity.NetCode.Generators ///
  • internal interface IDiagnosticReporter { + void LogDebug(string message, Location location); + void LogDebug(string message, + [System.Runtime.CompilerServices.CallerFilePath] + string sourceFilePath = "", + [System.Runtime.CompilerServices.CallerLineNumber] + int sourceLineNumber = 0); + void LogInfo(string message, Location location); void LogInfo(string message, [System.Runtime.CompilerServices.CallerFilePath] diff --git a/Runtime/SourceGenerators/Source~/Tests/SourceGeneratorTests.cs b/Runtime/SourceGenerators/Source~/Tests/SourceGeneratorTests.cs index 94d2c00..7acdbe1 100644 --- a/Runtime/SourceGenerators/Source~/Tests/SourceGeneratorTests.cs +++ b/Runtime/SourceGenerators/Source~/Tests/SourceGeneratorTests.cs @@ -1079,7 +1079,7 @@ public struct DefaultComponent : IComponentData ((AssignmentExpressionSyntax) e).Left.ToString() == "SendMask") as AssignmentExpressionSyntax; Assert.That(componentTypeAssignment, Is.Not.Null); Assert.AreEqual(componentTypeAssignment!.Right.ToString(), - "GhostComponentSerializer.SendMask.Interpolated|GhostComponentSerializer.SendMask.Predicted"); + "GhostSendType.AllClients"); // OwnerSendType = SendToOwnerType.All componentTypeAssignment = initBlockWalker.Intializer.Expressions.FirstOrDefault(e => @@ -1147,21 +1147,21 @@ public struct DontSendToChild : IComponentData [Test] [TestCase(GhostPrefabType.All, GhostSendType.AllClients, - ExpectedResult = - "GhostComponentSerializer.SendMask.Interpolated|GhostComponentSerializer.SendMask.Predicted")] + ExpectedResult = "GhostSendType.AllClients")] [TestCase(GhostPrefabType.All, GhostSendType.OnlyPredictedClients, - ExpectedResult = "GhostComponentSerializer.SendMask.Predicted")] + ExpectedResult = "GhostSendType.OnlyPredictedClients")] [TestCase(GhostPrefabType.All, GhostSendType.OnlyInterpolatedClients, - ExpectedResult = "GhostComponentSerializer.SendMask.Interpolated")] + ExpectedResult = "GhostSendType.OnlyInterpolatedClients")] [TestCase(GhostPrefabType.PredictedClient, GhostSendType.OnlyPredictedClients, - ExpectedResult = "GhostComponentSerializer.SendMask.Predicted")] + ExpectedResult = "GhostSendType.OnlyPredictedClients")] [TestCase(GhostPrefabType.PredictedClient, GhostSendType.OnlyInterpolatedClients, - ExpectedResult = "GhostComponentSerializer.SendMask.Predicted")] + ExpectedResult = "GhostSendType.OnlyPredictedClients")] [TestCase(GhostPrefabType.InterpolatedClient, GhostSendType.OnlyPredictedClients, - ExpectedResult = "GhostComponentSerializer.SendMask.Interpolated")] + ExpectedResult = "GhostSendType.OnlyInterpolatedClients")] [TestCase(GhostPrefabType.InterpolatedClient, GhostSendType.OnlyInterpolatedClients, - ExpectedResult = "GhostComponentSerializer.SendMask.Interpolated")] - [TestCase(GhostPrefabType.Server, GhostSendType.AllClients, ExpectedResult = "GhostComponentSerializer.SendMask.None")] + ExpectedResult = "GhostSendType.OnlyInterpolatedClients")] + [TestCase(GhostPrefabType.Server, GhostSendType.AllClients, + ExpectedResult = "GhostSendType.DontSend")] public string SourceGenerator_SendType_IsSetCorrectly(GhostPrefabType prefabType, GhostSendType sendType) { var testData = $@" @@ -1399,7 +1399,7 @@ public struct PlayerInput : IInputComponentData tree.GetCompilationUnitRoot().Accept(walker); Assert.AreEqual(1, walker.Receiver.Candidates.Count); - // Should get input buffer struct (IInputBufferData etc) and the command data (ICommandDataSerializer etc) generated from that + // Should get input buffer struct (InputBufferData etc) and the command data (ICommandDataSerializer etc) generated from that // and the registration system with the empty variant registration data var results = GeneratorTestHelpers.RunGenerators(tree); Assert.AreEqual(3, results.GeneratedSources.Length, "Num generated files does not match"); @@ -1407,7 +1407,7 @@ public struct PlayerInput : IInputComponentData var commandSourceData = results.GeneratedSources[1].SyntaxTree; var inputBufferSyntax = bufferSourceData.GetRoot().DescendantNodes().OfType() - .FirstOrDefault(node => node.Identifier.ValueText == "PlayerInputInputBufferData"); + .FirstOrDefault(node => node.Identifier.ValueText == "PlayerInputEventHelper"); Assert.IsNotNull(inputBufferSyntax); var commandSyntax = commandSourceData.GetRoot().DescendantNodes().OfType() .FirstOrDefault(node => node.Identifier.ValueText == "PlayerInputInputBufferDataSerializer"); @@ -1461,7 +1461,7 @@ struct DataComposition var commandSourceData = results.GeneratedSources[1].SyntaxTree; var inputBufferSyntax = bufferSourceData.GetRoot().DescendantNodes().OfType() - .FirstOrDefault(node => node.Identifier.ValueText == "ParentClass1_ParentClass2_PlayerInputInputBufferData"); + .FirstOrDefault(node => node.Identifier.ValueText == "ParentClass1_ParentClass2_PlayerInputEventHelper"); Assert.IsNotNull(inputBufferSyntax); var commandSyntax = commandSourceData.GetRoot().DescendantNodes().OfType() .FirstOrDefault(node => node.Identifier.ValueText == "ParentClass1_ParentClass2_PlayerInputInputBufferDataSerializer"); @@ -1515,7 +1515,7 @@ public struct PlayerInput : IInputComponentData var componentSourceData = results.GeneratedSources[2].SyntaxTree; var registrationSourceData = results.GeneratedSources[3].SyntaxTree; var inputBufferSyntax = bufferSourceData.GetRoot().DescendantNodes().OfType() - .FirstOrDefault(node => node.Identifier.ValueText == "PlayerInputInputBufferData"); + .FirstOrDefault(node => node.Identifier.ValueText == "PlayerInputEventHelper"); Assert.IsNotNull(inputBufferSyntax); var commandSyntax = commandSourceData.GetRoot().DescendantNodes().OfType() @@ -1545,7 +1545,7 @@ public struct PlayerInput : IInputComponentData // in the ghost snapshots for remote players sourceText = componentSyntax.GetText(); Assert.AreEqual(1, sourceText.Lines.Where((line => line.ToString().Contains("PrefabType = GhostPrefabType.All"))).Count()); - Assert.AreEqual(1, sourceText.Lines.Where((line => line.ToString().Contains("SendMask = GhostComponentSerializer.SendMask.Interpolated|GhostComponentSerializer.SendMask.Predicted"))).Count()); + Assert.AreEqual(1, sourceText.Lines.Where((line => line.ToString().Contains("SendMask = GhostSendType.AllClients"))).Count()); Assert.AreEqual(1, sourceText.Lines.Where((line => line.ToString().Contains("SendToOwner = SendToOwnerType.SendToNonOwner"))).Count()); var maskBits = componentSyntax.DescendantNodes().OfType() diff --git a/Runtime/SourceGenerators/Source~/Tests/SyntaxReceiver_Tests.cs b/Runtime/SourceGenerators/Source~/Tests/SyntaxReceiver_Tests.cs index ddf85d9..4a39f5c 100644 --- a/Runtime/SourceGenerators/Source~/Tests/SyntaxReceiver_Tests.cs +++ b/Runtime/SourceGenerators/Source~/Tests/SyntaxReceiver_Tests.cs @@ -40,6 +40,25 @@ public struct MyTest2 : IComponentData, IEquatable Assert.AreEqual(2, receiver.Candidates.Count); } + [Test] + public void SyntaxReceiver_SkipGenericTypes() + { + var testData = @" + using Unity.Entities; + using Unity.NetCode; + using Unity.Mathematics; + public struct MyTest : IComponentData + { + [GhostField] public int IntValue; + } + "; + var receiver = GeneratorTestHelpers.CreateSyntaxReceiver(); + var walker = new TestSyntaxWalker {Receiver = receiver}; + + CSharpSyntaxTree.ParseText(testData).GetCompilationUnitRoot().Accept(walker); + Assert.AreEqual(0, receiver.Candidates.Count); + } + [Test] public void SyntaxReceiver_FindVariants() { diff --git a/Runtime/Stats/netdbg.js b/Runtime/Stats/netdbg.js index ba07740..6cc4211 100644 --- a/Runtime/Stats/netdbg.js +++ b/Runtime/Stats/netdbg.js @@ -671,6 +671,21 @@ NetDbg.prototype.present = function() { content.ctx.fillRect(xpos, 0, this.SnapshotWidth, content.frames[i].discardedPackets * 10); } + // Vertical timeline lines, one for each snapshot tick. + { + content.ctx.strokeStyle = "gray"; + var xpos = i*this.SnapshotWidth - currentOffset; + var ypos = snapshotHeight; + // TODO: I'd like to show larger lines for the TickRate markers here (e.g. 60Hz), but it's not currently sent. + var isHundredMarker = content.frames[i].serverTick % 100 === 0; + var isTenTickMarker = content.frames[i].serverTick % 10 === 0; + content.ctx.beginPath(); + content.ctx.lineTo(xpos, ypos); + ypos += isHundredMarker ? 60 : (isTenTickMarker ? 30 : 20); + content.ctx.lineTo(xpos, ypos); + content.ctx.stroke(); + } + if (showPredictionErrors) { var predictionErrorBase = snapshotContentHeight + predictionErrorHeight; if (content.frames[i].predictionError != undefined) { diff --git a/Runtime/Unity.NetCode.asmdef b/Runtime/Unity.NetCode.asmdef index 392253d..ce7c335 100644 --- a/Runtime/Unity.NetCode.asmdef +++ b/Runtime/Unity.NetCode.asmdef @@ -13,7 +13,9 @@ "Unity.Networking.Transport", "Unity.Build", "Unity.Logging", - "Unity.Properties" + "Unity.Properties", + "Unity.DedicatedServer.MultiplayerRoles", + "Unity.DedicatedServer.MultiplayerRoles.Editor" ], "includePlatforms": [], "excludePlatforms": [], @@ -24,15 +26,20 @@ "defineConstraints": [], "versionDefines": [ { - "name": "com.unity.logging", - "expression": "0.0", - "define": "USING_OBSOLETE_METHODS_VIA_INTERNALSVISIBLETO" + "name": "com.unity.logging", + "expression": "0.0", + "define": "USING_OBSOLETE_METHODS_VIA_INTERNALSVISIBLETO" }, { "name": "Unity", "expression": "2022.2.14f1", "define": "UNITY_2022_2_14F1_OR_NEWER" + }, + { + "name": "com.unity.dedicated-server", + "expression": "0.8.0", + "define": "UNITY_USE_MULTIPLAYER_ROLES" } ], "noEngineReferences": false -} +} \ No newline at end of file diff --git a/Tests/Editor/BootstrapTests.cs b/Tests/Editor/BootstrapTests.cs index 53ac814..7882fe4 100644 --- a/Tests/Editor/BootstrapTests.cs +++ b/Tests/Editor/BootstrapTests.cs @@ -41,10 +41,12 @@ protected override void OnUpdate() /// The does some additional saving and writing, which needs to be tested. public enum PredictionSetting { - WithPredictedEntities, - WithInterpolatedEntities + WithPredictedEntities = 1, + WithInterpolatedEntities = 2, + // FIXME: Add support for WithPredictedAndOwnedEntities = 3, } + /// Defines which variant to use during testing (and how that variant is applied), thus testing all user flows. public enum SendForChildrenTestCase { /// diff --git a/Tests/Editor/ChangeFilterTests.cs b/Tests/Editor/ChangeFilterTests.cs new file mode 100644 index 0000000..8aaa763 --- /dev/null +++ b/Tests/Editor/ChangeFilterTests.cs @@ -0,0 +1,425 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Entities; +using Unity.Jobs.LowLevel.Unsafe; +using Unity.Transforms; +using UnityEngine; +using UnityEngine.Profiling; + +namespace Unity.NetCode.Tests +{ + [DisableAutoCreation] + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + partial class TestChangeFilter : SystemBase + { + public NativeHashMap changedEntities; + public NativeHashMap changedComponents; + public NativeList checkForChanges; + private uint lastSystemVersion; + public NativeList queries; + protected override void OnCreate() + { + changedEntities = new NativeHashMap(100, Allocator.Persistent); + checkForChanges = new NativeList(Allocator.Persistent); + changedComponents = new NativeHashMap(100, Allocator.Persistent); + queries = new NativeList(Allocator.Temp); + } + + public void BuildQueries() + { + //Can't use the correct IgnoreEnableComponentState with change filtering, + //it will incorrectly report entities as changed all the time. + queries.Clear(); + foreach (var componentType in checkForChanges) + { + queries.Add(GetEntityQuery(new EntityQueryDesc[] + { + new EntityQueryDesc() + { + All = new []{componentType}, + Options = EntityQueryOptions.IgnoreComponentEnabledState + } + })); + } + } + + protected override void OnDestroy() + { + checkForChanges.Dispose(); + changedEntities.Dispose(); + changedComponents.Dispose(); + } + + protected override void OnUpdate() + { + var entityHandle = GetEntityTypeHandle(); + for (var index = 0; index < checkForChanges.Length; index++) + { + var componentType = checkForChanges[index]; + var query = queries[index]; + var chunks = query.ToArchetypeChunkArray(Allocator.Temp); + var typeHandle = GetDynamicComponentTypeHandle(componentType); + bool hasChangedChunks = false; + foreach (var chunk in chunks) + { + if (!chunk.DidChange(ref typeHandle, LastSystemVersion)) + continue; + hasChangedChunks = true; + var entities = chunk.GetNativeArray(entityHandle); + for (int i = 0; i < entities.Length; ++i) + { + changedEntities.TryGetValue(entities[i], out var timeChanged); + changedEntities[entities[i]] = timeChanged + 1; + } + } + + if (hasChangedChunks) + { + changedComponents.TryGetValue(componentType, out var count); + changedComponents[componentType] = count + 1; + } + } + } + } + [DisableAutoCreation] + [CreateBefore(typeof(DefaultVariantSystemGroup))] + [UpdateInGroup(typeof(DefaultVariantSystemGroup))] + partial class TestChangeFilterDefaultConfig : DefaultVariantSystemBase + { + protected override void RegisterDefaultVariants(Dictionary defaultVariants) + { + defaultVariants.Add(new ComponentType(typeof(EnableableComponent_0)), Rule.OnlyChildren(typeof(EnableableComponent_0))); + defaultVariants.Add(new ComponentType(typeof(EnableableComponent_1)), Rule.OnlyChildren(typeof(EnableableComponent_1))); + defaultVariants.Add(new ComponentType(typeof(EnableableComponent_2)), Rule.OnlyChildren(typeof(EnableableComponent_2))); + defaultVariants.Add(new ComponentType(typeof(EnableableBuffer_0)), Rule.OnlyChildren(typeof(EnableableBuffer_0))); + defaultVariants.Add(new ComponentType(typeof(EnableableBuffer_1)), Rule.OnlyChildren(typeof(EnableableBuffer_1))); + defaultVariants.Add(new ComponentType(typeof(EnableableBuffer_2)), Rule.OnlyChildren(typeof(EnableableBuffer_2))); + } + } + + public class ChangeFilterTests + { + [TestCase(1)] + [TestCase(10)] + public void RestoreFromBackupDoesNotAffecUnchangeComponents(int entityCount) + { + using var testWorld = new NetCodeTestWorld(); + testWorld.Bootstrap(true, typeof(TestChangeFilterDefaultConfig), typeof(TestChangeFilter)); + testWorld.CreateWorlds(true, 1); + testWorld.CreateGhostCollection(); + var prefab = CreatePrefab(testWorld.ServerWorld.EntityManager); + CreatePrefab(testWorld.ClientWorlds[0].EntityManager); + testWorld.Connect(1f / 60f, 8); + testWorld.GoInGame(); + + var dt = 1f / 60f; + for (int i = 0; i < 32; ++i) + testWorld.Tick(dt); + + var testFilter = testWorld.ClientWorlds[0].GetOrCreateSystemManaged(); + //All components are replicated and use the default variant for serialisation. + testFilter.checkForChanges = new NativeList(Allocator.Temp); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.checkForChanges.Add(ComponentType.ReadOnly()); + testFilter.BuildQueries(); + + NativeList serverEntities = new NativeList(Allocator.Temp); + for (int i = 0; i < entityCount; ++i) + serverEntities.Add(testWorld.ServerWorld.EntityManager.Instantiate(prefab)); + + //Spawn all entities, reach a stable state. + for (int i = 0; i < 32; ++i) + testWorld.Tick(dt); + + //Expectation: all entities has changed, all components has changed once! + //There is no data changes, so apart from the first spawn no change filter should trigger + Assert.AreEqual(6*entityCount, testFilter.changedEntities.Count); + Assert.AreEqual(testFilter.checkForChanges.Length, testFilter.changedComponents.Count, "All components must have been changed at least once because of the new spawn"); + //Checks that also partial ticks does not invalidate the filters + testFilter.changedEntities.Clear(); + testFilter.changedComponents.Clear(); + for (int i = 0; i < 128; ++i) + testWorld.Tick(dt/3f); + + + testWorld.ClientWorlds[0].EntityManager.CompleteAllTrackedJobs(); + if (testFilter.changedComponents.Count != 0) + { + //report the changed components and the changed entities to help understand what happens + var sb = new StringBuilder(); + sb.Append($"Expecting no component changes when restoring from backup, but {testFilter.changedComponents.Count} components has their version bumped.\n"); + sb.Append("\nChanged Components:\n"); + foreach (var component in testFilter.changedComponents) + sb.Append(component.Key); + sb.Append("\nChanged Entities:\n"); + foreach (var entity in testFilter.changedEntities) + sb.Append(entity.Key); + Assert.Fail(sb.ToString()); + } + //if data is modified by the server, only the change component should be reported as modified. + //Also the modified component should be reported has changed only once + ChangeComponentValue(testWorld.ServerWorld); + ChangeComponentValue(testWorld.ServerWorld); + ChangeBuffer(testWorld.ServerWorld); + ChangeBuffer(testWorld.ServerWorld, 8); + testFilter.changedEntities.Clear(); + testFilter.changedComponents.Clear(); + for (int i = 0; i < 128; ++i) + testWorld.Tick(dt/3f); + testWorld.ServerWorld.EntityManager.CompleteAllTrackedJobs(); + testWorld.ClientWorlds[0].EntityManager.CompleteAllTrackedJobs(); + + Assert.AreEqual(4, testFilter.changedComponents.Count, "Expect only EnableableComponent_1,EnableableComponent_2, EnableableBuffer_0, EnableableBuffer_1 changed"); + Assert.AreEqual(6*entityCount, testFilter.changedEntities.Count, "Expected all entities has some component changed"); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly()), "Expect EnableableComponent_1 changed"); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly()), "Expect EnableableComponent_2 changed"); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly()), "Expect EnableableBuffer_0 changed"); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly()), "Expect EnableableBuffer_1 changed"); + //In presence of partial snapshot, all depends how the server send the data. + //We can check the == 1 when we use a very small number of entities. + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1, "The componnet should have been reported has changed at least once"); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1, "The componnet should have been reported has changed at least once"); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1, "The componnet should have been reported has changed at least once"); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1, "The componnet should have been reported has changed at least once"); + //If nothing changes here, no changes should be reported + testFilter.changedEntities.Clear(); + testFilter.changedComponents.Clear(); + for (int i = 0; i < 32; ++i) + testWorld.Tick(dt/3f); + Assert.AreEqual(0, testFilter.changedComponents.Count, "No component should change, if server does not change the data again"); + + var ghostQuery = testWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(typeof(GhostInstance)); + var rooGhosts = ghostQuery.ToEntityArray(Allocator.Temp); + //Changing parent only component does not change child. + ChangeComponentValue(testWorld.ServerWorld, FilterEntity.OnlyParent, iteration:1); + testFilter.changedEntities.Clear(); + testFilter.changedComponents.Clear(); + for (int i = 0; i < 128; ++i) + testWorld.Tick(dt/3f); + testWorld.ServerWorld.EntityManager.CompleteAllTrackedJobs(); + testWorld.ClientWorlds[0].EntityManager.CompleteAllTrackedJobs(); + Assert.AreEqual(entityCount, testFilter.changedEntities.Count, "Only the root should have changed"); + Assert.IsTrue(testFilter.changedEntities.ContainsKey(rooGhosts[0])); + Assert.AreEqual(1, testFilter.changedComponents.Count, "Expect only EnableableComponent_1 is changed"); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly())); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1); + + //Changing component only on child entities does not invalidate the parent entity + ChangeComponentValue(testWorld.ServerWorld, FilterEntity.OnlyChildren, iteration:2); + ChangeComponentValue(testWorld.ServerWorld, FilterEntity.OnlyChildren, iteration:2); + ChangeComponentValue(testWorld.ServerWorld, FilterEntity.OnlyChildren, iteration:2); + ChangeBuffer(testWorld.ServerWorld, filterEntity:FilterEntity.OnlyChildren, iteration:2); + ChangeBuffer(testWorld.ServerWorld, 8, FilterEntity.OnlyChildren, iteration:2); + testFilter.changedEntities.Clear(); + testFilter.changedComponents.Clear(); + for (int i = 0; i < 128; ++i) + testWorld.Tick(dt/3f); + testWorld.ServerWorld.EntityManager.CompleteAllTrackedJobs(); + testWorld.ClientWorlds[0].EntityManager.CompleteAllTrackedJobs(); + Assert.AreEqual(5, testFilter.changedComponents.Count); + Assert.AreEqual(5*entityCount, testFilter.changedEntities.Count); + Assert.IsFalse(testFilter.changedEntities.ContainsKey(rooGhosts[0])); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly())); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly())); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly())); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly())); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(ComponentType.ReadOnly())); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1); + Assert.GreaterOrEqual(testFilter.changedComponents[ComponentType.ReadOnly()], 1); + + //Changing component on a specific entity, only affect the entities and components for that chunk. + var spawnMap = testWorld.GetSingleton(testWorld.ClientWorlds[0]); + for (var entIndex = 0; entIndex < serverEntities.Length; entIndex++) + { + var ent = serverEntities[entIndex]; + var serverGroup = testWorld.ServerWorld.EntityManager.GetBuffer(ent).ToNativeArray(Allocator.Temp); + var ghost = testWorld.ServerWorld.EntityManager.GetComponentData(ent); + spawnMap.Value.TryGetValue(new SpawnedGhost(ghost.ghostId,ghost.spawnTick), out var clientRoot); + var clientGroup = testWorld.ClientWorlds[0].EntityManager.GetBuffer(clientRoot).ToNativeArray(Allocator.Temp); + for (var index = 0; index < serverGroup.Length; index++) + { + var entity = serverGroup[index]; + var clientEntity = clientGroup[index]; + TestSingleComponentChange(testWorld, entity.Value, clientEntity.Value); + TestSingleComponentChange(testWorld, entity.Value, clientEntity.Value); + TestSingleComponentChange(testWorld, entity.Value, clientEntity.Value); + TestSingleComponentChange(testWorld, entity.Value, clientEntity.Value); + TestSingleBuffChange(testWorld, entity.Value, clientEntity.Value); + TestSingleBuffChange(testWorld, entity.Value, clientEntity.Value); + TestSingleBuffChange(testWorld, entity.Value, clientEntity.Value); + } + } + } + + private static void TestSingleComponentChange(NetCodeTestWorld testWorld, Entity entity, Entity clientEntity) + where T: unmanaged, IComponentData, IEnableableComponent, IComponentValue + { + if (!testWorld.ServerWorld.EntityManager.HasComponent(entity)) + return; + var value = new T(); + value.SetValue(entity.Index); + testWorld.ServerWorld.EntityManager.SetComponentData(entity, value); + TestLoop(testWorld, clientEntity, ComponentType.ReadOnly()); + testWorld.ServerWorld.EntityManager.SetComponentEnabled(entity, false); + TestLoop(testWorld, clientEntity, ComponentType.ReadOnly()); + testWorld.ServerWorld.EntityManager.SetComponentEnabled(entity, true); + TestLoop(testWorld, clientEntity, ComponentType.ReadOnly()); + } + private static void TestSingleBuffChange(NetCodeTestWorld testWorld, Entity entity, Entity clientEntity) + where T: unmanaged, IBufferElementData, IEnableableComponent, IComponentValue + { + if (!testWorld.ServerWorld.EntityManager.HasComponent(entity)) + return; + var buffer = testWorld.ServerWorld.EntityManager.GetBuffer(entity); + buffer.ResizeUninitialized(10); + TestLoop(testWorld, clientEntity, ComponentType.ReadOnly()); + buffer = testWorld.ServerWorld.EntityManager.GetBuffer(entity); + for (int i = 0; i < buffer.Length; ++i) + buffer.ElementAt(i).SetValue(entity.Index); + TestLoop(testWorld, clientEntity, ComponentType.ReadOnly()); + } + + private static void TestLoop(NetCodeTestWorld testWorld, Entity entity, ComponentType componentType) + { + var testFilter = testWorld.ClientWorlds[0].GetExistingSystemManaged(); + testFilter.changedEntities.Clear(); + testFilter.changedComponents.Clear(); + for (int i = 0; i < 8; ++i) + { + testWorld.Tick(1f/20f); + testWorld.ServerWorld.EntityManager.CompleteAllTrackedJobs(); + testWorld.ClientWorlds[0].EntityManager.CompleteAllTrackedJobs(); + } + Assert.IsTrue(testFilter.changedEntities.ContainsKey(entity), $"Expect entity {entity} changed"); + Assert.IsTrue(testFilter.changedComponents.ContainsKey(componentType), $"Expect component {componentType} changed for entity {entity}"); + Assert.AreEqual(1, testFilter.changedComponents.Count, $"Expected only {componentType} changed."); + var expectedTouchedEntities = testWorld.ClientWorlds[0].EntityManager.GetChunk(entity).Count; + Assert.AreEqual(expectedTouchedEntities, testFilter.changedEntities.Count, "Expected only one entities that has the same archetype are affected"); + Assert.AreEqual(testFilter.changedComponents[componentType], 1, $"Expected {componentType} changed only once"); + } + + enum FilterEntity + { + BothParentAndChildren, + OnlyParent, + OnlyChildren + } + + private void ChangeComponentValue(World world, + FilterEntity filterEntity= FilterEntity.BothParentAndChildren, + int iteration = 0) where T: unmanaged, IComponentData, IComponentValue + { + var builder = new EntityQueryBuilder(Allocator.Temp); + builder.WithAll(); + if(filterEntity == FilterEntity.OnlyParent) + builder.WithAll(); + else if (filterEntity == FilterEntity.OnlyChildren) + builder.WithAll(); + using var ghosts = world.EntityManager.CreateEntityQuery(builder); + using var chunks = ghosts.ToArchetypeChunkArray(Allocator.Temp); + var t1 = world.EntityManager.GetComponentTypeHandle(false); + foreach (var chunk in chunks) + { + unsafe + { + var c1 = (T*)chunk.GetNativeArray(ref t1).GetUnsafePtr(); + for (int i = 0; i < chunk.Count; ++i) + c1[i].SetValue(typeof(T).GetHashCode() * (i+i) + iteration*1000); + } + } + } + private void ChangeBuffer(World world, int newLen = -1, + FilterEntity filterEntity= FilterEntity.BothParentAndChildren, int iteration=0) where T: unmanaged, IBufferElementData, IComponentValue + { + var builder = new EntityQueryBuilder(Allocator.Temp); + builder.WithAll(); + if(filterEntity == FilterEntity.OnlyParent) + builder.WithAll(); + else if (filterEntity == FilterEntity.OnlyChildren) + builder.WithAll(); + using var ghosts = world.EntityManager.CreateEntityQuery(builder); + using var chunks = ghosts.ToArchetypeChunkArray(Allocator.Temp); + var t1 = world.EntityManager.GetBufferTypeHandle(false); + foreach (var chunk in chunks) + { + var bufferAccessor = chunk.GetBufferAccessor(ref t1); + for (int i = 0; i < chunk.Count; ++i) + { + if(newLen >= 0) + bufferAccessor[i].ResizeUninitialized(newLen); + var len = bufferAccessor[i].Length; + for (int k = 0; k < len; ++k) + bufferAccessor[i].ElementAt(k).SetValue(typeof(T).GetHashCode() * (i + i) + iteration*1000); + } + } + } + + private static Entity CreatePrefab(EntityManager entityManager) + { + //This create a ghost with 5 child entites, of which 3 in the same chunk, and other 2 in distinct chunks + //for an an overall use of 4 archetypes per ghost. + var prefab = entityManager.CreateEntity(); + entityManager.AddComponentData(prefab, new EnableableComponent_0{value = 1}); + entityManager.AddComponentData(prefab, new EnableableComponent_1{value = 2}); + entityManager.AddComponentData(prefab, new EnableableComponent_2{value = 3}); + entityManager.AddComponentData(prefab, new EnableableComponent_3{value = 4}); + entityManager.AddComponentData(prefab, LocalTransform.Identity); + entityManager.AddComponent(prefab); + entityManager.AddBuffer(prefab).ResizeUninitialized(3); + entityManager.AddBuffer(prefab).ResizeUninitialized(4); + entityManager.AddBuffer(prefab).ResizeUninitialized(5); + entityManager.AddBuffer(prefab); + entityManager.GetBuffer(prefab).Add(prefab); + for (int i = 0; i < 5; ++i) + { + var child = entityManager.CreateEntity(); + entityManager.AddComponent(child); + if (i < 3) + { + entityManager.AddComponentData(child, new EnableableComponent_1{value = 10 + i}); + entityManager.AddComponentData(child, new EnableableComponent_2{value = 20 + i}); + entityManager.AddComponentData(child, new EnableableComponent_3{value = 30 + i}); + entityManager.AddBuffer(child).ResizeUninitialized(3); + entityManager.AddBuffer(child).ResizeUninitialized(4); + } + else if (i == 3) + { + entityManager.AddComponentData(child, new EnableableComponent_1{value = 10 + i}); + entityManager.AddComponentData(child, new EnableableComponent_2{value = 20 + i}); + } + else if (i == 4) + { + entityManager.AddComponentData(child, new EnableableComponent_0{value = 10 + i}); + entityManager.AddComponentData(child, new EnableableComponent_1{value = 30 + i}); + entityManager.AddBuffer(child).ResizeUninitialized(3); + } + entityManager.GetBuffer(prefab).Add(child); + } + + GhostPrefabCreation.ConvertToGhostPrefab(entityManager, prefab, new GhostPrefabCreation.Config + { + Name = "TestPrefab", + Importance = 0, + SupportedGhostModes = GhostModeMask.Predicted, + DefaultGhostMode = GhostMode.Predicted, + OptimizationMode = GhostOptimizationMode.Dynamic, + UsePreSerialization = false + }); + return prefab; + } + } +} diff --git a/Tests/Editor/ChangeFilterTests.cs.meta b/Tests/Editor/ChangeFilterTests.cs.meta new file mode 100644 index 0000000..36e5c5f --- /dev/null +++ b/Tests/Editor/ChangeFilterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1a35bd2438b41929f0a22c9fe78a84d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/CommandBufferSerialization.cs b/Tests/Editor/CommandBufferSerialization.cs index f87a0ac..d118df7 100644 --- a/Tests/Editor/CommandBufferSerialization.cs +++ b/Tests/Editor/CommandBufferSerialization.cs @@ -108,7 +108,7 @@ public class CommandBufferTests ghostConfig.DefaultGhostMode = mode; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 64)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverEnt = SpawnEntityAndAssignOwnerOnServer(testWorld, ghostGameObject, 0); @@ -166,7 +166,7 @@ public class CommandBufferTests Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 2); - Assert.IsTrue(testWorld.Connect(deltaTime, 64)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverEnt = SpawnEntityAndAssignOwnerOnServer(testWorld, ghostGameObject, 0); @@ -224,7 +224,7 @@ public class CommandBufferTests int numClients = 2; testWorld.CreateWorlds(true, numClients); - Assert.IsTrue(testWorld.Connect(deltaTime, 64)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverEnt = SpawnEntityAndAssignOwnerOnServer(testWorld, ghostGameObject, 0); @@ -265,7 +265,7 @@ public void CommandDataBuffer_OwnerPredicted_InterpolatedClientes_ShouldNotRecei int numClients = 3; testWorld.CreateWorlds(true, numClients); - Assert.IsTrue(testWorld.Connect(deltaTime, 64)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverEnt1 = SpawnEntityAndAssignOwnerOnServer(testWorld, ghostGameObject, 0); diff --git a/Tests/Editor/CommandDataTests.cs b/Tests/Editor/CommandDataTests.cs index 158bc14..c590896 100644 --- a/Tests/Editor/CommandDataTests.cs +++ b/Tests/Editor/CommandDataTests.cs @@ -81,7 +81,7 @@ public void MissingCommandTargetUpdatesAckAndCommandAge() ghostConfig.HasOwner = true; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -119,7 +119,7 @@ public void ConnectionCommandTargetComponentSendsDataForSingleBuffer() ghostConfig.HasOwner = true; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -170,7 +170,7 @@ public void ConnectionCommandTargetComponentSendsDataForMultipleBuffers() ghostConfig.HasOwner = true; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -239,7 +239,7 @@ public void AutoCommandTargetSendsDataForSingleBuffer() ghostConfig.DefaultGhostMode = GhostMode.OwnerPredicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -281,7 +281,7 @@ public void AutoCommandTargetSendsDataForMultipleBuffers() ghostConfig.DefaultGhostMode = GhostMode.OwnerPredicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -335,7 +335,7 @@ public void MultipleAutoCommandTargetSendsData() ghostConfig.DefaultGhostMode = GhostMode.OwnerPredicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -399,7 +399,7 @@ public void ConnectionCommandTargetAndAutoCommandTargetSendsDataAtTheSameTime() ghostConfig.DefaultGhostMode = GhostMode.OwnerPredicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -457,7 +457,7 @@ public void AutoCommandTargetDoesNotSendWhenDisabled() ghostConfig.DefaultGhostMode = GhostMode.OwnerPredicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -500,7 +500,7 @@ public void AutoCommandTargetDoesNotSendWhenNotPredicted() ghostConfig.DefaultGhostMode = GhostMode.Interpolated; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -542,7 +542,7 @@ public void AutoCommandTargetDoesNotSendWhenNotOwned() ghostConfig.DefaultGhostMode = GhostMode.Predicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(deltaTime, 4)); + testWorld.Connect(deltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); diff --git a/Tests/Editor/ConnectionTests.cs b/Tests/Editor/ConnectionTests.cs index 76953c9..4677ce5 100644 --- a/Tests/Editor/ConnectionTests.cs +++ b/Tests/Editor/ConnectionTests.cs @@ -4,6 +4,7 @@ using Unity.Entities; using Unity.NetCode.LowLevel.Unsafe; using Unity.Networking.Transport; +using Unity.Transforms; using UnityEngine; using UnityEngine.TestTools; @@ -60,6 +61,48 @@ public void ConnectSingleClient() Assert.AreEqual(1, testWorld.ClientWorlds[0].GetExistingSystemManaged().numInGame); } } + + [TestCase(60, 60, 1)] + [TestCase(40, 20, 2)] + public void ClientTickRate_ServerAndClientsUseTheSameRateSettings( + int simulationTickRate, int networkTickRate, int predictedFixedStepRatio) + { + using var testWorld = new NetCodeTestWorld(); + var tickRate = new ClientServerTickRate + { + SimulationTickRate = simulationTickRate, + PredictedFixedStepSimulationTickRatio = predictedFixedStepRatio, + NetworkTickRate = networkTickRate, + }; + SetupTickRate(tickRate, testWorld); + //Check that the predicted fixed step rate is also set accordingly. + LogAssert.NoUnexpectedReceived(); + Assert.AreEqual(tickRate.PredictedFixedStepSimulationTimeStep, testWorld.ServerWorld.GetExistingSystemManaged().Timestep); + Assert.AreEqual(tickRate.PredictedFixedStepSimulationTimeStep, testWorld.ClientWorlds[0].GetExistingSystemManaged().Timestep); + } + + static void SetupTickRate(ClientServerTickRate tickRate, NetCodeTestWorld testWorld) + { + testWorld.Bootstrap(true); + testWorld.CreateWorlds(true, 1); + testWorld.ServerWorld.EntityManager.CreateSingleton(tickRate); + tickRate.ResolveDefaults(); + tickRate.Validate(); + // Connect and make sure the connection could be established + const float frameTime = 1f / 60f; + testWorld.Connect(frameTime); + + //Check that the simulation tick rate are the same + var serverRate = testWorld.GetSingleton(testWorld.ServerWorld); + var clientRate = testWorld.GetSingleton(testWorld.ClientWorlds[0]); + Assert.AreEqual(tickRate.SimulationTickRate, serverRate.SimulationTickRate); + Assert.AreEqual(tickRate.SimulationTickRate, clientRate.SimulationTickRate); + Assert.AreEqual(tickRate.PredictedFixedStepSimulationTickRatio, serverRate.PredictedFixedStepSimulationTickRatio); + Assert.AreEqual(tickRate.PredictedFixedStepSimulationTickRatio, clientRate.PredictedFixedStepSimulationTickRatio); + + //Do one last step so all the new settings are applied + testWorld.Tick(frameTime); + } } public class VersionTests @@ -203,16 +246,12 @@ public void ProtocolVersionDebugInfoAppearsOnMismatch(bool debugServer) { using (var testWorld = new NetCodeTestWorld()) { - testWorld.Bootstrap(true); - testWorld.CreateWorlds(true, 1, false); - // Only print the protocol version debug errors in one world, so the output can be deterministically validated // if it's printed in both worlds (client and server) the output can interweave and log checks will fail - var debugWorld = testWorld.ServerWorld; - if (debugServer) - debugWorld = testWorld.ClientWorlds[0]; - var netDebug = debugWorld.EntityManager.CreateEntity(ComponentType.ReadWrite()); - debugWorld.EntityManager.SetComponentData(netDebug, new NetCodeDebugConfig(){ DumpPackets = false, LogLevel = NetDebug.LogLevelType.Exception }); + testWorld.EnableLogsOnServer = debugServer; + testWorld.EnableLogsOnClients = !debugServer; + testWorld.Bootstrap(true); + testWorld.CreateWorlds(true, 1, false); float dt = 16f / 1000f; var entity = testWorld.ClientWorlds[0].EntityManager.CreateEntity(ComponentType.ReadWrite()); @@ -245,16 +284,16 @@ public void DisconnectEventAndRPCVersionErrorProcessedInSameFrame(bool checkServ { using (var testWorld = new NetCodeTestWorld()) { + // Only print the protocol version debug errors in one world, so the output can be deterministically validated + // if it's printed in both worlds (client and server) the output can interweave and log checks will fail + testWorld.EnableLogsOnServer = checkServer; + testWorld.EnableLogsOnClients = !checkServer; testWorld.Bootstrap(true); testWorld.CreateWorlds(true, 1, false); float dt = 16f / 1000f; var entity = testWorld.ClientWorlds[0].EntityManager.CreateEntity(ComponentType.ReadWrite()); testWorld.ClientWorlds[0].EntityManager.SetComponentData(entity, new GameProtocolVersion(){Version = 9000}); - entity = testWorld.ClientWorlds[0].EntityManager.CreateEntity(ComponentType.ReadWrite()); - testWorld.ClientWorlds[0].EntityManager.SetComponentData(entity, new NetCodeDebugConfig(){LogLevel = checkServer ? NetDebug.LogLevelType.Exception : NetDebug.LogLevelType.Debug}); - entity = testWorld.ServerWorld.EntityManager.CreateEntity(ComponentType.ReadWrite()); - testWorld.ServerWorld.EntityManager.SetComponentData(entity, new NetCodeDebugConfig(){LogLevel = checkServer ? NetDebug.LogLevelType.Debug : NetDebug.LogLevelType.Exception}); testWorld.Tick(dt); var ep = NetworkEndpoint.LoopbackIpv4; @@ -336,7 +375,7 @@ public void GhostCollectionGenerateSameHashOnClientAndServer() } //Check that and server can connect (same component hash) - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<10;++i) @@ -345,5 +384,52 @@ public void GhostCollectionGenerateSameHashOnClientAndServer() Assert.IsTrue(testWorld.TryGetSingletonEntity(testWorld.ClientWorlds[0]) != Entity.Null); } } + + [Test] + public void DefaultVariantHashAreCalculatedCorrectly() + { + var realHash = GhostVariantsUtility.UncheckedVariantHash(typeof(LocalTransform).FullName, typeof(LocalTransform).FullName); + Assert.AreEqual(realHash, GhostVariantsUtility.CalculateVariantHashForComponent(ComponentType.ReadWrite())); + var compName = new FixedString512Bytes(typeof(LocalTransform).FullName); + Assert.AreEqual(realHash, GhostVariantsUtility.UncheckedVariantHash(compName, compName)); + Assert.AreEqual(realHash, GhostVariantsUtility.UncheckedVariantHash(compName, ComponentType.ReadWrite())); + Assert.AreEqual(realHash, GhostVariantsUtility.UncheckedVariantHashNBC(typeof(LocalTransform), ComponentType.ReadWrite())); + } + [Test] + public void tVariantHashAreCalculatedCorrectly() + { + var realHash = GhostVariantsUtility.UncheckedVariantHash(typeof(TransformDefaultVariant).FullName, typeof(LocalTransform).FullName); + var compName = new FixedString512Bytes(typeof(LocalTransform).FullName); + var variantName = new FixedString512Bytes(typeof(TransformDefaultVariant).FullName); + Assert.AreEqual(realHash, GhostVariantsUtility.UncheckedVariantHash(variantName, compName)); + Assert.AreEqual(realHash, GhostVariantsUtility.UncheckedVariantHash(variantName, ComponentType.ReadWrite())); + Assert.AreEqual(realHash, GhostVariantsUtility.UncheckedVariantHashNBC(typeof(TransformDefaultVariant), ComponentType.ReadWrite())); + } + [Test] + public void RuntimeAndCodeGeneratedVariantHashMatch() + { + using (var testWorld = new NetCodeTestWorld()) + { + testWorld.Bootstrap(true); + testWorld.CreateWorlds(true, 1); + //Grab all the serializers we have and recalculate locally the hash and verify they match. + //TODO: to have a complete end-to-end test we have a missing piece: we don't have the original variant System.Type. + //Either we add that in code-gen (as string, for test/debug purpose only) or we need to store somehow the type + //when we register the serialiser itself. It is not a priority, but great to have. + //Right now I exposed a a VariantTypeFullHashName in the serialiser that allow at lest to do the most + //important verification: the hash matches! + var data = testWorld.GetSingleton(testWorld.ServerWorld); + for (int i = 0; i < data.Serializers.Length; ++i) + { + var variantTypeHash = data.Serializers.ElementAt(i).VariantTypeFullNameHash; + var componentType = data.Serializers.ElementAt(i).ComponentType; + var variantHash = GhostVariantsUtility.UncheckedVariantHash(variantTypeHash, componentType); + Assert.AreEqual(data.Serializers.ElementAt(i).VariantHash, variantHash, + $"Expect variant hash for code-generated serializer is identical to the" + + $"calculated at runtime for component {componentType.GetManagedType().FullName}." + + $"generated: {data.Serializers.ElementAt(i).VariantHash} runtime:{variantHash}"); + } + } + } } } diff --git a/Tests/Editor/ExtrapolationTests.cs b/Tests/Editor/ExtrapolationTests.cs index 65c657c..32b7b52 100644 --- a/Tests/Editor/ExtrapolationTests.cs +++ b/Tests/Editor/ExtrapolationTests.cs @@ -100,7 +100,7 @@ public void MaxSmoothingDistanceIsUsed() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -149,7 +149,7 @@ public void ExtrapolationProduceSmoothValues() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/GameObjectConversionTest.cs b/Tests/Editor/GameObjectConversionTest.cs index fc31966..8594ea4 100644 --- a/Tests/Editor/GameObjectConversionTest.cs +++ b/Tests/Editor/GameObjectConversionTest.cs @@ -254,7 +254,7 @@ public void ComponentsStrippedAccordingToGhostConfig() CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly(), 3); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 64; ++i) testWorld.Tick(frameTime); diff --git a/Tests/Editor/GhostCollectionStreamingTests.cs b/Tests/Editor/GhostCollectionStreamingTests.cs index f41e66a..2bd3d8f 100644 --- a/Tests/Editor/GhostCollectionStreamingTests.cs +++ b/Tests/Editor/GhostCollectionStreamingTests.cs @@ -69,7 +69,7 @@ public void OnDemandLoadedPrefabsAreUsed() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -116,7 +116,7 @@ public void OnDemandLoadFailureCauseError() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -133,7 +133,7 @@ public void OnDemandLoadFailureCauseError() //testWorld.ConvertGhostCollection(testWorld.ClientWorlds[0]); onDemandSystem.IsLoading = false; - LogAssert.Expect(UnityEngine.LogType.Error, new Regex("The ghost collection contains a ghost which does not have a valid prefab on the client!")); + LogAssert.Expect(UnityEngine.LogType.Error, new Regex("^The ghost collection contains a ghost which does not have a valid prefab on the client!")); LogAssert.Expect(UnityEngine.LogType.Error, "Disconnecting all the connections because of errors while processing the ghost prefabs (see previous reported errors)."); for (int i = 0; i < 2; ++i) testWorld.Tick(frameTime); diff --git a/Tests/Editor/GhostGenTestTypes.cs b/Tests/Editor/GhostGenTestTypes.cs index a99dd8d..1ccd876 100644 --- a/Tests/Editor/GhostGenTestTypes.cs +++ b/Tests/Editor/GhostGenTestTypes.cs @@ -43,7 +43,7 @@ public void GhostValuesAreSerialized_IComponentData() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -126,7 +126,7 @@ public void ValuesAreSerialized_ICommandData_Strings() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -212,7 +212,7 @@ public void ValuesAreSerialized_IInputComponentData_Strings() // Connect and make sure the connection could be established float frameTime = 1.0f / 60.0f; testWorld.CreateWorlds(true, 2); - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); // Spawn ghost and set owner @@ -267,7 +267,7 @@ public void ValuesAreSerialized_IRpc() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -336,7 +336,7 @@ public void CommandTooBig() // Connect and make sure the connection could be established float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -519,7 +519,7 @@ public void StructWithLargeNumberOfFields() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/GhostGroupTests.cs b/Tests/Editor/GhostGroupTests.cs index 8419bb4..26f0ce4 100644 --- a/Tests/Editor/GhostGroupTests.cs +++ b/Tests/Editor/GhostGroupTests.cs @@ -60,7 +60,7 @@ public void EntityMarkedAsChildIsNotSent() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -105,7 +105,7 @@ public void EntityMarkedAsChildIsSentAsPartOfGroup() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -160,7 +160,7 @@ public void CanHaveManyGhostGroupGhostTypes() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -214,7 +214,7 @@ public void CanHaveManyGhostGroupsOfSameType() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/GhostSerializationDataForEnableableBits.cs b/Tests/Editor/GhostSerializationDataForEnableableBits.cs index 231e89c..e924f66 100644 --- a/Tests/Editor/GhostSerializationDataForEnableableBits.cs +++ b/Tests/Editor/GhostSerializationDataForEnableableBits.cs @@ -113,6 +113,17 @@ public void Bake(GameObject gameObject, IBaker baker) (typeof(ComponentWithDontSendChildrenVariant), typeof(ComponentWithDontSendChildrenVariantVariation)), (typeof(ComponentWithNonReplicatedVariant), typeof(ComponentWithNonReplicatedVariantVariation)), // Skipped as never replicated. (typeof(NeverReplicatedEnableableFlagComponent), null), + + // GhostComponent: + (typeof(SendForChildren_OnlyPredictedGhosts_SendToOwner_EnableableComponent), null), + (typeof(SendForChildren_OnlyInterpolatedGhosts_SendToOwner_EnableableComponent), null), + (typeof(SendForChildren_OnlyPredictedGhosts_SendToNonOwner_EnableableComponent), null), + (typeof(SendForChildren_OnlyInterpolatedGhosts_SendToNonOwner_EnableableComponent), null), + (typeof(DontSendForChildren_OnlyPredictedGhosts_SendToOwner_EnableableComponent), null), + (typeof(DontSendForChildren_OnlyInterpolatedGhosts_SendToOwner_EnableableComponent), null), + (typeof(DontSendForChildren_OnlyPredictedGhosts_SendToNonOwner_EnableableComponent), null), + (typeof(DontSendForChildren_OnlyInterpolatedGhosts_SendToNonOwner_EnableableComponent), null), + (typeof(ChildOnlyComponent_1), null), (typeof(ChildOnlyComponent_2), null), (typeof(ChildOnlyComponent_3), null), @@ -232,6 +243,16 @@ void AddTestEnableableComponents(IBaker baker) AddComponentWithDefaultValue(baker); AddComponentWithDefaultValue(baker); AddComponentWithDefaultValue(baker); + + // FIXME: GhostComponentAttribute coverage. + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); + // AddComponentWithDefaultValue(baker); } void SetupMultipleEnableableComponents(IBaker baker) @@ -446,23 +467,6 @@ public struct ComponentWithReplicatedVariant : IComponentData, IEnableableCompon public void SetValue(int value) => this.value = value; public int GetValue() => value; - - public static bool ExpectChildReplicated(SendForChildrenTestCase sendForChildrenTestCase) - { - switch (sendForChildrenTestCase) - { - // Note that the variant has: [GhostComponent(SendDataForChildEntity = true)], thus yes. - case SendForChildrenTestCase.YesViaExplicitVariantRule: - case SendForChildrenTestCase.YesViaInspectionComponentOverride: - case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: - case SendForChildrenTestCase.Default: // This is also yes, because a type having only 1 variant implies it should be used. - return true; - case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: - return false; - default: - throw new ArgumentOutOfRangeException(); - } - } } // As this is the only variant, it becomes the default variant. @@ -483,42 +487,6 @@ public struct ComponentWithDontSendChildrenVariant : IComponentData, IEnableabl public void SetValue(int value) => this.value = value; public int GetValue() => value; - - public static bool ExpectReplicate(SendForChildrenTestCase sendForChildrenTestCase) - { - switch (sendForChildrenTestCase) - { - case SendForChildrenTestCase.YesViaExplicitVariantRule: - case SendForChildrenTestCase.YesViaInspectionComponentOverride: - return true; // We explicitly use the variant that has [GhostEnabledBit]. - case SendForChildrenTestCase.Default: - return true; // We only have one variant, so we should default to it (and it has [GhostEnabledBit]). - case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: - case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: - return false; - default: - throw new ArgumentOutOfRangeException(); - } - } - - public static bool ExpectChildReplicate(SendForChildrenTestCase sendForChildrenTestCase) - { - switch (sendForChildrenTestCase) - { - // Weird case: The variant doesn't NORMALLY allow children to be replicated, - // but in this case it WILL replicate on a child because the variant is specifically set for children. - case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: - case SendForChildrenTestCase.YesViaExplicitVariantRule: - case SendForChildrenTestCase.YesViaInspectionComponentOverride: - return true; - case SendForChildrenTestCase.Default: - return false; // The variant we use "by default" doesn't replicate children. - case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: - return false; // Nothing will serialize. - default: - throw new ArgumentOutOfRangeException(); - } - } } // As this is the only variant, it becomes the default variant. @@ -540,22 +508,6 @@ public struct ComponentWithNonReplicatedVariant : IComponentData, IEnableableCom public void SetValue(int value) => this.value = value; public int GetValue() => value; - - public static bool ExpectReplicate(SendForChildrenTestCase sendForChildrenTestCase) - { - switch (sendForChildrenTestCase) - { - case SendForChildrenTestCase.YesViaExplicitVariantRule: - case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: - case SendForChildrenTestCase.YesViaInspectionComponentOverride: - return false; // Opting into a non-serialized variant. - case SendForChildrenTestCase.Default: - case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: - return false; // Default variant never replicated, so false. - default: - throw new ArgumentOutOfRangeException(); - } - } } // As this is the only variant, it becomes the default variant. @@ -606,6 +558,84 @@ public struct ChildOnlyComponent_4 : IComponentData, IComponentValue, IEnableabl public int GetValue() => value; } + // FIXME: GhostComponentAttribute coverage, test children equivalents of this. + // FIXME: GhostComponentAttribute coverage, test SendData = false too. + + // Test components with lots of GhostComponentAttribute modifications (note: PrefabType stripping is tested elsewhere): + [GhostComponent(SendDataForChildEntity = true, SendTypeOptimization = GhostSendType.OnlyPredictedClients, OwnerSendType = SendToOwnerType.SendToOwner)] + [GhostEnabledBit] + public struct SendForChildren_OnlyPredictedGhosts_SendToOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + [GhostComponent(SendDataForChildEntity = true, SendTypeOptimization = GhostSendType.OnlyInterpolatedClients, OwnerSendType = SendToOwnerType.SendToOwner)] + [GhostEnabledBit] + public struct SendForChildren_OnlyInterpolatedGhosts_SendToOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + [GhostComponent(SendDataForChildEntity = true, SendTypeOptimization = GhostSendType.OnlyPredictedClients, OwnerSendType = SendToOwnerType.SendToNonOwner)] + [GhostEnabledBit] + public struct SendForChildren_OnlyPredictedGhosts_SendToNonOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + [GhostComponent(SendDataForChildEntity = true, SendTypeOptimization = GhostSendType.OnlyInterpolatedClients, OwnerSendType = SendToOwnerType.SendToNonOwner)] + [GhostEnabledBit] + public struct SendForChildren_OnlyInterpolatedGhosts_SendToNonOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + // ---- + [GhostComponent(SendDataForChildEntity = false, SendTypeOptimization = GhostSendType.OnlyPredictedClients, OwnerSendType = SendToOwnerType.SendToOwner)] + [GhostEnabledBit] + public struct DontSendForChildren_OnlyPredictedGhosts_SendToOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + [GhostComponent(SendDataForChildEntity = false, SendTypeOptimization = GhostSendType.OnlyInterpolatedClients, OwnerSendType = SendToOwnerType.SendToOwner)] + [GhostEnabledBit] + public struct DontSendForChildren_OnlyInterpolatedGhosts_SendToOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + [GhostComponent(SendDataForChildEntity = false, SendTypeOptimization = GhostSendType.OnlyPredictedClients, OwnerSendType = SendToOwnerType.SendToNonOwner)] + [GhostEnabledBit] + public struct DontSendForChildren_OnlyPredictedGhosts_SendToNonOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + [GhostComponent(SendDataForChildEntity = false, SendTypeOptimization = GhostSendType.OnlyInterpolatedClients, OwnerSendType = SendToOwnerType.SendToNonOwner)] + [GhostEnabledBit] + public struct DontSendForChildren_OnlyInterpolatedGhosts_SendToNonOwner_EnableableComponent : IComponentData, IComponentValue, IEnableableComponent + { + [GhostField] + public int value; + public void SetValue(int value) => this.value = value; + public int GetValue() => value; + } + //////////////////////////////////////////////////////////////////////////// [GhostEnabledBit] diff --git a/Tests/Editor/GhostSerializationTests.cs b/Tests/Editor/GhostSerializationTests.cs index b54a49c..276cd29 100644 --- a/Tests/Editor/GhostSerializationTests.cs +++ b/Tests/Editor/GhostSerializationTests.cs @@ -207,7 +207,7 @@ public void GhostValuesAreSerialized() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -246,7 +246,7 @@ public void GhostValuesAreSerialized_WithPacketDumpsEnabled() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -284,7 +284,7 @@ public void EntityReferenceSetAtSpawnIsResolved() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -333,7 +333,7 @@ public void EntityReferenceUnavailableGhostIsResolved() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); ghostRelevancy.GhostRelevancyMode = GhostRelevancyMode.SetIsRelevant; // Go in-game @@ -429,7 +429,7 @@ public void ManyEntitiesCanBeDespawnedSameTick() { float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/GhostSerializationTestsForEnableableBits.cs b/Tests/Editor/GhostSerializationTestsForEnableableBits.cs index c3be3e7..7268d8e 100644 --- a/Tests/Editor/GhostSerializationTestsForEnableableBits.cs +++ b/Tests/Editor/GhostSerializationTestsForEnableableBits.cs @@ -232,10 +232,10 @@ void SetComponentEnabled(bool enabled) } } - private void VerifyGhostGroupValues(bool expectValueReplicated, bool expectEnabledReplicated) + private void VerifyGhostGroupValues() where T : unmanaged, IComponentData, IEnableableComponent, IComponentValue { - VerifyGhostGroupEnabledBits(expectValueReplicated, expectEnabledReplicated); + VerifyGhostGroupEnabledBits(); var rootType = ComponentType.ReadOnly(); var childType = ComponentType.ReadOnly(); @@ -253,7 +253,7 @@ private void VerifyGhostGroupValues(bool expectValueReplicated, bool expectEn var clientGroupRootValue = m_TestWorld.ClientWorlds[0].EntityManager.GetComponentData(clientGroupRootEntity).GetValue(); var clientGroupMemberValue = m_TestWorld.ClientWorlds[0].EntityManager.GetComponentData(clientMemberEntity).GetValue(); - if (expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) // Ghost groups are root entities, by definition. + if (IsExpectedToReplicateValue(true)) // Ghost groups are root entities, by definition. { Assert.AreEqual(m_ExpectedValueIfReplicated, clientGroupRootValue, $"[{typeof(T)}] Expect \"group root\" entity value IS replicated when `{m_SendForChildrenTestCase}`!"); Assert.AreEqual(m_ExpectedValueIfReplicated, clientGroupMemberValue, $"[{typeof(T)}] Expect \"group member\" entity value when `{m_SendForChildrenTestCase}`!"); @@ -266,10 +266,10 @@ private void VerifyGhostGroupValues(bool expectValueReplicated, bool expectEn } } - ValidateChangeMaskForComponent(expectEnabledReplicated | expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true), true); + ValidateChangeMaskForComponent(true); } - private void VerifyGhostGroupEnabledBits(bool expectValueReplicated, bool expectEnabledReplicated) + private void VerifyGhostGroupEnabledBits() where T : unmanaged, IComponentData, IEnableableComponent { var rootType = ComponentType.ReadOnly(); @@ -289,7 +289,7 @@ private void VerifyGhostGroupEnabledBits(bool expectValueReplicated, bool exp var rootEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientGroupRootEntity); var memberEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientGroupMemberEntity); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) // Ghost groups are root entities, by definition. + if (IsExpectedToReplicateEnabledBit(true)) // Ghost groups are root entities, by definition. { Assert.AreEqual(m_ExpectedEnabledIfReplicated, rootEnabled, $"[{typeof(T)}] Expect \"group root\" entity enabled IS replicated when `{m_SendForChildrenTestCase}`!"); Assert.AreEqual(m_ExpectedEnabledIfReplicated, memberEnabled, $"[{typeof(T)}] Expect \"group member\" entity enabled IS replicated when `{m_SendForChildrenTestCase}`!"); @@ -301,10 +301,10 @@ private void VerifyGhostGroupEnabledBits(bool expectValueReplicated, bool exp } } - ValidateChangeMaskForComponent(expectEnabledReplicated | expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true), true); + ValidateChangeMaskForComponent(true); } - private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expectEnabledReplicated) + private void VerifyLinkedBufferValues() where T : unmanaged, IBufferElementData, IEnableableComponent, IComponentValue { var type = ComponentType.ReadOnly(); @@ -328,7 +328,7 @@ private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expec var clientParentEntityComponentEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientEntityGroup[0].Value); var clientChildEntityComponentEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientEntityGroup[1].Value); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateEnabledBit(true)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, clientParentEntityComponentEnabled, $"[{typeof(T)}] Expect client parent entity component enabled bit IS replicated when `{m_SendForChildrenTestCase}`!"); } @@ -346,7 +346,7 @@ private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expec Assert.AreEqual(m_ExpectedServerBufferSize, serverChildBuffer.Length, $"[{typeof(T)}] Expect server child buffer length!"); // Root: - if (expectValuesReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateValue(true)) { Assert.AreEqual(m_ExpectedServerBufferSize, clientParentBuffer.Length, $"[{typeof(T)}] Expect client parent buffer length IS replicated when `{m_SendForChildrenTestCase}`!"); Assert.AreEqual(m_ExpectedEnabledIfReplicated, clientParentEntityComponentEnabled, $"[{typeof(T)}] Expect client parent buffer enable bit IS replicated when `{m_SendForChildrenTestCase}`!"); @@ -364,7 +364,7 @@ private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expec var expectedBufferValue = m_IsValidatingBakedValues ? kDefaultValueIfNotReplicated : ((j + 1) * 1000 + m_ExpectedValueIfReplicated); Assert.AreEqual(expectedBufferValue, serverValue.GetValue(), $"[{typeof(T)}] Expect server parent value is written [{i}]"); - if (expectValuesReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateValue(true)) { Assert.AreEqual(expectedBufferValue, clientValue.GetValue(), $"[{typeof(T)}] Expect client parent value [{i}] IS replicated when `{m_SendForChildrenTestCase}`!"); } @@ -375,7 +375,7 @@ private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expec } // Children: - if (IsExpectedToBeReplicated(m_SendForChildrenTestCase, false)) + if (IsExpectedToReplicateEnabledBit(false)) // FIXME - Determine if we need to do this for GhostEnableBit buffer with no GhostField value. Is that even supported? { Assert.AreEqual(m_ExpectedServerBufferSize, clientChildBuffer.Length, $"[{typeof(T)}] Expect client child buffer length IS replicated when `{m_SendForChildrenTestCase}`!"); Assert.AreEqual(m_ExpectedEnabledIfReplicated, clientChildEntityComponentEnabled, $"[{typeof(T)}] Expect client child buffer enable bit IS replicated when `{m_SendForChildrenTestCase}`!"); @@ -393,7 +393,7 @@ private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expec var expectedBufferValue = m_IsValidatingBakedValues ? kDefaultValueIfNotReplicated : ((j + 1) * 1000 + m_ExpectedValueIfReplicated); Assert.AreEqual(expectedBufferValue, serverValue.GetValue(), $"[{typeof(T)}] Expect client child value is written [{i}]!"); - if (IsExpectedToBeReplicated(m_SendForChildrenTestCase, false)) + if (IsExpectedToReplicateValue(false)) { Assert.AreEqual(expectedBufferValue, clientValue.GetValue(), $"[{typeof(T)}] Expect client child entity buffer value [{i}] IS replicated when `{m_SendForChildrenTestCase}`!"); } @@ -405,10 +405,10 @@ private void VerifyLinkedBufferValues(bool expectValuesReplicated, bool expec } } - private void VerifyLinkedComponentValues(bool expectValueReplicated, bool expectEnabledReplicated, bool? forceChildReplication = default) + private void VerifyLinkedComponentValues() where T : unmanaged, IComponentData, IEnableableComponent, IComponentValue { - VerifyLinkedComponentEnabled(expectValueReplicated, expectEnabledReplicated, forceChildReplication); + VerifyLinkedComponentEnabled(); var type = ComponentType.ReadOnly(); using var query = m_TestWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(type); @@ -426,7 +426,7 @@ private void VerifyLinkedComponentValues(bool expectValueReplicated, bool exp var clientRootValue = m_TestWorld.ClientWorlds[0].EntityManager.GetComponentData(clientEntityGroup[0].Value).GetValue(); var clientChildValue = m_TestWorld.ClientWorlds[0].EntityManager.GetComponentData(clientEntityGroup[1].Value).GetValue(); - if (expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateValue(true)) { Assert.AreEqual(m_ExpectedValueIfReplicated, clientRootValue, $"[{typeof(T)}] Expected that value on component on root entity [{i}] IS replicated correctly when using this `{m_SendForChildrenTestCase}`!"); } @@ -435,7 +435,7 @@ private void VerifyLinkedComponentValues(bool expectValueReplicated, bool exp Assert.AreEqual(kDefaultValueIfNotReplicated, clientRootValue, $"[{typeof(T)}] Expected that value on component on root entity [{i}] is NOT replicated by default (via this `{m_SendForChildrenTestCase}`)!"); } - if (forceChildReplication ?? expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false)) + if (IsExpectedToReplicateValue(false)) { Assert.AreEqual(m_ExpectedValueIfReplicated, clientChildValue, $"[{typeof(T)}] Expected that value on component on child entity [{i}] IS replicated when using this `{m_SendForChildrenTestCase}`!"); } @@ -445,13 +445,13 @@ private void VerifyLinkedComponentValues(bool expectValueReplicated, bool exp } } - ValidateChangeMaskForComponent(forceChildReplication ?? expectValueReplicated | expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false), false); + ValidateChangeMaskForComponent(false); } - void VerifyLinkedComponentValueOnChild(bool expectValueReplicated, bool expectEnabledReplicated) + void VerifyLinkedComponentValueOnChild() where T : unmanaged, IComponentData, IEnableableComponent, IComponentValue { - VerifyLinkedComponentEnabledOnChild(expectValueReplicated, expectEnabledReplicated); + VerifyLinkedComponentEnabledOnChild(); var type = ComponentType.ReadOnly(); using var query = m_TestWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(type); @@ -470,7 +470,7 @@ void VerifyLinkedComponentValueOnChild(bool expectValueReplicated, bool expec // This method is exclusively to test behaviour of children. var value = m_TestWorld.ClientWorlds[0].EntityManager.GetComponentData(clientEntityGroup[1].Value).GetValue(); - if (expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false)) + if (IsExpectedToReplicateValue(false)) { Assert.AreEqual(m_ExpectedValueIfReplicated, value, $"[{typeof(T)}] Expected that value on component on child entity [{i}] IS replicated when using this `{m_SendForChildrenTestCase}`!"); } @@ -481,7 +481,7 @@ void VerifyLinkedComponentValueOnChild(bool expectValueReplicated, bool expec } } - private void VerifyLinkedComponentEnabled(bool expectValueReplicated, bool expectEnabledReplicated, bool? forceChildReplication = default) + private void VerifyLinkedComponentEnabled() where T : unmanaged, IComponentData, IEnableableComponent { var type = ComponentType.ReadOnly(); @@ -499,7 +499,7 @@ private void VerifyLinkedComponentEnabled(bool expectValueReplicated, bool ex Assert.AreEqual(2, clientEntityGroup.Length, $"[{typeof(T)}] Entities in the LinkedEntityGroup!"); var rootEntityEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientEntityGroup[0].Value); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateEnabledBit(true)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, rootEntityEnabled, $"[{typeof(T)}] Expected that the enable-bit on component on root entity [{i}] is replicated when using `{m_SendForChildrenTestCase}`!"); } @@ -509,7 +509,7 @@ private void VerifyLinkedComponentEnabled(bool expectValueReplicated, bool ex } var childEntityEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientEntityGroup[1].Value); - if (forceChildReplication ?? expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false)) + if (IsExpectedToReplicateEnabledBit(false)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, childEntityEnabled, $"[{typeof(T)}] Expected that the enable-bit on component on child entity [{i}] is replicated when using `{m_SendForChildrenTestCase}`!"); } @@ -519,10 +519,10 @@ private void VerifyLinkedComponentEnabled(bool expectValueReplicated, bool ex } } - ValidateChangeMaskForComponent(forceChildReplication ?? expectValueReplicated|expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false), false); + ValidateChangeMaskForComponent(false); } - private void VerifyLinkedComponentEnabledOnChild(bool expectValueReplicated, bool expectEnabledReplicated) + private void VerifyLinkedComponentEnabledOnChild() where T : unmanaged, IComponentData, IEnableableComponent { var type = ComponentType.ReadOnly(); @@ -542,7 +542,7 @@ private void VerifyLinkedComponentEnabledOnChild(bool expectValueReplicated, // This method is exclusively to test behaviour of children. var childEntityEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientEntityGroup[1].Value); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false)) + if (IsExpectedToReplicateEnabledBit(false)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, childEntityEnabled, $"[{typeof(T)}] Expected that the enable-bit on component ONLY on child entity [{i}] is replicated when using `{m_SendForChildrenTestCase}`!"); } @@ -552,12 +552,12 @@ private void VerifyLinkedComponentEnabledOnChild(bool expectValueReplicated, } } - ValidateChangeMaskForComponent(expectValueReplicated | expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, false), false); + ValidateChangeMaskForComponent(false); } - private void VerifyComponentValues(bool expectValueReplicated, bool expectEnabledReplicated) where T: unmanaged, IComponentData, IEnableableComponent, IComponentValue + private void VerifyComponentValues() where T: unmanaged, IComponentData, IEnableableComponent, IComponentValue { - VerifyFlagComponentEnabledBit(expectValueReplicated, expectEnabledReplicated); + VerifyFlagComponentEnabledBit(); var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll().WithOptions(EntityQueryOptions.IgnoreComponentEnabledState); @@ -577,7 +577,7 @@ private void VerifyLinkedComponentEnabledOnChild(bool expectValueReplicated, Assert.AreEqual(m_ExpectedEnabledIfReplicated, isServerEnabled, $"[{typeof(T)}] Test expects server enable bit [{i}] to still be same!"); Assert.AreEqual(m_ExpectedValueIfReplicated, serverValue, $"[{typeof(T)}] Test expects server value [{i}] to still be same!"); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateEnabledBit(true)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, isClientEnabled, $"[{typeof(T)}] Test expects client enable bit [{i}] IS replicated when using `{m_SendForChildrenTestCase}`!"); } @@ -585,7 +585,7 @@ private void VerifyLinkedComponentEnabledOnChild(bool expectValueReplicated, { Assert.AreEqual(m_ExpectedEnabledIfNotReplicated, isClientEnabled, $"[{typeof(T)}] Test expects client enable bit [{i}] NOT replicated when using `{m_SendForChildrenTestCase}`!"); } - if (expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateValue(true)) { // Note that values are replicated even if the component is disabled! Assert.AreEqual(m_ExpectedValueIfReplicated, clientValue, $"[{typeof(T)}] Test expects client value [{i}] IS replicated when using `{m_SendForChildrenTestCase}`!"); @@ -597,21 +597,22 @@ private void VerifyLinkedComponentEnabledOnChild(bool expectValueReplicated, } } - private void ValidateChangeMaskForComponent(bool isExpectedToBeReplicated, bool isRoot) + private void ValidateChangeMaskForComponent(bool isRoot) where T : unmanaged, IComponentData { var componentType = ComponentType.ReadOnly(); - ValidateChangeMask(isExpectedToBeReplicated, componentType, isRoot); + ValidateChangeMask(componentType, isRoot); } - private void ValidateChangeMaskForBuffer(bool isExpectedToBeReplicated, bool isRoot) + + private void ValidateChangeMaskForBuffer(bool isRoot) where T : unmanaged, IBufferElementData { var componentType = ComponentType.ReadOnly(); - ValidateChangeMask(isExpectedToBeReplicated, componentType, isRoot); + ValidateChangeMask(componentType, isRoot); } /// Tests how Change Filtering works in the . - private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType componentType, bool isRoot) + private void ValidateChangeMask(ComponentType componentType, bool isRoot) { if (m_IsFirstRun) // On the first run, there will be inconsistencies. Not worth trying to handle. return; @@ -632,7 +633,9 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com var componentChangeVersionInChunk = chunk.GetChangeVersion(ref dynamicComponentTypeHandle); var didChangeSinceLastVerifyCall = ChangeVersionUtility.DidChange(componentChangeVersionInChunk, m_LastGlobalSystemVersion); - if (m_ExpectChangeFilterToChange && isExpectedToBeReplicated) + var isReplicatingAnything = IsExpectedToReplicateValue(isRoot) || IsExpectedToReplicateEnabledBit(isRoot); + + if (m_ExpectChangeFilterToChange && isReplicatingAnything) Assert.IsTrue(didChangeSinceLastVerifyCall, $"[{componentType}] [Chunk:{chunkIdx}] Expected this component's change version to be updated, but it was not! {componentChangeVersionInChunk} vs {m_LastGlobalSystemVersion}. Implies a bug in GhostUpdateSystem Change Filtering."); else if (m_ExpectChangeFilterToChange) Assert.IsFalse(didChangeSinceLastVerifyCall, $"[{componentType}] [Chunk:{chunkIdx}] We'd expected this component's change version to be updated, but it's not replicated, so it SHOULDN'T be changed! {componentChangeVersionInChunk} vs {m_LastGlobalSystemVersion}. Implies a bug in GhostUpdateSystem Change Filtering."); @@ -641,7 +644,7 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com } } - private void VerifyFlagComponentEnabledBit(bool expectValueReplicated, bool expectEnabledReplicated) where T : unmanaged, IComponentData, IEnableableComponent + private void VerifyFlagComponentEnabledBit() where T : unmanaged, IComponentData, IEnableableComponent { var type = ComponentType.ReadOnly(); using var query = m_TestWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(type); @@ -658,7 +661,7 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com var isClientEnabled = m_TestWorld.ClientWorlds[0].EntityManager.IsComponentEnabled(clientEntity); Assert.AreEqual(m_ExpectedEnabledIfReplicated, isServerEnabled, $"[{typeof(T)}] Expect flag component server enabled bit is correct."); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateEnabledBit(true)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, isClientEnabled, $"{typeof(T)} Expected client enabled bit IS replicated."); } @@ -668,12 +671,12 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com } } - ValidateChangeMaskForComponent(expectEnabledReplicated | expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true), true); + ValidateChangeMaskForComponent(true); } NativeArray chunkArray; - private void VerifyBufferValues(bool expectValueReplicated, bool expectEnabledReplicated) where T: unmanaged, IBufferElementData, IEnableableComponent, IComponentValue + private void VerifyBufferValues() where T: unmanaged, IBufferElementData, IEnableableComponent, IComponentValue { var builder = new EntityQueryBuilder(Allocator.Temp).WithAll().WithOptions(EntityQueryOptions.IgnoreComponentEnabledState); using var query = m_TestWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(builder); @@ -694,7 +697,7 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com Assert.AreEqual(m_ExpectedServerBufferSize, serverBuffer.Length, $"[{typeof(T)}] server buffer length"); Assert.AreEqual(m_ExpectedEnabledIfReplicated, isServerEnabled, $"[{typeof(T)}] server enable bit"); - if (expectEnabledReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateEnabledBit(true)) { Assert.AreEqual(m_ExpectedEnabledIfReplicated, isClientEnabled, $"[{typeof(T)}] Client enable bit IS replicated when `{m_SendForChildrenTestCase}`!"); } @@ -702,7 +705,7 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com { Assert.AreEqual(m_ExpectedEnabledIfNotReplicated, isClientEnabled, $"[{typeof(T)}] Client enable bit is NOT replicated when `{m_SendForChildrenTestCase}`!"); } - if (expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateBuffer(m_SendForChildrenTestCase, true) && IsExpectedToReplicateValue(true)) { Assert.AreEqual(m_ExpectedServerBufferSize, clientBuffer.Length, $"[{typeof(T)}] Expect client buffer length IS replicated when `{m_SendForChildrenTestCase}`!"); } @@ -719,7 +722,7 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com var expectedBufferValue = m_IsValidatingBakedValues ? kDefaultValueIfNotReplicated : ((j + 1) * 1000 + m_ExpectedValueIfReplicated); Assert.AreEqual(expectedBufferValue, serverValue.GetValue(), $"[{typeof(T)}] Expect server buffer value [{i}]"); - if (expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true)) + if (IsExpectedToReplicateValue(true)) { Assert.AreEqual(expectedBufferValue, clientValue.GetValue(), $"[{typeof(T)}] Expect client buffer value [{i}] IS replicated when `{m_SendForChildrenTestCase}`!"); } @@ -730,7 +733,7 @@ private void ValidateChangeMask(bool isExpectedToBeReplicated, ComponentType com } } - ValidateChangeMaskForBuffer(expectEnabledReplicated | expectValueReplicated && IsExpectedToBeReplicated(m_SendForChildrenTestCase, true), true); + ValidateChangeMaskForBuffer(true); } void SetGhostValues(int value, bool enabled = false) @@ -751,6 +754,16 @@ void SetGhostValues(int value, bool enabled = false) SetComponentValues(value, enabled); SetComponentValues(value, enabled); SetComponentEnabled(enabled); + + // FIXME: GhostComponentAttribute coverage, tests disabled due to GhostCHunkSerializer issue. + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); + // SetComponentValues(value, enabled); break; case GhostTypeConverter.GhostTypes.MultipleEnableableComponent: SetComponentValues(value, enabled); @@ -835,6 +848,17 @@ void SetGhostValues(int value, bool enabled = false) SetLinkedComponentValues(value, enabled); SetLinkedComponentValues(value, enabled); SetLinkedComponentEnabled(enabled); + + // FIXME: GhostComponentAttribute coverage, tests disabled due to GhostCHunkSerializer issue. + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + // SetLinkedComponentValues(value, enabled); + SetLinkedComponentEnabledOnlyOnChildren(enabled); SetLinkedComponentEnabledOnlyOnChildren(enabled); SetLinkedComponentValueOnlyOnChildren(value, enabled); @@ -853,6 +877,16 @@ void SetGhostValues(int value, bool enabled = false) SetGhostGroupValues(value, enabled); SetGhostGroupValues(value, enabled); SetGhostGroupEnabled(enabled); + + // FIXME: GhostComponentAttribute coverage, tests disabled due to GhostCHunkSerializer issue. + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); + // SetGhostGroupValues(value, enabled); break; default: Assert.True(true); @@ -860,131 +894,161 @@ void SetGhostValues(int value, bool enabled = false) } } - void VerifyGhostValues(int value, bool enabled, SendForChildrenTestCase sendForChildrenTestCase, EnabledBitBakedValue enabledBitBakedValue) + void VerifyGhostValues(int value, bool enabled) { Assert.IsTrue(m_ServerEntities.IsCreated); m_ExpectedValueIfReplicated = value; m_ExpectedEnabledIfReplicated = enabled; - m_ExpectedEnabledIfNotReplicated = GhostTypeConverter.BakedEnabledBitValue(enabledBitBakedValue); - m_SendForChildrenTestCase = sendForChildrenTestCase; + switch (m_Type) { case GhostTypeConverter.GhostTypes.EnableableComponent: - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyFlagComponentEnabledBit(false, true); - VerifyComponentValues(true, false); - VerifyComponentValues(false, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(false, false); - VerifyFlagComponentEnabledBit(false, false); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyFlagComponentEnabledBit(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyFlagComponentEnabledBit(); + + // FIXME: GhostComponentAttribute coverage, tests disabled due to GhostCHunkSerializer issue. + // VerifyComponentValues(); + // VerifyComponentValues(); + // VerifyComponentValues(); + // VerifyComponentValues(); + // VerifyComponentValues(); + // VerifyComponentValues(); + // VerifyComponentValues(); + // VerifyComponentValues(); break; case GhostTypeConverter.GhostTypes.MultipleEnableableComponent: - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); - VerifyComponentValues(true, true); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); + VerifyComponentValues(); break; case GhostTypeConverter.GhostTypes.EnableableBuffer: - VerifyBufferValues(true, true); + VerifyBufferValues(); break; case GhostTypeConverter.GhostTypes.MultipleEnableableBuffer: - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); - VerifyBufferValues(true, true); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); + VerifyBufferValues(); break; case GhostTypeConverter.GhostTypes.ChildComponent: - VerifyLinkedComponentValues(true, true); - VerifyLinkedComponentValues(true, true); - VerifyLinkedComponentEnabled(false, true); - VerifyLinkedComponentValues(true, false); - VerifyLinkedComponentValues(false, true); + VerifyLinkedComponentValues(); + VerifyLinkedComponentValues(); + VerifyLinkedComponentEnabled(); + VerifyLinkedComponentValues(); + VerifyLinkedComponentValues(); // We override variants for these two, so cannot test their "default variants" without massive complications. - VerifyLinkedComponentValues(true, true, ComponentWithReplicatedVariant.ExpectChildReplicated(m_SendForChildrenTestCase)); - VerifyLinkedComponentEnabled(false, ComponentWithNonReplicatedVariant.ExpectReplicate(m_SendForChildrenTestCase)); + VerifyLinkedComponentValues(); + VerifyLinkedComponentEnabled(); // Note: We don't test the component on the root here. - VerifyLinkedComponentValues(ComponentWithDontSendChildrenVariant.ExpectReplicate(m_SendForChildrenTestCase), ComponentWithDontSendChildrenVariant.ExpectReplicate(m_SendForChildrenTestCase), ComponentWithDontSendChildrenVariant.ExpectChildReplicate(m_SendForChildrenTestCase)); - VerifyLinkedComponentEnabled(false, false); - - VerifyLinkedComponentEnabledOnChild(false, true); - VerifyLinkedComponentEnabledOnChild(false, false); - VerifyLinkedComponentValueOnChild(true, true); - VerifyLinkedComponentValueOnChild(true, false); + VerifyLinkedComponentValues(); + VerifyLinkedComponentEnabled(); + + // FIXME: GhostComponentAttribute coverage, tests disabled due to GhostCHunkSerializer issue. + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + // VerifyLinkedComponentValues(); + + VerifyLinkedComponentEnabledOnChild(); + VerifyLinkedComponentEnabledOnChild(); + VerifyLinkedComponentValueOnChild(); + VerifyLinkedComponentValueOnChild(); break; case GhostTypeConverter.GhostTypes.ChildBufferComponent: - VerifyLinkedBufferValues(true, true); + VerifyLinkedBufferValues(); break; case GhostTypeConverter.GhostTypes.GhostGroup: // GhostGroup implies all of these are root entities! I.e. No children to worry about, so `_sendForChildrenTestCase` is ignored. - VerifyGhostGroupValues(true, true); - VerifyGhostGroupValues(true, true); - VerifyGhostGroupEnabledBits(false, true); - VerifyGhostGroupValues(true, false); - VerifyGhostGroupValues(false, true); - VerifyGhostGroupValues(true, true); - VerifyGhostGroupValues(ComponentWithDontSendChildrenVariant.ExpectReplicate(m_SendForChildrenTestCase), ComponentWithDontSendChildrenVariant.ExpectReplicate(m_SendForChildrenTestCase)); - VerifyGhostGroupValues(ComponentWithNonReplicatedVariant.ExpectReplicate(m_SendForChildrenTestCase), ComponentWithNonReplicatedVariant.ExpectReplicate(m_SendForChildrenTestCase)); - VerifyGhostGroupEnabledBits(false, false); - // TODO - Ghost groups cannot have children, right? + VerifyGhostGroupValues(); + VerifyGhostGroupValues(); + VerifyGhostGroupEnabledBits(); + VerifyGhostGroupValues(); + VerifyGhostGroupValues(); + VerifyGhostGroupValues(); + VerifyGhostGroupValues(); + VerifyGhostGroupValues(); + VerifyGhostGroupEnabledBits(); + + // FIXME: GhostComponentAttribute coverage, tests disabled due to GhostCHunkSerializer issue. + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + // VerifyGhostGroupValues(); + + // Ghost groups cannot have children. break; default: Assert.Fail(); @@ -1007,10 +1071,12 @@ void VerifyGhostValues(int value, bool enabled, SendForChildrenTestCase sendForC bool m_ExpectedEnabledIfReplicated; bool m_ExpectedEnabledIfNotReplicated; SendForChildrenTestCase m_SendForChildrenTestCase; + PredictionSetting m_PredictionSetting; bool m_IsValidatingBakedValues; bool m_ExpectChangeFilterToChange; uint m_LastGlobalSystemVersion; private bool m_IsFirstRun; + private (Type, Type)[] m_Variants; enum GhostFlags : int { @@ -1021,6 +1087,11 @@ enum GhostFlags : int void RunTest(int numClients, GhostTypeConverter.GhostTypes type, int entityCount, GhostFlags flags, SendForChildrenTestCase sendForChildrenTestCase, PredictionSetting predictionSetting, EnabledBitBakedValue enabledBitBakedValue) { + // Save test vars: + m_ExpectedEnabledIfNotReplicated = GhostTypeConverter.BakedEnabledBitValue(enabledBitBakedValue); + m_SendForChildrenTestCase = sendForChildrenTestCase; + m_PredictionSetting = predictionSetting; + // Create worlds: switch (sendForChildrenTestCase) { @@ -1140,10 +1211,7 @@ void RunTest(int numClients, GhostTypeConverter.GhostTypes type, int entityCount } } - // Connect and make sure the connection could be established - Assert.IsTrue(m_TestWorld.Connect(frameTime, 4)); - - // Go in-game + m_TestWorld.Connect(frameTime); m_TestWorld.GoInGame(); // Perform test: @@ -1151,14 +1219,14 @@ void RunTest(int numClients, GhostTypeConverter.GhostTypes type, int entityCount m_IsFirstRun = true; m_ExpectChangeFilterToChange = true; - ValidateBakedValues(enabledBitBakedValue, sendForChildrenTestCase, type); + ValidateBakedValues(enabledBitBakedValue, sendForChildrenTestCase, type, predictionSetting); void SingleTest(int value, bool enabled) { SetGhostValues(value, enabled); m_LastGlobalSystemVersion = m_TestWorld.ClientWorlds[0].EntityManager.GlobalSystemVersion; TickMultipleFrames(GetNumTicksToReplicateGhostTypes(type)); - VerifyGhostValues(value, enabled, sendForChildrenTestCase, enabledBitBakedValue); + VerifyGhostValues(value, enabled); } SingleTest(-999, false); @@ -1170,7 +1238,7 @@ void SingleTest(int value, bool enabled) m_ExpectChangeFilterToChange = false; m_LastGlobalSystemVersion = m_TestWorld.ClientWorlds[0].EntityManager.GlobalSystemVersion; TickMultipleFrames(15); - VerifyGhostValues(999, true, sendForChildrenTestCase, enabledBitBakedValue); + VerifyGhostValues(999, true); } /// @@ -1179,7 +1247,7 @@ void SingleTest(int value, bool enabled) /// 2. WAIT for them to be spawned on the client. /// 3. VERIFY that the baked value and enabled-bit matches the baked values on the prefab. /// - private void ValidateBakedValues(EnabledBitBakedValue enabledBitBakedValue, SendForChildrenTestCase sendForChildrenTestCase, GhostTypeConverter.GhostTypes type) + private void ValidateBakedValues(EnabledBitBakedValue enabledBitBakedValue, SendForChildrenTestCase sendForChildrenTestCase, GhostTypeConverter.GhostTypes type, PredictionSetting predictionSetting) { if (GhostTypeConverter.WaitForClientEntitiesToSpawn(enabledBitBakedValue)) { @@ -1187,7 +1255,7 @@ private void ValidateBakedValues(EnabledBitBakedValue enabledBitBakedValue, Send m_LastGlobalSystemVersion = m_TestWorld.ClientWorlds[0].EntityManager.GlobalSystemVersion; m_ExpectedServerBufferSize = kBakedBufferSize; // We haven't written to the server buffers yet. TickMultipleFrames(GetNumTicksToReplicateGhostTypes(type)); - VerifyGhostValues(kDefaultValueIfNotReplicated, GhostTypeConverter.BakedEnabledBitValue(enabledBitBakedValue), sendForChildrenTestCase, enabledBitBakedValue); + VerifyGhostValues(kDefaultValueIfNotReplicated, GhostTypeConverter.BakedEnabledBitValue(enabledBitBakedValue)); } } @@ -1195,6 +1263,7 @@ private void ValidateBakedValues(EnabledBitBakedValue enabledBitBakedValue, Send public void SetupTestsForEnableableBits() { m_TestWorld = new NetCodeTestWorld(); + m_Variants = GhostTypeConverter.FetchAllTestComponentTypesRequiringSendRuleOverride(); } [TearDown] @@ -1292,8 +1361,16 @@ static GhostAuthoringInspectionComponent.ComponentOverride[] BuildComponentOverr RunTest(1, type, count, GhostFlags.StaticOptimization, sendForChildrenTestCase, PredictionSetting.WithInterpolatedEntities, enabledBitBakedValue); } - internal static bool IsExpectedToBeReplicated(SendForChildrenTestCase sendForChildrenTestCase, bool isRoot) + /// + /// Checks attributes on component to determine if this buffer's enable bit SHOULD be replicated. + /// NOTE & FIXME: DOES NOT CHECK GhostComponentAttribute CONFIGURATION! + /// + internal static bool IsExpectedToReplicateBuffer(SendForChildrenTestCase sendForChildrenTestCase, bool isRoot) + where T : IBufferElementData { + // Note that we should really be fetching the GhostComponentAttribute on the VARIANT. + var ghostComponentAttribute = GetGhostComponentAttribute(typeof(T)); + switch (sendForChildrenTestCase) { case SendForChildrenTestCase.YesViaExplicitVariantRule: @@ -1302,18 +1379,135 @@ internal static bool IsExpectedToBeReplicated(SendForChildrenTestCase sendFor case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: return false; case SendForChildrenTestCase.Default: - return isRoot || HasSendForChildrenFlagOnAttribute(); + return isRoot || HasSendForChildrenFlagOnAttribute(ghostComponentAttribute); + case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: + return !isRoot; + default: + throw new ArgumentOutOfRangeException(nameof(sendForChildrenTestCase), sendForChildrenTestCase, nameof(IsExpectedToReplicateEnabledBit)); + } + } + + private bool IsExpectedToReplicateEnabledBit(bool isRoot) + { + if (!IsEnableableComponent(typeof(T))) + return false; + + var variantType = FindTestVariantForType(); + var ghostComponent = GetGhostComponentAttribute(variantType); + + if (!IsExpectedToReplicateGivenOwnerSendTypeAttribute(ghostComponent)) + return false; + if (!IsExpectedToReplicateGivenSendTypeOptimizationAttribute(ghostComponent)) + return false; + if (!HasGhostEnabledBitAttribute(variantType)) + return false; + + switch (m_SendForChildrenTestCase) + { + case SendForChildrenTestCase.YesViaExplicitVariantRule: + case SendForChildrenTestCase.YesViaInspectionComponentOverride: + return true; + case SendForChildrenTestCase.Default: + return isRoot || HasSendForChildrenFlagOnAttribute(ghostComponent); + case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: + return !isRoot; + case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: + return false; + default: + throw new ArgumentOutOfRangeException(nameof(m_SendForChildrenTestCase), m_SendForChildrenTestCase, nameof(IsExpectedToReplicateEnabledBit)); + } + } + + private static bool IsEnableableComponent(Type type) => typeof(IEnableableComponent).IsAssignableFrom(type); + private static bool HasGhostEnabledBitAttribute(Type type) => type.GetCustomAttribute() != null; + + private Type FindTestVariantForType() + { + var foundPair = m_Variants.FirstOrDefault(x => x.Item1 == typeof(T)); + if (foundPair.Item1 == null) + return typeof(T); + var variantType = foundPair.Item2 ?? foundPair.Item1; + return variantType; + } + + /// Checks attributes on component to determine if this components backing field should be replicated. + private bool IsExpectedToReplicateValue(bool isRoot) + { + var variantType = FindTestVariantForType(); + var ghostComponent = GetGhostComponentAttribute(variantType); + + if (!IsExpectedToReplicateGivenOwnerSendTypeAttribute(ghostComponent)) + return false; + if (!IsExpectedToReplicateGivenSendTypeOptimizationAttribute(ghostComponent)) + return false; + if (!HasGhostFieldMainValue(variantType)) + return false; + + switch (m_SendForChildrenTestCase) + { + case SendForChildrenTestCase.YesViaExplicitVariantRule: + case SendForChildrenTestCase.YesViaInspectionComponentOverride: + return true; + case SendForChildrenTestCase.Default: + return isRoot || HasSendForChildrenFlagOnAttribute(ghostComponent); case SendForChildrenTestCase.YesViaExplicitVariantOnlyAllowChildrenToReplicateRule: return !isRoot; + case SendForChildrenTestCase.NoViaExplicitDontSerializeVariantRule: + return false; + default: + throw new ArgumentOutOfRangeException(nameof(m_SendForChildrenTestCase), m_SendForChildrenTestCase, nameof(IsExpectedToReplicateValue)); + } + } + + private static GhostComponentAttribute GetGhostComponentAttribute(Type variantType) + { + return variantType.GetCustomAttribute(typeof(GhostComponentAttribute)) as GhostComponentAttribute ?? new GhostComponentAttribute(); + } + + private static bool HasSendForChildrenFlagOnAttribute(GhostComponentAttribute attribute) => attribute != null && attribute.SendDataForChildEntity; + + private bool IsExpectedToReplicateGivenOwnerSendTypeAttribute(GhostComponentAttribute attribute) + { + switch (attribute.OwnerSendType) + { + case SendToOwnerType.None: + return false; + case SendToOwnerType.SendToOwner: + // Note: This will return true for interpolated entities. + return m_PredictionSetting != PredictionSetting.WithPredictedEntities; + case SendToOwnerType.SendToNonOwner: + // Note: This will return true for interpolated entities. + return m_PredictionSetting == PredictionSetting.WithPredictedEntities; + // FIXME: Once we test ownership, this should be: + // return m_PredictionSetting != PredictionSetting.WithPredictedAndOwnedEntities; + case SendToOwnerType.All: + return true; + default: + throw new ArgumentOutOfRangeException(nameof(attribute.OwnerSendType), attribute.OwnerSendType, nameof(IsExpectedToReplicateGivenOwnerSendTypeAttribute)); + } + } + + private bool IsExpectedToReplicateGivenSendTypeOptimizationAttribute(GhostComponentAttribute attribute) + { + switch (attribute.SendTypeOptimization) + { + case GhostSendType.DontSend: + return false; + case GhostSendType.OnlyInterpolatedClients: + return m_PredictionSetting == PredictionSetting.WithInterpolatedEntities; + case GhostSendType.OnlyPredictedClients: + return m_PredictionSetting == PredictionSetting.WithPredictedEntities; + case GhostSendType.AllClients: + return true; default: - throw new ArgumentOutOfRangeException(nameof(sendForChildrenTestCase), sendForChildrenTestCase, nameof(IsExpectedToBeReplicated)); + throw new ArgumentOutOfRangeException(nameof(attribute.SendTypeOptimization), attribute.SendTypeOptimization, nameof(IsExpectedToReplicateGivenSendTypeOptimizationAttribute)); } } - private static bool HasSendForChildrenFlagOnAttribute() + static bool HasGhostFieldMainValue(Type type) { - var attribute = typeof(T).GetCustomAttribute(typeof(GhostComponentAttribute)) as GhostComponentAttribute; - return attribute != null && attribute.SendDataForChildEntity; + var ghostFieldAttribute = type.GetField("value", BindingFlags.Instance | BindingFlags.Public)?.GetCustomAttribute(); + return ghostFieldAttribute != null && ghostFieldAttribute.SendData; } /// Ensure the GhostUpdateSystem doesn't corrupt fields without the . diff --git a/Tests/Editor/GhostSerializeBufferTests.cs b/Tests/Editor/GhostSerializeBufferTests.cs index 3ba0264..74a0285 100644 --- a/Tests/Editor/GhostSerializeBufferTests.cs +++ b/Tests/Editor/GhostSerializeBufferTests.cs @@ -244,7 +244,7 @@ public void BuffersAreSerialized() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -279,7 +279,7 @@ public void BuffersCanChangeSize() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -327,7 +327,7 @@ public void MultipleBuffersCanChangeSize() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -458,7 +458,7 @@ public void BuffersSupportMultipleBuffers() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -537,7 +537,7 @@ public void BuffersSentWithFragmentedPipelineAreReceivedCorrectly() //sending partial contents float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); // Let the game run for a bit so the ghosts are spawned on the client @@ -572,7 +572,7 @@ public void BuffersSentInPartialChunkAreReceivedCorrectly() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -673,7 +673,7 @@ public void ChildEntitiesBuffersAreSerializedCorrectly([Values]SendForChildrenTe float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -691,7 +691,7 @@ public void ChildEntitiesBuffersAreSerializedCorrectly([Values]SendForChildrenTe Assert.AreEqual(2, serverEntityGroup.Length); //Verify that the client snapshot data contains the right things - var shouldChildReceiveData = GhostSerializationTestsForEnableableBits.IsExpectedToBeReplicated(sendForChildrenTestCase, false); + var shouldChildReceiveData = GhostSerializationTestsForEnableableBits.IsExpectedToReplicateBuffer(sendForChildrenTestCase, false); var dynamicBuffer = testWorld.ClientWorlds[0].EntityManager.GetBuffer(clientEntities[0]); if(shouldChildReceiveData) BufferTestHelper.ValidateMultiBufferSnapshotDataContents(dynamicBuffer, 3, 0, 10, 10); @@ -780,7 +780,7 @@ public void GhostGroupBuffersAreSerialized() BufferTestHelper.SetByteBufferValues(testWorld.ServerWorld, serverChild, 10, 10); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 32; ++i) testWorld.Tick(frameTime); @@ -848,7 +848,7 @@ public void BuffersAreNotSerialized(Type bufferType, bool presentOnServer, bool testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); var serverEntity = testWorld.SpawnOnServer(ghostGameObject); @@ -924,7 +924,7 @@ public void PredictedGhostsBackupAndRestoreBufferCorrectly() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); //Disable the prediction logic @@ -1045,7 +1045,7 @@ public void PredictedGhostsBackupAndRestoreBufferCorrectly() } } - [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))] [UpdateAfter(typeof(GhostSpawnClassificationSystem))] [DisableAutoCreation] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] @@ -1146,7 +1146,7 @@ public void PredictedSpawnedGhostSerializeBufferCorrectly() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); //run for a bit to sync server time and ghost collections diff --git a/Tests/Editor/GhostTypeTests.cs b/Tests/Editor/GhostTypeTests.cs index d614c50..8fbfc62 100644 --- a/Tests/Editor/GhostTypeTests.cs +++ b/Tests/Editor/GhostTypeTests.cs @@ -70,7 +70,7 @@ public void GhostsWithSameArchetypeAreDifferent() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/InputComponentDataTest.cs b/Tests/Editor/InputComponentDataTest.cs index ae0ed81..be7a770 100644 --- a/Tests/Editor/InputComponentDataTest.cs +++ b/Tests/Editor/InputComponentDataTest.cs @@ -235,7 +235,7 @@ public void InputComponentData_IsCorrectlySynchronized() ghostConfig.SupportAutoCommandTarget = true; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(m_DeltaTime, 64)); + testWorld.Connect(m_DeltaTime); testWorld.GoInGame(); var serverConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); @@ -295,7 +295,7 @@ public void InputComponentData_InputBufferIsRemotePredictedWhenAppropriate() ghostConfig.DefaultGhostMode = GhostMode.Predicted; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 2); - Assert.IsTrue(testWorld.Connect(m_DeltaTime, 64)); + testWorld.Connect(m_DeltaTime); testWorld.GoInGame(); using var serverConnectionQuery = testWorld.ServerWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); @@ -345,38 +345,10 @@ public void InputComponentData_InputBufferIsRemotePredictedWhenAppropriate() testWorld.Tick(m_DeltaTime); // Input buffer must be added to the prefab - // We don't see the input buffer type here as it's generated, so we detect the name in the component list instead - var archTypes = new NativeList(Allocator.Temp); - testWorld.ServerWorld.EntityManager.GetAllArchetypes(archTypes); - bool foundBuffer = false; - var expectedBufferName = new FixedString128Bytes("Unity.NetCode.EditorTests.Generated.InputRemoteTestComponentDataInputBufferData"); - foreach (var archType in archTypes) - { - if (archType.Prefab) - { - foreach (var component in archType.GetComponentTypes()) - { - if (component.GetDebugTypeName() == expectedBufferName) - foundBuffer = true; - } - } - } - Assert.IsTrue(foundBuffer); - - testWorld.ClientWorlds[0].EntityManager.GetAllArchetypes(archTypes); - foundBuffer = false; - foreach (var archType in archTypes) - { - if (archType.Prefab) - { - foreach (var component in archType.GetComponentTypes()) - { - if (component.GetDebugTypeName() == expectedBufferName) - foundBuffer = true; - } - } - } - Assert.IsTrue(foundBuffer); + Assert.IsTrue(testWorld.ServerWorld.EntityManager.HasBuffer>(serverEntPlayer1)); + Assert.IsTrue(testWorld.ServerWorld.EntityManager.HasBuffer>(serverEntPlayer2)); + Assert.IsTrue(testWorld.ClientWorlds[0].EntityManager.HasBuffer>(clientEnt1OwnPlayer)); + Assert.IsTrue(testWorld.ClientWorlds[1].EntityManager.HasBuffer>(clientEnt2OwnPlayer)); // Validate that client 2 actually has input buffer data (copied to component) from client 1 and reversed testWorld.ClientWorlds[0].EntityManager.CompleteAllTrackedJobs(); @@ -419,26 +391,23 @@ public void InputComponentData_WithDefaultGhostComponents() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); - var inputComponentDataBufferType = GetComponentType(testWorld.ServerWorld, nameof(InputComponentData)); - var inputRemoteComponentDataBufferType = GetComponentType(testWorld.ServerWorld, nameof(InputRemoteTestComponentData)); testWorld.SpawnOnServer(gameObject0); testWorld.SpawnOnServer(gameObject1); - CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly(), 1); CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly(), 1); - CheckComponent(testWorld.ServerWorld, inputComponentDataBufferType, 1); - CheckComponent(testWorld.ServerWorld, inputRemoteComponentDataBufferType, 1); + CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly>(), 1); + CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly>(), 1); for (int i = 0; i < 64; ++i) testWorld.Tick(frameTime); CheckComponent(testWorld.ClientWorlds[0], ComponentType.ReadOnly(), 1); CheckComponent(testWorld.ClientWorlds[0], ComponentType.ReadOnly(), 1); - CheckComponent(testWorld.ClientWorlds[0], inputComponentDataBufferType, 1); - CheckComponent(testWorld.ClientWorlds[0], inputRemoteComponentDataBufferType, 1); + CheckComponent(testWorld.ClientWorlds[0], ComponentType.ReadOnly>(), 1); + CheckComponent(testWorld.ClientWorlds[0], ComponentType.ReadOnly>(), 1); } } @@ -470,11 +439,9 @@ public void InputComponentData_WithAllPredictedGhost() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); - var inputComponentDataWithGhostFieldsBufferType = GetComponentType(testWorld.ServerWorld, nameof(InputComponentDataAllPredictedWithGhostFields)); - var inputComponentDataBufferType = GetComponentType(testWorld.ServerWorld, nameof(InputComponentDataAllPredicted)); var clientConnectionEnt = testWorld.TryGetSingletonEntity(testWorld.ClientWorlds[0]); var netId = testWorld.ClientWorlds[0].EntityManager.GetComponentData(clientConnectionEnt).Value; @@ -485,16 +452,12 @@ public void InputComponentData_WithAllPredictedGhost() CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly(), 1); CheckComponent(testWorld.ServerWorld, ComponentType.ReadOnly(), 1); - CheckComponent(testWorld.ServerWorld, inputComponentDataWithGhostFieldsBufferType, 1); - CheckComponent(testWorld.ServerWorld, inputComponentDataBufferType, 1); for (int i = 0; i < 64; ++i) testWorld.Tick(frameTime); CheckComponent(testWorld.ClientWorlds[0], ComponentType.ReadOnly(), 1); CheckComponent(testWorld.ClientWorlds[0], ComponentType.ReadOnly(), 1); - CheckComponent(testWorld.ClientWorlds[0], inputComponentDataWithGhostFieldsBufferType, 1); - CheckComponent(testWorld.ClientWorlds[0], inputComponentDataBufferType, 1); } } @@ -532,8 +495,8 @@ public void InputComponentData_BufferCopiesGhostComponentConfigFromInputComponen using var collectionQuery = testWorld.ServerWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); var collectionData = collectionQuery.GetSingleton(); GhostComponentSerializer.State inputBufferWithGhostFieldsSerializerState = default; - var inputBufferType = GetComponentType(testWorld.ServerWorld, nameof(InputComponentDataWithGhostComponent)); - var inputBufferWithFieldsType = GetComponentType(testWorld.ServerWorld, nameof(InputComponentDataWithGhostComponentAndGhostFields)); + var inputBufferType = ComponentType.ReadWrite>(); + var inputBufferWithFieldsType = ComponentType.ReadWrite>(); foreach (var state in collectionData.Serializers) { if (state.ComponentType.CompareTo(inputBufferWithFieldsType) == 0) @@ -587,7 +550,7 @@ public void InputComponentData_BufferCopiesGhostComponentConfigFromInputComponen CheckComponent(testWorld.ServerWorld, inputBufferWithFieldsType, 0); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 64; ++i) testWorld.Tick(frameTime); @@ -608,24 +571,5 @@ void CheckComponent(World w, ComponentType testType, int expectedCount) Assert.AreEqual(expectedCount, compCount); } } - - ComponentType GetComponentType(World w, string componentName) - { - var archTypes = new NativeList(Allocator.Temp); - w.EntityManager.GetAllArchetypes(archTypes); - var expectedBufferName = new FixedString128Bytes($"Unity.NetCode.EditorTests.Generated.{componentName}InputBufferData"); - foreach (var archType in archTypes) - { - if (archType.Prefab) - { - foreach (var component in archType.GetComponentTypes()) - { - if (component.GetDebugTypeName() == expectedBufferName) - return component; - } - } - } - return null; - } } } diff --git a/Tests/Editor/InterpolationTests.cs b/Tests/Editor/InterpolationTests.cs index ebb2712..ed6ac3d 100644 --- a/Tests/Editor/InterpolationTests.cs +++ b/Tests/Editor/InterpolationTests.cs @@ -63,7 +63,7 @@ public void WhenUsingIPC_ClientPredictOnlyOneTickAhead() Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(frameTime, 128)); + testWorld.Connect(frameTime, 128); testWorld.GoInGame(); // Spawn a new entity on the server. Server will start send snapshots now. var serverEnt = testWorld.SpawnOnServer(ghostGameObject); @@ -106,7 +106,7 @@ public void InterpolationAndPredictedTickNeverGoesBack() ghostConfig.DefaultGhostMode = GhostMode.Interpolated; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(frameTime, 128)); + testWorld.Connect(frameTime, 128); testWorld.GoInGame(); // Spawn a new entity on the server. Server will start send snapshots now. var serverEnt = testWorld.SpawnOnServer(ghostGameObject); @@ -145,7 +145,7 @@ public void InterpolationTickAdaptToPacketDelay() ghostConfig.DefaultGhostMode = GhostMode.Interpolated; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(frameTime, 128)); + testWorld.Connect(frameTime, 128); testWorld.GoInGame(); // Spawn a new entity on the server. Server will start send snapshots now. var serverEnt = testWorld.SpawnOnServer(ghostGameObject); @@ -197,7 +197,7 @@ public void InterpolationTickAdaptToPacketDrop() ghostConfig.DefaultGhostMode = GhostMode.Interpolated; Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(frameTime, 128)); + testWorld.Connect(frameTime, 128); testWorld.GoInGame(); // Spawn a new entity on the server. Server will start send snapshots now. var serverEnt = testWorld.SpawnOnServer(ghostGameObject); diff --git a/Tests/Editor/InvalidUsageTests.cs b/Tests/Editor/InvalidUsageTests.cs index 98ab82d..cc45b7d 100644 --- a/Tests/Editor/InvalidUsageTests.cs +++ b/Tests/Editor/InvalidUsageTests.cs @@ -64,7 +64,7 @@ public void CanRecoverFromDeletingGhostOnClient() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -113,7 +113,7 @@ public void UnintializedGhostOwnerThrowsException() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/LateJoinCompletionTests.cs b/Tests/Editor/LateJoinCompletionTests.cs index b7d92be..0e63cf9 100644 --- a/Tests/Editor/LateJoinCompletionTests.cs +++ b/Tests/Editor/LateJoinCompletionTests.cs @@ -34,7 +34,7 @@ public void ServerGhostCountIsVisibleOnClient() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -73,7 +73,7 @@ public void ServerGhostCountOnlyIncludesRelevantSet() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); for (int i = 0; i < 8; ++i) testWorld.SpawnOnServer(ghostGameObject); @@ -128,7 +128,7 @@ public void ServerGhostCountDoesNotIncludeIrrelevantSet() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); for (int i = 0; i < 8; ++i) testWorld.SpawnOnServer(ghostGameObject); diff --git a/Tests/Editor/MultiDriverTests.cs b/Tests/Editor/MultiDriverTests.cs index 4ea8775..fc90ed8 100644 --- a/Tests/Editor/MultiDriverTests.cs +++ b/Tests/Editor/MultiDriverTests.cs @@ -131,7 +131,7 @@ public void CommandsFromAllClientsAreReceived() testWorld.UseMultipleDrivers = 1; testWorld.CreateWorlds(true, 2); - Assert.IsTrue(testWorld.Connect(1f/60f, 8)); + testWorld.Connect(1f/60f); testWorld.GoInGame(); var clientConnectionEnt = new[] diff --git a/Tests/Editor/MultiEntityGhostTests.cs b/Tests/Editor/MultiEntityGhostTests.cs index 5353adc..aef2ff2 100644 --- a/Tests/Editor/MultiEntityGhostTests.cs +++ b/Tests/Editor/MultiEntityGhostTests.cs @@ -62,7 +62,7 @@ public void ChildEntityDataReplicationCanBeDisabledViaFlagOnGhostComponentAttrib float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -108,7 +108,7 @@ public void ChildEntityDataCanBeReplicatedViaFlagOnGhostComponentAttribute() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/PartialSendTests.cs b/Tests/Editor/PartialSendTests.cs index 956e9fd..fb0464e 100644 --- a/Tests/Editor/PartialSendTests.cs +++ b/Tests/Editor/PartialSendTests.cs @@ -85,7 +85,7 @@ private void TestHelper(bool predicted, bool ownerPrediction) float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Check the clients network id var serverCon = testWorld.TryGetSingletonEntity(testWorld.ServerWorld); diff --git a/Tests/Editor/PerPrefabOverridesTests.cs b/Tests/Editor/PerPrefabOverridesTests.cs index b3c2f55..067c339 100644 --- a/Tests/Editor/PerPrefabOverridesTests.cs +++ b/Tests/Editor/PerPrefabOverridesTests.cs @@ -311,7 +311,7 @@ public void OverrideComponentSendType_RootEntity() testWorld.Tick(1.0f/60.0f); //In order to get the collection setup I need to enter in game - testWorld.Connect(1.0f / 60f, 16); + testWorld.Connect(1.0f / 60f); testWorld.GoInGame(); for (int i = 0; i < collection.Length; ++i) @@ -369,7 +369,7 @@ public void OverrideComponentSendType_ChildEntity() testWorld.Tick(1.0f/60.0f); //In order to get the collection setup I need to enter in game - testWorld.Connect(1.0f / 60f, 16); + testWorld.Connect(1.0f / 60f); testWorld.GoInGame(); for (int i = 0; i < collection.Length; ++i) @@ -428,7 +428,7 @@ public void OverrideComponentSendType_NestedChildEntity() testWorld.Tick(1.0f/60.0f); //In order to get the collection setup I need to enter in game - testWorld.Connect(1.0f / 60f, 16); + testWorld.Connect(1.0f / 60f); testWorld.GoInGame(); for (int i = 0; i < collection.Length; ++i) @@ -548,7 +548,7 @@ public void SerializationVariant_AreAppliedToBothRootAndChildEntities() testWorld.Tick(1.0f/60.0f); //In order to get the collection setup I need to enter in game - testWorld.Connect(1.0f / 60f, 16); + testWorld.Connect(1.0f / 60f); testWorld.GoInGame(); testWorld.SpawnOnServer(ghostGameObject); diff --git a/Tests/Editor/PredictionSwitchTests.cs b/Tests/Editor/PredictionSwitchTests.cs index edaf088..7836a53 100644 --- a/Tests/Editor/PredictionSwitchTests.cs +++ b/Tests/Editor/PredictionSwitchTests.cs @@ -70,7 +70,7 @@ public void SwitchingPredictionAddsAndRemovesComponent() Assert.AreNotEqual(Entity.Null, serverEnt); // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -143,7 +143,7 @@ public void SwitchingPredictionSmoothChildEntities() Assert.AreNotEqual(Entity.Null, serverEnt); // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/PredictionTests.cs b/Tests/Editor/PredictionTests.cs index e268bb6..9edc7bc 100644 --- a/Tests/Editor/PredictionTests.cs +++ b/Tests/Editor/PredictionTests.cs @@ -168,7 +168,7 @@ public void PredictionTickEvolveCorrectly(uint serverTickData) Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); testWorld.CreateWorlds(true, 1); testWorld.SetServerTick(serverTick); - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); var serverEnt = testWorld.SpawnOnServer(0); Assert.AreNotEqual(Entity.Null, serverEnt); @@ -205,7 +205,7 @@ public void PartialPredictionTicksAreRolledBack() // nonReplicatedBuffer[i] = new BufferWithReplicatedEnableBits { value = (byte)(10 * (i + 1)) }; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -280,7 +280,7 @@ public void HistoryBufferIsRollbackCorrectly(int ghostCount) // nonReplicatedBuffer[el] = new BufferWithReplicatedEnableBits { value = (byte)(10 * (el + 1)) }; } // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -302,72 +302,57 @@ public void HistoryBufferIsRollbackCorrectly(int ghostCount) } } - [TestCase(120)] [TestCase(90)] [TestCase(82)] [TestCase(45)] - public void NetcodeClientPredictionRateManager_WillWarnWhenMismatchSimulationTickRate(int simulationTickRate) + public void NetcodeClientPredictionRateManager_WillWarnWhenMismatchSimulationTickRate(int fixedStepRate) { using (var testWorld = new NetCodeTestWorld()) { - SetupPredictionAndTickRate(simulationTickRate, testWorld); - - LogAssert.Expect(LogType.Warning, $"1 / {nameof(PredictedFixedStepSimulationSystemGroup)}.{nameof(ComponentSystemGroup.RateManager)}.{nameof(IRateManager.Timestep)}(ms): {60}(FPS) " + - $"must be an integer multiple of {nameof(ClientServerTickRate)}.{nameof(ClientServerTickRate.SimulationTickRate)}:{simulationTickRate}(FPS).\n" + - $"Timestep will default to 1 / SimulationTickRate: {1f / simulationTickRate} to fix this issue for now."); - var timestep = testWorld.ClientWorlds[0].GetOrCreateSystemManaged().RateManager.Timestep; - Assert.That(timestep, Is.EqualTo(1f / simulationTickRate)); - } - } + testWorld.Bootstrap(true); + testWorld.CreateWorlds(true, 1); + testWorld.ServerWorld.GetOrCreateSystemManaged().RateManager.Timestep = 1f/fixedStepRate; + testWorld.ClientWorlds[0].GetOrCreateSystemManaged().RateManager.Timestep = 1f/fixedStepRate; - [TestCase(30)] - [TestCase(20)] - public void NetcodeClientPredictionRateManager_WillNotWarnWhenMatchingSimulationTickRate(int simulationTickRate) - { - using (var testWorld = new NetCodeTestWorld()) - { - SetupPredictionAndTickRate(simulationTickRate, testWorld); - - LogAssert.Expect(LogType.Warning, - @"Ignoring invalid [Unity.Entities.UpdateAfterAttribute] attribute on Unity.NetCode.NetworkTimeSystem targeting Unity.Entities.UpdateWorldTimeSystem. -This attribute can only order systems that are members of the same ComponentSystemGroup instance. -Make sure that both systems are in the same system group with [UpdateInGroup(typeof(Unity.Entities.InitializationSystemGroup))], -or by manually adding both systems to the same group's update list."); - LogAssert.NoUnexpectedReceived(); - var timestep = testWorld.ClientWorlds[0].GetOrCreateSystemManaged().RateManager.Timestep; - Assert.That(timestep, Is.EqualTo(1f / 60f)); + // Connect and make sure the connection could be established + const float frameTime = 1f / 60f; + testWorld.Connect(frameTime); + //Expect 2, one for server, one for the client + LogAssert.Expect(LogType.Warning, $"The PredictedFixedStepSimulationSystemGroup.TimeStep is {1f/fixedStepRate}ms ({fixedStepRate}FPS) but should be equals to ClientServerTickRate.PredictedFixedStepSimulationTimeStep: {1f/60f}ms ({60f}FPS).\n" + + "The current timestep will be changed to match the ClientServerTickRate settings. You should never set the rate of this system directly with neither the PredictedFixedStepSimulationSystemGroup.TimeStep nor the RateManager.TimeStep method.\n " + + "Instead, you must always configure the desired rate by changing the ClientServerTickRate.PredictedFixedStepSimulationTickRatio property."); + + LogAssert.Expect(LogType.Warning, $"The PredictedFixedStepSimulationSystemGroup.TimeStep is {1f/fixedStepRate}ms ({fixedStepRate}FPS) but should be equals to ClientServerTickRate.PredictedFixedStepSimulationTimeStep: {1f/60f}ms ({60f}FPS).\n" + + "The current timestep will be changed to match the ClientServerTickRate settings. You should never set the rate of this system directly with neither the PredictedFixedStepSimulationSystemGroup.TimeStep nor the RateManager.TimeStep method.\n " + + "Instead, you must always configure the desired rate by changing the ClientServerTickRate.PredictedFixedStepSimulationTickRatio property."); + + //Check that the simulation tick rate are the same + var clientRate = testWorld.GetSingleton(testWorld.ClientWorlds[0]); + Assert.AreEqual(60, clientRate.SimulationTickRate); + Assert.AreEqual(1, clientRate.PredictedFixedStepSimulationTickRatio); + var serverTimeStep = testWorld.ServerWorld.GetOrCreateSystemManaged().Timestep; + var clientTimestep = testWorld.ClientWorlds[0].GetOrCreateSystemManaged().Timestep; + Assert.That(serverTimeStep, Is.EqualTo(1f / clientRate.SimulationTickRate)); + Assert.That(clientTimestep, Is.EqualTo(1f / clientRate.SimulationTickRate)); + + //Also check that if the value is overriden, it is still correctly set to the right value + for (int i = 0; i < 8; ++i) + { + testWorld.ServerWorld.GetOrCreateSystemManaged().RateManager.Timestep = 1f/fixedStepRate; + testWorld.ClientWorlds[0].GetOrCreateSystemManaged().RateManager.Timestep = 1f/fixedStepRate; + testWorld.Tick(1f / 60f); + serverTimeStep = testWorld.ServerWorld.GetOrCreateSystemManaged().Timestep; + clientTimestep = testWorld.ClientWorlds[0].GetOrCreateSystemManaged().Timestep; + LogAssert.Expect(LogType.Warning, $"The PredictedFixedStepSimulationSystemGroup.TimeStep is {1f/fixedStepRate}ms ({fixedStepRate}FPS) but should be equals to ClientServerTickRate.PredictedFixedStepSimulationTimeStep: {1f/60f}ms ({60f}FPS).\n" + + "The current timestep will be changed to match the ClientServerTickRate settings. You should never set the rate of this system directly with neither the PredictedFixedStepSimulationSystemGroup.TimeStep nor the RateManager.TimeStep method.\n " + + "Instead, you must always configure the desired rate by changing the ClientServerTickRate.PredictedFixedStepSimulationTickRatio property."); + LogAssert.Expect(LogType.Warning, $"The PredictedFixedStepSimulationSystemGroup.TimeStep is {1f/fixedStepRate}ms ({fixedStepRate}FPS) but should be equals to ClientServerTickRate.PredictedFixedStepSimulationTimeStep: {1f/60f}ms ({60f}FPS).\n" + + "The current timestep will be changed to match the ClientServerTickRate settings. You should never set the rate of this system directly with neither the PredictedFixedStepSimulationSystemGroup.TimeStep nor the RateManager.TimeStep method.\n " + + "Instead, you must always configure the desired rate by changing the ClientServerTickRate.PredictedFixedStepSimulationTickRatio property."); + Assert.That(clientTimestep, Is.EqualTo(1f / clientRate.SimulationTickRate)); + Assert.That(serverTimeStep, Is.EqualTo(1f / clientRate.SimulationTickRate)); + } } } - - static void SetupPredictionAndTickRate(int simulationTickRate, NetCodeTestWorld testWorld) - { - testWorld.Bootstrap(true); - - var ghostGameObject = new GameObject(); - var ghostConfig = ghostGameObject.AddComponent(); - ghostConfig.DefaultGhostMode = GhostMode.Predicted; - - Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject)); - - testWorld.CreateWorlds(true, 1); - - var serverEnt = testWorld.SpawnOnServer(ghostGameObject); - var ent = testWorld.ServerWorld.EntityManager.CreateEntity(); - testWorld.ServerWorld.EntityManager.AddComponentData(ent, new ClientServerTickRate - { - SimulationTickRate = simulationTickRate, - }); - Assert.AreNotEqual(Entity.Null, serverEnt); - - // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 8)); - - // Go in-game - testWorld.GoInGame(); - - // Let the game run for a bit so the ghosts are spawned on the client - for (int i = 0; i < 16; ++i) - testWorld.Tick(frameTime); - } } } diff --git a/Tests/Editor/Prespawn/Assets/Whitebox_Ground_1600x1600_A.prefab b/Tests/Editor/Prespawn/Assets/Whitebox_Ground_1600x1600_A.prefab index 369ebc8..0e21a8c 100644 --- a/Tests/Editor/Prespawn/Assets/Whitebox_Ground_1600x1600_A.prefab +++ b/Tests/Editor/Prespawn/Assets/Whitebox_Ground_1600x1600_A.prefab @@ -75,11 +75,11 @@ PrefabInstance: - targetCorrespondingSourceObject: {fileID: -927199367670048503, guid: b912c1b66b24d453183613aefe1b5e90, type: 3} insertIndex: -1 - addedObject: {fileID: 1845873263332167380} + addedObject: {fileID: 5123344914533893751} - targetCorrespondingSourceObject: {fileID: -927199367670048503, guid: b912c1b66b24d453183613aefe1b5e90, type: 3} insertIndex: -1 - addedObject: {fileID: 4333214011668154287} + addedObject: {fileID: 7597785791380488142} m_SourcePrefab: {fileID: 100100000, guid: b912c1b66b24d453183613aefe1b5e90, type: 3} --- !u!1 &63904447334045665 stripped GameObject: @@ -87,19 +87,7 @@ GameObject: type: 3} m_PrefabInstance: {fileID: 8341482175533059816} m_PrefabAsset: {fileID: 0} ---- !u!114 &1845873263332167380 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 63904447334045665} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3} - m_Name: - m_EditorClassIdentifier: ---- !u!114 &4333214011668154287 +--- !u!114 &5123344914533893751 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -121,3 +109,15 @@ MonoBehaviour: TrackInterpolationDelay: 0 GhostGroup: 0 UsePreSerialization: 0 +--- !u!114 &7597785791380488142 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 63904447334045665} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3} + m_Name: + m_EditorClassIdentifier: diff --git a/Tests/Editor/Prespawn/LateJoinOptTests.cs b/Tests/Editor/Prespawn/LateJoinOptTests.cs index 29aba6b..56372c7 100644 --- a/Tests/Editor/Prespawn/LateJoinOptTests.cs +++ b/Tests/Editor/Prespawn/LateJoinOptTests.cs @@ -192,7 +192,7 @@ void ValidateReceivedSnapshotData(World clientWorld) //Stream the sub scene in SubSceneHelper.LoadSubSceneInWorlds(testWorld); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); CheckPrespawnArePresent(numObjects, testWorld); CheckComponents(numObjects, testWorld); //To Disable the prespawn optimization, just remove the baselines @@ -362,7 +362,7 @@ public void Test_BaselineAreCreated() //Stream the sub scene in SubSceneHelper.LoadSubSceneInWorlds(testWorld); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); CheckPrespawnArePresent(numObjects*2, testWorld); CheckComponents(numObjects*2, testWorld); testWorld.GoInGame(); @@ -402,7 +402,7 @@ public void UsingStaticOptimzationServerDoesNotSendData() //Stream the sub scene in SubSceneHelper.LoadSubSceneInWorlds(testWorld); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); CheckPrespawnArePresent(numObjects, testWorld); testWorld.GoInGame(); diff --git a/Tests/Editor/Prespawn/PreSpawnTests.cs b/Tests/Editor/Prespawn/PreSpawnTests.cs index f817e80..3e6d0fa 100644 --- a/Tests/Editor/Prespawn/PreSpawnTests.cs +++ b/Tests/Editor/Prespawn/PreSpawnTests.cs @@ -70,6 +70,8 @@ protected override void OnUpdate() public class PreSpawnTests : TestWithSceneAsset { + private const float frameTime = 1.0f / 60f; + void CheckAllPrefabsInWorlds(NetCodeTestWorld testWorld) { CheckAllPrefabsInWorld(testWorld.ServerWorld); @@ -165,11 +167,11 @@ public void WithNoPrespawnsScenesAreNotInitialized() testWorld.Bootstrap(true); testWorld.CreateWorlds(true, 1); SubSceneHelper.LoadSubSceneInWorlds(testWorld); - testWorld.Connect(1.0f/60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); // Give Prespawn ghosts processing a chance to run a bunch of times for (int i = 0; i < 16; ++i) - testWorld.Tick(1.0f/60f); + testWorld.Tick(frameTime); var query = testWorld.ServerWorld.EntityManager.CreateEntityQuery(typeof(PrespawnsSceneInitialized)); Assert.AreEqual(0, query.CalculateEntityCount()); } @@ -190,11 +192,11 @@ public void VerifyPreSpawnIDsAreApplied() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<64;++i) { - testWorld.Tick(1.0f / 60f); + testWorld.Tick(frameTime); if (testWorld.ServerWorld.GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene && testWorld.ClientWorlds[0].GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene) break; @@ -222,7 +224,7 @@ public void DestroyedPreSpawnedObjectsCleanup() testWorld.Bootstrap(true); testWorld.CreateWorlds(true, 2); SubSceneHelper.LoadSubSceneInWorlds(testWorld); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); //Set in game the first client testWorld.SetInGame(0); // Delete one prespawned entity on the server @@ -304,7 +306,7 @@ public void GhostCleanup() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); // If servers spawns something before connection is in game it will be registered as a prespawned entity // Wait until prespawned ghosts have been initialized @@ -312,7 +314,7 @@ public void GhostCleanup() typeof(GhostInstance)); for (int i = 0; i < 16; ++i) { - testWorld.Tick(1.0f/60f); + testWorld.Tick(frameTime); var prespawns = query.CalculateEntityCount(); if (prespawns > 0) break; @@ -326,7 +328,7 @@ public void GhostCleanup() var clientQuery = testWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(typeof(GhostInstance), typeof(PreSpawnedGhostIndex)); for (int i = 0; i < 64 && currentCount != ghostCount; ++i) { - testWorld.Tick(1.0f/60f); + testWorld.Tick(frameTime); currentCount = clientQuery.CalculateEntityCount(); } Assert.That(ghostCount == currentCount, "Client did not spawn runtime entity (clientCount=" + currentCount + " serverCount=" + ghostCount + ")"); @@ -351,7 +353,7 @@ public void GhostCleanup() var clientGhosts = testWorld.ClientWorlds[0].EntityManager.CreateEntityQuery(typeof(GhostInstance)); for (int i = 0; i < 16; ++i) { - testWorld.Tick(1.0f/60f); + testWorld.Tick(frameTime); // clientGhostCount will be 6 for a bit as it creates an initial archetype ghost and later a delayed one when on the right tick serverGhostCount = serverGhosts.CalculateEntityCount(); clientGhostCount = clientGhosts.CalculateEntityCount(); @@ -384,7 +386,7 @@ public void MultipleSubscenes() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<64;++i) testWorld.Tick(1.0f/60.0f); @@ -422,11 +424,11 @@ public void ManyPrespawnedObjects() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i=0; i<64;++i) { - testWorld.Tick(1.0f / 60f); + testWorld.Tick(frameTime); if (testWorld.ServerWorld.GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene && testWorld.ClientWorlds[0].GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene) break; @@ -457,7 +459,7 @@ public void ManyPrespawnedObjects() } for (int i = 0; i < clientGhosts.Length; ++i) { - Assert.IsTrue(PrespawnHelper.IsPrespawGhostId(clientGhosts[i].ghostId), "Prespawned ghosts not initialized"); + Assert.IsTrue(PrespawnHelper.IsPrespawnGhostId(clientGhosts[i].ghostId), "Prespawned ghosts not initialized"); // Verify that the client ghost id exists on the server with the same position Assert.IsTrue(serverPosLookup.TryGetValue(clientGhosts[i].ghostId, out var serverPos)); Assert.LessOrEqual(math.distance(clientGhostPos[i].Position, serverPos), 0.001f); @@ -486,11 +488,11 @@ public void PrefabVariantAreHandledCorrectly() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<64;++i) { - testWorld.Tick(1.0f / 60f); + testWorld.Tick(frameTime); if (testWorld.ServerWorld.GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene && testWorld.ClientWorlds[0].GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene) break; @@ -520,11 +522,11 @@ public void PrefabModelsAreHandledCorrectly() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<64;++i) { - testWorld.Tick(1.0f / 60f); + testWorld.Tick(frameTime); if (testWorld.ServerWorld.GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene && testWorld.ClientWorlds[0].GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene) break; @@ -555,11 +557,11 @@ public void MulitpleSubScenesWithSameObjectsPositionAreHandledCorrectly() SubSceneHelper.LoadSubSceneInWorlds(testWorld); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<64;++i) { - testWorld.Tick(1.0f / 60f); + testWorld.Tick(frameTime); if (testWorld.ServerWorld.GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene && testWorld.ClientWorlds[0].GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene) break; @@ -600,7 +602,7 @@ public void MismatchedPrespawnClientServerScenesCantConnect() } entities.Dispose(); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); UnityEngine.TestTools.LogAssert.Expect(LogType.Error, new Regex(@"Subscene (\w+) baseline mismatch.")); @@ -632,13 +634,13 @@ public void ServerTickWrapAroundDoesnNotCauseIssue() testWorld.SetServerTick(new NetworkTick((UInt32.MaxValue>>1) - 16)); testWorld.ServerWorld.EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(EnableVerifyGhostIds)); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<32;++i) { if (testWorld.GetNetworkTime(testWorld.ServerWorld).ServerTick.TickIndexForValidTick >= (UInt32.MaxValue>>1) - 3) testWorld.SpawnOnServer(0); - testWorld.Tick(1.0f / 60f); + testWorld.Tick(frameTime); if (testWorld.ServerWorld.GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene && testWorld.ClientWorlds[0].GetExistingSystemManaged().Matches == VerifyGhostIds.GhostsPerScene) break; @@ -669,7 +671,7 @@ public void PrespawnsCanGetRelevantAgain() testWorld.CreateWorlds(true, 1, true); SubSceneHelper.LoadSubSceneInWorlds(testWorld, subScene); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(frameTime); testWorld.GoInGame(); for(int i=0;i<16;++i) @@ -707,5 +709,115 @@ public void PrespawnsCanGetRelevantAgain() Assert.AreEqual(rows*columns, query.CalculateEntityCount()); } } + + [Test] + public void PrespawnBasicSerialization() + { + int rows = 5; + int columns = 2; + var ghost = SubSceneHelper.CreateSimplePrefab(ScenePath, "ghost", typeof(GhostAuthoringComponent), typeof(NetCodePrespawnAuthoring)); + var parentScene = SubSceneHelper.CreateEmptyScene(ScenePath, "Scene1"); + var subScene = SubSceneHelper.CreateSubScene(parentScene, Path.GetDirectoryName(parentScene.path), $"SubScene1", rows, columns, ghost, + Vector3.zero); + SceneManager.SetActiveScene(parentScene); + + using (var testWorld = new NetCodeTestWorld()) + { + testWorld.Bootstrap(true); + testWorld.CreateWorlds(true, 2, true); + SubSceneHelper.LoadSubSceneInWorlds(testWorld, subScene); + + testWorld.Connect(frameTime); + testWorld.GoInGame(); + + // Prespawns on the client and server have the same values, even before replication. + foreach (var clientWorld in testWorld.ClientWorlds) + ValidateClientVsServer(testWorld.ServerWorld, clientWorld); + + // Ensure the values don't get corrupted by early replication. + for(int i=0;i<8;++i) + testWorld.Tick(1.0f/60.0f); + + foreach (var clientWorld in testWorld.ClientWorlds) + ValidateClientVsServer(testWorld.ServerWorld, clientWorld); + + // Modify these prespawn ghost values on the server: + { + using var builder = new EntityQueryBuilder(Allocator.Temp).WithAll().WithOptions(EntityQueryOptions.IgnoreComponentEnabledState); + using var serverQuery = testWorld.ServerWorld.EntityManager.CreateEntityQuery(builder); + var s2 = serverQuery.ToComponentDataArray(Allocator.Temp); + var sEntities = serverQuery.ToEntityArray(Allocator.Temp); + for (int i = 0; i < sEntities.Length; i++) + { + var sEntityManager = testWorld.ServerWorld.EntityManager; + sEntityManager.SetComponentEnabled(sEntities[i], true); + sEntityManager.SetComponentEnabled(sEntities[i], false); + sEntityManager.SetComponentEnabled(sEntities[i], true); + + s2[i] = new TestComponent2 + { + Test1 = 11, + Test2 = 12, + Test3 = 13, + Test4 = "TEST_14", + }; + + var sBuffer = sEntityManager.GetBuffer(sEntities[i]); + sBuffer.Length = 20; + for (int j = 0; j < sBuffer.Length; j++) + { + sBuffer[j] = new TestBuffer3 + { + Test1 = 21, + Test2 = 22, + Test3 = 23, + Test4 = 24 + }; + } + } + serverQuery.CopyFromComponentDataArray(s2); + } + + // Replicate new values, then test again to ensure they replicate properly: + for(int i=0;i<8;++i) + testWorld.Tick(1.0f/60.0f); + + foreach (var clientWorld in testWorld.ClientWorlds) + ValidateClientVsServer(testWorld.ServerWorld, clientWorld); + + static void ValidateClientVsServer(World serverWorld, World clientWorld) + { + using var builder = new EntityQueryBuilder(Allocator.Temp).WithAll().WithOptions(EntityQueryOptions.IgnoreComponentEnabledState); + using var serverQuery = serverWorld.EntityManager.CreateEntityQuery(builder); + using var clientQuery = clientWorld.EntityManager.CreateEntityQuery(builder); + + var s2 = serverQuery.ToComponentDataArray(Allocator.Temp); + var sEntities = serverQuery.ToEntityArray(Allocator.Temp); + + var c1 = clientQuery.ToComponentDataArray(Allocator.Temp); + var c2 = clientQuery.ToComponentDataArray(Allocator.Temp); + var cEntities = clientQuery.ToEntityArray(Allocator.Temp); + + Assert.AreEqual(sEntities.Length, cEntities.Length, "Different number of ghosts on the server vs client!"); + for (var i = 0; i < sEntities.Length; i++) + { + // TestComponent1 is a flag component. + Assert.AreEqual(s2[i], c2[i], "TestComponent2 is not the same on client vs server!"); + + var sBuffer = serverWorld.EntityManager.GetBuffer(sEntities[i]); + var cBuffer = clientWorld.EntityManager.GetBuffer(cEntities[i]); + Assert.AreEqual(sBuffer.Length, cBuffer.Length, "TestBuffer3.Length is not the same on client vs server!"); + for (int j = 0; j < sBuffer.Length; j++) + { + Assert.AreEqual(sBuffer[j], cBuffer[j], $"TestBuffer3[{j}] entry is not the same on client vs server!"); + } + + Assert.AreEqual(serverWorld.EntityManager.IsComponentEnabled(sEntities[i]), clientWorld.EntityManager.IsComponentEnabled(cEntities[i]), "TestComponent1 Enabled bit is not the same on client vs server!"); + Assert.AreEqual(serverWorld.EntityManager.IsComponentEnabled(sEntities[i]), clientWorld.EntityManager.IsComponentEnabled(cEntities[i]), "TestComponent2 Enabled bit is not the same on client vs server!"); + Assert.AreEqual(serverWorld.EntityManager.IsComponentEnabled(sEntities[i]), clientWorld.EntityManager.IsComponentEnabled(cEntities[i]), "TestBuffer3 Enabled bit is not the same on client vs server!"); + } + } + } + } } } diff --git a/Tests/Editor/RelevancyTests.cs b/Tests/Editor/RelevancyTests.cs index 0d68a4c..730cf2b 100644 --- a/Tests/Editor/RelevancyTests.cs +++ b/Tests/Editor/RelevancyTests.cs @@ -85,7 +85,7 @@ Entity spawnAndSetId(NetCodeTestWorld testWorld, GameObject ghostGameObject, int int connectAndGoInGame(NetCodeTestWorld testWorld, int maxFrames = 4) { // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, maxFrames)); + testWorld.Connect(frameTime, maxFrames); // Go in-game testWorld.GoInGame(); @@ -592,7 +592,7 @@ public void ManyEntitiesCanBecomeIrrelevantSameTick() { float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/RpcTestSystems.cs b/Tests/Editor/RpcTestSystems.cs index d32c38b..d90549c 100644 --- a/Tests/Editor/RpcTestSystems.cs +++ b/Tests/Editor/RpcTestSystems.cs @@ -1,4 +1,3 @@ -using AOT; using Unity.Burst; using Unity.Burst.Intrinsics; using Unity.Entities; @@ -25,7 +24,7 @@ public PortableFunctionPointer CompileExecute() } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { RpcExecutor.ExecuteCreateRequestComponent(ref parameters); @@ -62,7 +61,7 @@ public PortableFunctionPointer CompileExecute() } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { var serializedData = default(SerializedRpcCommand); @@ -99,7 +98,7 @@ public PortableFunctionPointer CompileExecute() } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { var serializedData = default(SerializedLargeRpcCommand); @@ -136,7 +135,7 @@ public PortableFunctionPointer CompileExecute() } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { var serializedData = default(ClientIdRpcCommand); @@ -201,7 +200,7 @@ public void Deserialize(ref DataStreamReader reader, in RpcDeserializerState sta } [BurstCompile(DisableDirectCall = true)] - [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] + [AOT.MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))] private static void InvokeExecute(ref RpcExecutor.Parameters parameters) { RpcExecutor.ExecuteCreateRequestComponent(ref parameters); diff --git a/Tests/Editor/RpcTests.cs b/Tests/Editor/RpcTests.cs index dbfdbf8..89ed7a9 100644 --- a/Tests/Editor/RpcTests.cs +++ b/Tests/Editor/RpcTests.cs @@ -27,7 +27,7 @@ public void Rpc_UsingBroadcastOnClient_Works() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); for (int i = 0; i < 33; ++i) testWorld.Tick(1f / 60f); @@ -53,7 +53,7 @@ public void Rpc_UsingConnectionEntityOnClient_Works() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); var remote = testWorld.TryGetSingletonEntity(testWorld.ClientWorlds[0]); testWorld.ClientWorlds[0].GetExistingSystemManaged().Remote = remote; @@ -86,7 +86,7 @@ public void Rpc_SerializedRpcFlow_Works() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); for (int i = 0; i < 33; ++i) testWorld.Tick(1f / 60f); @@ -113,7 +113,7 @@ public void Rpc_ServerBroadcast_Works() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); int SendCount = 5; ServerRpcBroadcastSendSystem.SendCount = SendCount; @@ -143,7 +143,7 @@ public void Rpc_SendingBeforeGettingNetworkId_LogWarning() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); LogAssert.Expect(LogType.Warning, new Regex("Cannot send RPC with no remote connection.")); for (int i = 0; i < 33; ++i) @@ -180,7 +180,7 @@ public void Rpc_MalformedPackets_ThrowsAndLogError() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); LogAssert.Expect(LogType.Error, new Regex("RpcSystem received invalid rpc")); for (int i = 0; i < 32; ++i) @@ -212,7 +212,7 @@ public void Rpc_CanSendMoreThanOnePacketPerFrame() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); for (int i = 0; i < 33; ++i) testWorld.Tick(1f / 60f); @@ -242,7 +242,7 @@ public void Rpc_CanPackMultipleRPCs() float frameTime = 1.0f / 60.0f; // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); for (int i = 0; i < 33; ++i) testWorld.Tick(1f / 60f); @@ -290,7 +290,7 @@ RpcWithEntity RecvRpc(World world) testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); @@ -364,6 +364,38 @@ RpcWithEntity RecvRpc(World world) } } + [Test] + public void WarnsIfApplicationRunInBackgroundIsFalse() + { + const float dt = 1f/60f; + var existingRunInBackground = Application.runInBackground; + try + { + using var testWorld = new NetCodeTestWorld(); + testWorld.Bootstrap(true); + testWorld.CreateWorlds(true, 1); + + Application.runInBackground = false; + testWorld.Connect(dt, 4); + // Warning is suppressed by default. + testWorld.Tick(dt); + // Un-suppress it. + Assert.IsTrue(testWorld.TrySetSuppressRunInBackgroundWarning(false), "Failed to suppress!"); + // Expect two logs, one per world: + var regex = new Regex(@"Netcode detected that you don't have Application\.runInBackground enabled"); + LogAssert.Expect(LogType.Error, regex); + LogAssert.Expect(LogType.Error, regex); + testWorld.Tick(dt); + // When the client is DC'd, it should not warn. + testWorld.DisposeServerWorld(); + testWorld.Tick(dt); + } + finally + { + Application.runInBackground = existingRunInBackground; + } + } + #if ENABLE_UNITY_COLLECTIONS_CHECKS [Test] public void Rpc_WarnsIfNotConsumedAfter4Frames() diff --git a/Tests/Editor/SendToOwnerTests.cs b/Tests/Editor/SendToOwnerTests.cs index d11fe78..0d7e34d 100644 --- a/Tests/Editor/SendToOwnerTests.cs +++ b/Tests/Editor/SendToOwnerTests.cs @@ -104,7 +104,7 @@ public void SendToOwner_Clients_ReceiveTheCorrectData(GhostModeMask modeMask, Gh break; } - Assert.IsTrue(testWorld.Connect(1.0f/60.0f, 64)); + testWorld.Connect(1.0f/60.0f); testWorld.GoInGame(); var net1 = testWorld.TryGetSingletonEntity(testWorld.ClientWorlds[0]); diff --git a/Tests/Editor/SnapshotDataBufferLookupTests.cs b/Tests/Editor/SnapshotDataBufferLookupTests.cs index 3a48042..1a4ddcc 100644 --- a/Tests/Editor/SnapshotDataBufferLookupTests.cs +++ b/Tests/Editor/SnapshotDataBufferLookupTests.cs @@ -8,7 +8,7 @@ namespace Unity.NetCode.Tests.Editor { [DisableAutoCreation] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))] [UpdateAfter(typeof(GhostSpawnClassificationSystem))] [CreateAfter(typeof(GhostCollectionSystem))] [CreateAfter(typeof(GhostReceiveSystem))] @@ -90,7 +90,7 @@ public void ComponentCanBeInspected() testWorld.CreateWorlds(true, 1); BuildPrefab(testWorld.ServerWorld, "TestPrefab"); BuildPrefab(testWorld.ClientWorlds[0], "TestPrefab"); - testWorld.Connect(1f / 60f, 10); + testWorld.Connect(1f / 60f); testWorld.GoInGame(); for(var i=0;i<32;++i) testWorld.Tick(1.0f/60f); @@ -114,7 +114,7 @@ public void ComponentCanBeExtractedFromPredictedSpawnBuffer() BuildPrefab(testWorld.ServerWorld, "TestPrefab"); var clientPrefab = BuildPrefab(testWorld.ClientWorlds[0], "TestPrefab"); Assert.IsTrue(testWorld.ClientWorlds[0].EntityManager.HasComponent(clientPrefab)); - testWorld.Connect(1f / 60f, 10); + testWorld.Connect(1f / 60f); testWorld.GoInGame(); for(var i=0;i<32;++i) testWorld.Tick(1.0f/60f); @@ -153,7 +153,7 @@ public void ComponentCanBeExtractedForDifferentGhostTypes() } - testWorld.Connect(1f / 60f, 10); + testWorld.Connect(1f / 60f); testWorld.GoInGame(); for(var i=0;i<32;++i) testWorld.Tick(1.0f/60f); diff --git a/Tests/Editor/SpawnTests.cs b/Tests/Editor/SpawnTests.cs index 6b88597..a6cd294 100644 --- a/Tests/Editor/SpawnTests.cs +++ b/Tests/Editor/SpawnTests.cs @@ -55,7 +55,7 @@ public partial class SpawnTests { [DisableAutoCreation] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))] [UpdateAfter(typeof(GhostSpawnClassificationSystem))] public partial class TestSpawnClassificationSystem : SystemBase { @@ -150,7 +150,7 @@ public void PredictSpawnGhost() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 16; ++i) @@ -273,7 +273,7 @@ public void CustomSpawnClassificationSystem() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 16; ++i) diff --git a/Tests/Editor/StaticOptimizationTests.cs b/Tests/Editor/StaticOptimizationTests.cs index 1725ce5..6b2ae3f 100644 --- a/Tests/Editor/StaticOptimizationTests.cs +++ b/Tests/Editor/StaticOptimizationTests.cs @@ -59,7 +59,7 @@ void SetupBasicTest(NetCodeTestWorld testWorld, int entitiesToSpawn = 1) } // Connect and make sure the connection could be established - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); // Go in-game testWorld.GoInGame(); diff --git a/Tests/Editor/SubSceneLoadingTests.cs b/Tests/Editor/SubSceneLoadingTests.cs index 4365d42..447816f 100644 --- a/Tests/Editor/SubSceneLoadingTests.cs +++ b/Tests/Editor/SubSceneLoadingTests.cs @@ -100,7 +100,7 @@ public void SubSceneListIsSentToClient() //Stream the sub scene in SubSceneHelper.LoadSubSceneInWorlds(testWorld); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); var query = testWorld.ServerWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); Assert.IsTrue(query.IsEmptyIgnoreFilter); @@ -147,10 +147,10 @@ public void SubSceneListIsSentToClient() if (PrespawnHelper.IsRuntimeSpawnedGhost(ghost.ghostId)) continue; var serverPrespawnId = testWorld.ServerWorld.EntityManager.GetComponentData(kv.Value); - Assert.AreEqual(PrespawnHelper.MakePrespawGhostId(serverPrespawnId.Value + 1), ghost.ghostId); + Assert.AreEqual(PrespawnHelper.MakePrespawnGhostId(serverPrespawnId.Value + 1), ghost.ghostId); var clientGhost = recvGhostMap.Value[ghost]; var clientPrespawnId = testWorld.ClientWorlds[0].EntityManager.GetComponentData(clientGhost); - Assert.AreEqual(PrespawnHelper.MakePrespawGhostId(clientPrespawnId.Value + 1), ghost.ghostId); + Assert.AreEqual(PrespawnHelper.MakePrespawnGhostId(clientPrespawnId.Value + 1), ghost.ghostId); Assert.AreEqual(serverPrespawnId.Value, clientPrespawnId.Value); } } @@ -206,7 +206,7 @@ public void ClientLoadSceneWhileInGame() SubSceneHelper.LoadSubScene(testWorld.ServerWorld, sub0, sub1); SubSceneHelper.LoadSubScene(testWorld.ClientWorlds[0], sub0); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 16; ++i) { @@ -296,7 +296,7 @@ public void ServerAndClientsLoadSceneInGame() //Just create the scene entities proxies but not load any content SubSceneHelper.LoadSceneSceneProxies(sub0.SceneGUID, testWorld, 1.0f/60.0f, 200); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); //Run some frames, nothing should be synched or sent here for (int i = 0; i < 16; ++i) @@ -404,7 +404,7 @@ public void ServerInitiatedSceneUnload() testWorld.CreateWorlds(true, 1); SubSceneHelper.LoadSubSceneInWorlds(testWorld); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); testWorld.GoInGame(); for (int i = 0; i < 16; ++i) testWorld.Tick(frameTime); @@ -479,7 +479,7 @@ public void ClientLoadUnloadScene() testWorld.CreateWorlds(true, 1); float frameTime = 1.0f / 60.0f; SubSceneHelper.LoadSubScene(testWorld.ServerWorld, subScenes); - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); //Here it is already required to have something that tell the client he need to load the prefabs testWorld.GoInGame(); for (int i = 0; i < 16; ++i) @@ -549,7 +549,7 @@ public void ClientReceiveDespawedGhostsWhenReloadingScene() SubSceneHelper.LoadSubScene(testWorld.ServerWorld, subScenes); SubSceneHelper.LoadSubScene(testWorld.ClientWorlds[0], subScenes[0]); - testWorld.Connect(1.0f / 60f, 4); + testWorld.Connect(1.0f / 60f); testWorld.GoInGame(); //synch scene 0 but not scene 1 @@ -563,22 +563,22 @@ public void ClientReceiveDespawedGhostsWhenReloadingScene() { new SpawnedGhost { - ghostId = PrespawnHelper.MakePrespawGhostId(1), + ghostId = PrespawnHelper.MakePrespawnGhostId(1), spawnTick = NetworkTick.Invalid }, new SpawnedGhost { - ghostId = PrespawnHelper.MakePrespawGhostId(4), + ghostId = PrespawnHelper.MakePrespawnGhostId(4), spawnTick = NetworkTick.Invalid }, new SpawnedGhost { - ghostId = PrespawnHelper.MakePrespawGhostId(8), + ghostId = PrespawnHelper.MakePrespawnGhostId(8), spawnTick = NetworkTick.Invalid }, new SpawnedGhost { - ghostId = PrespawnHelper.MakePrespawGhostId(9), + ghostId = PrespawnHelper.MakePrespawnGhostId(9), spawnTick = NetworkTick.Invalid }, }; diff --git a/Tests/Editor/SubSceneLoadingTests_CustomAckFlow.cs b/Tests/Editor/SubSceneLoadingTests_CustomAckFlow.cs index 373cadc..024af1b 100644 --- a/Tests/Editor/SubSceneLoadingTests_CustomAckFlow.cs +++ b/Tests/Editor/SubSceneLoadingTests_CustomAckFlow.cs @@ -132,7 +132,7 @@ public void CustomSceneAckFlowTest() float frameTime = 1.0f / 60.0f; //Server load all the scenes SubSceneHelper.LoadSubScene(testWorld.ServerWorld, subScenes); - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); //Disable the automatic reporting testWorld.ServerWorld.EntityManager.CreateEntity(typeof(DisableAutomaticPrespawnSectionReporting)); testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(DisableAutomaticPrespawnSectionReporting)); diff --git a/Tests/Editor/TestEnterExitGame.cs b/Tests/Editor/TestEnterExitGame.cs index d34dcb8..e1d307c 100644 --- a/Tests/Editor/TestEnterExitGame.cs +++ b/Tests/Editor/TestEnterExitGame.cs @@ -36,7 +36,7 @@ public void PrespawnSystemResetWhenExitGame() //Stream the sub scene in SubSceneHelper.LoadSubSceneInWorlds(testWorld); float frameTime = 1.0f / 60.0f; - Assert.IsTrue(testWorld.Connect(frameTime, 4)); + testWorld.Connect(frameTime); var firstTimeJoinStats = new uint[testWorld.ClientWorlds.Length * 3]; var rejoinStats = new uint[testWorld.ClientWorlds.Length * 3]; testWorld.GoInGame(); diff --git a/Tests/Editor/WorldMigrationTests.cs b/Tests/Editor/WorldMigrationTests.cs index fa9b902..2f43a6d 100644 --- a/Tests/Editor/WorldMigrationTests.cs +++ b/Tests/Editor/WorldMigrationTests.cs @@ -63,7 +63,7 @@ public void WorldMigration_ResetWorlds_Works() typeof(SerializedRpcCommandRequestSystem)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(frameTime, 7)); + testWorld.Connect(frameTime); PrepareSend(1); StepTicks(testWorld, 15, frameTime); @@ -100,7 +100,7 @@ public void WorldMigration_MigrateToOwnWorld_Works() typeof(SerializedRpcCommandRequestSystem)); testWorld.CreateWorlds(true, 1); - Assert.IsTrue(testWorld.Connect(frameTime, 7)); + testWorld.Connect(frameTime); PrepareSend(1); StepTicks(testWorld, 15, frameTime); @@ -145,7 +145,7 @@ public void WorldMigration_ResetWorldsWithMultipleClients_Works() typeof(SerializedRpcCommandRequestSystem)); testWorld.CreateWorlds(true, 10); - Assert.IsTrue(testWorld.Connect(frameTime, 10)); + testWorld.Connect(frameTime); Unity.Mathematics.Random random = new Unity.Mathematics.Random(42); var rndClient = random.NextInt(0, 10); diff --git a/Tests/Utils/Editor.meta b/Tests/Utils/Editor.meta new file mode 100644 index 0000000..a934c5a --- /dev/null +++ b/Tests/Utils/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 838eb8b1a9254ed595049fd54a173a0b +timeCreated: 1676637831 \ No newline at end of file diff --git a/Tests/Utils/Editor/PlaymodeUtils.cs b/Tests/Utils/Editor/PlaymodeUtils.cs new file mode 100644 index 0000000..c650b0f --- /dev/null +++ b/Tests/Utils/Editor/PlaymodeUtils.cs @@ -0,0 +1,22 @@ +#if UNITY_EDITOR +using Unity.NetCode.Hybrid; + +namespace Unity.NetCode.Tests +{ + /// + /// Helper functions that helps building playmode tests. + /// + public static class PlaymodeUtils + { + /// + /// Helper function to set the current build-target to client-only. + /// Can be executed before a build by passing "-executeMethod Unity.NetCode.Tests.PlaymodeUtils.SetClientBuild" when launching the editor through command line. + /// + public static void SetClientBuild() + { + NetCodeClientSettings.instance.ClientTarget = NetCodeClientTarget.Client; + NetCodeClientSettings.instance.Save(); + } + } +} +#endif diff --git a/Tests/Utils/Editor/PlaymodeUtils.cs.meta b/Tests/Utils/Editor/PlaymodeUtils.cs.meta new file mode 100644 index 0000000..1a62fd5 --- /dev/null +++ b/Tests/Utils/Editor/PlaymodeUtils.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5b2ee515555d47f7818370ca9e1b912f +timeCreated: 1676469103 \ No newline at end of file diff --git a/Tests/Utils/LoggingForward.cs b/Tests/Utils/LoggingForward.cs new file mode 100644 index 0000000..931cc7e --- /dev/null +++ b/Tests/Utils/LoggingForward.cs @@ -0,0 +1,30 @@ +using Unity.Logging; +using Unity.Logging.Sinks; + +namespace Unity.NetCode.Tests +{ + public static class LoggingForward + { + /// + /// Forwards loggings to the Unity DebugLog sink to ensure that errors in tests actually cause the tests to fail. + /// The test framework does not by default pick up logging package errors as errors. + /// + public static void ForwardUnityLoggingToDebugLog() + { + static void AddUnityDebugLogSink(Unity.Logging.Logger logger) + { + // This is a bit of a hack since we can't disable a logger sink. + logger.GetOrCreateSink(new UnityDebugLogSink.Configuration(logger.Config.WriteTo, LogFormatterText.Formatter, + minLevelOverride: logger.MinimalLogLevelAcrossAllSystems, outputTemplateOverride: "{Message}")); + logger.GetSink()?.SetMinimalLogLevel(LogLevel.Fatal); + logger.GetSink()?.SetMinimalLogLevel(LogLevel.Fatal); + } + + Unity.Logging.Internal.LoggerManager.OnNewLoggerCreated(AddUnityDebugLogSink); + Unity.Logging.Internal.LoggerManager.CallForEveryLogger(AddUnityDebugLogSink); + + // Self log enabled, so any error inside logging will cause Debug.LogError -> failed test + Unity.Logging.Internal.Debug.SelfLog.SetMode(Unity.Logging.Internal.Debug.SelfLog.Mode.EnabledInUnityEngineDebugLogError); + } + } +} diff --git a/Tests/Utils/LoggingForward.cs.meta b/Tests/Utils/LoggingForward.cs.meta new file mode 100644 index 0000000..2f673f2 --- /dev/null +++ b/Tests/Utils/LoggingForward.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 02e94e7c0c43749a7b503b2060f1931f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Utils/NetCodePrespawnAuthoring.cs b/Tests/Utils/NetCodePrespawnAuthoring.cs index 25acdb9..6b9aba7 100644 --- a/Tests/Utils/NetCodePrespawnAuthoring.cs +++ b/Tests/Utils/NetCodePrespawnAuthoring.cs @@ -1,10 +1,37 @@ +using Unity.Collections; using Unity.Entities; +using Unity.Mathematics; using UnityEngine; namespace Unity.NetCode.Tests { public class NetCodePrespawnAuthoring : MonoBehaviour + {} + + [GhostComponent(SendDataForChildEntity = true)] + [GhostEnabledBit] + public struct TestComponent1 : IComponentData, IEnableableComponent + {} + + [GhostComponent(SendDataForChildEntity = true)] + [GhostEnabledBit] + public struct TestComponent2 : IComponentData, IEnableableComponent + { + [GhostField] public float3 Test1; + [GhostField] public long Test2; + [GhostField] public ulong Test3; + [GhostField] public FixedString128Bytes Test4; + } + + [GhostComponent(SendDataForChildEntity = true)] + [InternalBufferCapacity(0)] + [GhostEnabledBit] + public struct TestBuffer3 : IBufferElementData, IEnableableComponent { + [GhostField] public float2 Test1; + [GhostField] public int Test2; + [GhostField] public byte Test3; + [GhostField] public sbyte Test4; } class NetCodePrespawnAuthoringBaker : Baker @@ -13,6 +40,29 @@ public override void Bake(NetCodePrespawnAuthoring authoring) { var entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity); + AddComponent(entity); + SetComponentEnabled(entity, false); + AddComponent(entity, new TestComponent2 + { + Test1 = 5, + Test2 = -6, + Test3 = 7, + Test4 = "::LongTextLongTextLongTextLongTextLongTextLongTextLongTextLongTextLongTextLongTextLongTextLongTextLongText::", + }); + SetComponentEnabled(entity, true); + + var buffer = AddBuffer(entity); + const int bufferLength = 5; + buffer.Length = bufferLength; + for (int i = 0; i < bufferLength; i++) + buffer[i] = new TestBuffer3 + { + Test1 = 1, + Test2 = 2, + Test3 = 3, + Test4 = 4 + }; + SetComponentEnabled(entity, false); } } } diff --git a/Tests/Utils/NetCodeScenarioUtils.cs b/Tests/Utils/NetCodeScenarioUtils.cs index 5165bbb..dd2bac2 100644 --- a/Tests/Utils/NetCodeScenarioUtils.cs +++ b/Tests/Utils/NetCodeScenarioUtils.cs @@ -6,7 +6,7 @@ namespace Unity.NetCode.Tests { -#if UNITY_EDITOR && NETCODE_ENABLE_PERF_TESTS +#if UNITY_EDITOR public class NetcodeScenarioUtils { public struct ScenarioDesc @@ -69,8 +69,7 @@ public static void ExecuteScenario(ScenarioDesc scenario, ScenarioParams paramet // create worlds, spawn and connect scenarioWorld.CreateWorlds(true, parameters.NumClients, parameters.UseThinClients); - var maxSteps = 4; - Assert.IsTrue(scenarioWorld.Connect(frameTime, maxSteps)); + scenarioWorld.Connect(frameTime); var ghostSendProxy = scenarioWorld.ServerWorld.GetOrCreateSystemManaged(); // ForcePreSerialize must be set before going in-game or it will not be applied diff --git a/Tests/Utils/NetCodeTestWorld.cs b/Tests/Utils/NetCodeTestWorld.cs index 2afb56e..ad8dcb5 100644 --- a/Tests/Utils/NetCodeTestWorld.cs +++ b/Tests/Utils/NetCodeTestWorld.cs @@ -15,6 +15,7 @@ using Unity.Logging.Sinks; using Unity.Profiling; using Unity.Transforms; +using UnityEngine.TestTools; using Debug = UnityEngine.Debug; #if UNITY_EDITOR using Unity.NetCode.Editor; @@ -32,7 +33,18 @@ public struct NetCodeTestPrefab : IBufferElementData public class NetCodeTestWorld : IDisposable, INetworkStreamDriverConstructor { + /// True if you want to forward all netcode logs from the server, to allow usage. + /// Defaults to true. and . + public bool EnableLogsOnServer = true; + /// True if you want to forward all netcode logs from the client, to allow usage. + /// Defaults to true. and . + public bool EnableLogsOnClients = true; + + /// Enable packet dumping in tests? Useful to ensure serialization doesn't fail. + /// Note: Packet dump files will not be cleaned up! public bool DebugPackets = false; + /// If you want to test extremely verbose logs, you can modify this flag. + public NetDebug.LogLevelType LogLevel = NetDebug.LogLevelType.Notify; static readonly ProfilerMarker k_TickServerInitializationSystem = new ProfilerMarker("TickServerInitializationSystem"); static readonly ProfilerMarker k_TickClientInitializationSystem = new ProfilerMarker("TickClientInitializationSystem"); @@ -67,19 +79,17 @@ public class NetCodeTestWorld : IDisposable, INetworkStreamDriverConstructor private BlobAssetStore m_BlobAssetStore; #endif - private static void ForwardUnityLoggingToDebugLog() + /// Configure how logging should occur in tests. We apply and here. + /// World to apply this config on. + private void SetupNetDebugConfig(World world) { - static void AddUnityDebugLogSink(Unity.Logging.Logger logger) + var shouldLog = (world.IsServer() && EnableLogsOnServer) || (world.IsClient() && EnableLogsOnClients); + world.EntityManager.CreateSingleton(new NetCodeDebugConfig { - logger.GetOrCreateSink(new UnityDebugLogSink.Configuration(logger.Config.WriteTo, LogFormatterText.Formatter, - minLevelOverride: logger.MinimalLogLevelAcrossAllSystems, outputTemplateOverride: "{Message}")); - } - - Unity.Logging.Internal.LoggerManager.OnNewLoggerCreated(AddUnityDebugLogSink); - Unity.Logging.Internal.LoggerManager.CallForEveryLogger(AddUnityDebugLogSink); - - // Self log enabled, so any error inside logging will cause Debug.LogError -> failed test - Unity.Logging.Internal.Debug.SelfLog.SetMode(Unity.Logging.Internal.Debug.SelfLog.Mode.EnabledInUnityEngineDebugLogError); + // Hack essentially disabling all logging for this world, as we should never have exceptions going via this logger anyway. + LogLevel = shouldLog ? LogLevel : NetDebug.LogLevelType.Exception, + DumpPackets = DebugPackets, + }); } public NetCodeTestWorld() @@ -93,8 +103,6 @@ public NetCodeTestWorld() ClientServerBootstrap.AutoConnectPort = 0; m_DefaultWorld = new World("NetCodeTest"); m_ElapsedTime = 42; - - ForwardUnityLoggingToDebugLog(); } public void Dispose() @@ -209,8 +217,10 @@ public void Bootstrap(bool includeNetCodeSystems, params Type[] userSystems) m_ControlSystems.Add(typeof(TickClientSimulationSystem)); m_ControlSystems.Add(typeof(TickClientPresentationSystem)); #endif +#if !UNITY_CLIENT m_ControlSystems.Add(typeof(TickServerInitializationSystem)); m_ControlSystems.Add(typeof(TickServerSimulationSystem)); +#endif m_ControlSystems.Add(typeof(DriverMigrationSystem)); s_AllClientSystems ??= DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.ClientSimulation); @@ -227,6 +237,10 @@ public void Bootstrap(bool includeNetCodeSystems, params Type[] userSystems) m_ThinClientSystems.AddRange(s_AllThinClientSystems.Where(filter)); m_ServerSystems.AddRange(s_AllServerSystems.Where(filter)); + m_ClientSystems.Add(typeof(Unity.Entities.UpdateWorldTimeSystem)); + m_ThinClientSystems.Add(typeof(Unity.Entities.UpdateWorldTimeSystem)); + m_ServerSystems.Add(typeof(Unity.Entities.UpdateWorldTimeSystem)); + m_ClientSystems.AddRange(TestSpecificAdditionalSystems); m_ThinClientSystems.AddRange(TestSpecificAdditionalSystems); m_ServerSystems.AddRange(TestSpecificAdditionalSystems); @@ -293,11 +307,8 @@ public void CreateWorlds(bool server, int numClients, bool tickWorldAfterCreatio #if UNITY_EDITOR BakeGhostCollection(m_ServerWorld); #endif - if (DebugPackets) - { - var dbg = m_ServerWorld.EntityManager.CreateEntity(ComponentType.ReadWrite()); - m_ServerWorld.EntityManager.SetComponentData(dbg, new NetCodeDebugConfig {LogLevel = NetDebug.LogLevelType.Debug, DumpPackets = true}); - } + + SetupNetDebugConfig(m_ServerWorld); } if (numClients > 0) @@ -314,11 +325,7 @@ public void CreateWorlds(bool server, int numClients, bool tickWorldAfterCreatio m_ClientWorlds[i] = CreateClientWorld($"ClientTest{i}-{testMethodName}", useThinClients); - if (DebugPackets) - { - var dbg = m_ClientWorlds[i].EntityManager.CreateEntity(ComponentType.ReadWrite()); - m_ClientWorlds[i].EntityManager.SetComponentData(dbg, new NetCodeDebugConfig {LogLevel = NetDebug.LogLevelType.Debug, DumpPackets = true}); - } + SetupNetDebugConfig(m_ClientWorlds[i]); } catch (Exception) { @@ -339,6 +346,32 @@ public void CreateWorlds(bool server, int numClients, bool tickWorldAfterCreatio //Run 1 tick so that all the ghost collection and the ghost collection component run once. if (tickWorldAfterCreation) Tick(1.0f / 60.0f); + + TrySetSuppressRunInBackgroundWarning(true); + } + + /// + /// Tests will fail on CI due to `runInBackground = false`, so we must suppress the warning: + /// Note that if netcode systems don't exist (i.e. no NetDebug), no suppression is necessary. + /// + /// + /// Called multiple times as some tests don't tick until they've established a collection. + public bool TrySetSuppressRunInBackgroundWarning(bool suppress) + { + var success = TryGetSingletonEntity(ServerWorld) != default; + if (success) + GetSingletonRW(ServerWorld).ValueRW.SuppressApplicationRunInBackgroundWarning = suppress; + + if (ClientWorlds != null) + { + foreach (var clientWorld in ClientWorlds) + { + success &= TryGetSingletonEntity(clientWorld) != default; + if(success) + GetSingletonRW(clientWorld).ValueRW.SuppressApplicationRunInBackgroundWarning = suppress; + } + } + return success; } private World CreateServerWorld(string name, World world = null) @@ -350,6 +383,7 @@ private World CreateServerWorld(string name, World world = null) var initializationGroup = world.GetExistingSystemManaged(); var simulationGroup = world.GetExistingSystemManaged(); var presentationGroup = world.GetExistingSystemManaged(); + world.GetExistingSystemManaged().Enabled = false; #if !UNITY_SERVER var initializationTickSystem = m_DefaultWorld.GetExistingSystemManaged(); var simulationTickSystem = m_DefaultWorld.GetExistingSystemManaged(); @@ -358,6 +392,7 @@ private World CreateServerWorld(string name, World world = null) simulationTickSystem.AddSystemGroupToTickList(simulationGroup); presentationTickSystem.AddSystemGroupToTickList(presentationGroup); #endif + ClientServerBootstrap.ServerWorlds.Add(world); return world; } @@ -372,7 +407,7 @@ private World CreateClientWorld(string name, bool thinClient, World world = null var initializationGroup = world.GetExistingSystemManaged(); var simulationGroup = world.GetExistingSystemManaged(); var presentationGroup = world.GetExistingSystemManaged(); - + world.GetExistingSystemManaged().Enabled = false; #if !UNITY_SERVER var initializationTickSystem = m_DefaultWorld.GetExistingSystemManaged(); var simulationTickSystem = m_DefaultWorld.GetExistingSystemManaged(); @@ -381,6 +416,7 @@ private World CreateClientWorld(string name, bool thinClient, World world = null simulationTickSystem.AddSystemGroupToTickList(simulationGroup); presentationTickSystem.AddSystemGroupToTickList(presentationGroup); #endif + ClientServerBootstrap.ClientWorlds.Add(world); return world; } @@ -399,18 +435,19 @@ public void Tick(float dt) } // Make sure the log flush does not run + // FIXME: Fix this so that the test world updates the below systems in the same order as the package simulation does. (Server first, then client). +#if !UNITY_CLIENT k_TickServerInitializationSystem.Begin(); m_DefaultWorld.GetExistingSystemManaged().Update(); k_TickServerInitializationSystem.End(); -#if !UNITY_SERVER - k_TickClientInitializationSystem.Begin(); - m_DefaultWorld.GetExistingSystemManaged().Update(); - k_TickClientInitializationSystem.End(); -#endif k_TickServerSimulationSystem.Begin(); m_DefaultWorld.GetExistingSystemManaged().Update(); k_TickServerSimulationSystem.End(); +#endif #if !UNITY_SERVER + k_TickClientInitializationSystem.Begin(); + m_DefaultWorld.GetExistingSystemManaged().Update(); + k_TickClientInitializationSystem.End(); k_TickClientSimulationSystem.Begin(); m_DefaultWorld.GetExistingSystemManaged().Update(); k_TickClientSimulationSystem.End(); @@ -441,6 +478,8 @@ public void MigrateServerWorld(World suppliedWorld = null) m_ServerWorld = CreateServerWorld(newWorld.Name, newWorld); Assert.True(newWorld.Name == m_ServerWorld.Name); + + TrySetSuppressRunInBackgroundWarning(true); } public void MigrateClientWorld(int index, World suppliedWorld = null) @@ -463,6 +502,8 @@ public void MigrateClientWorld(int index, World suppliedWorld = null) m_ClientWorlds[index] = CreateClientWorld(newWorld.Name, false, newWorld); Assert.True(newWorld.Name == m_ClientWorlds[index].Name); + + TrySetSuppressRunInBackgroundWarning(true); } public void RestartClientWorld(int index) @@ -590,25 +631,32 @@ public void CreateServerDriver(World world, ref NetworkDriverStore driverStore, } } - public bool Connect(float dt, int maxSteps) + /// + /// Will throw if connect fails. + /// + public void Connect(float dt, int maxSteps = 4) { var ep = NetworkEndpoint.LoopbackIpv4; ep.Port = 7979; GetSingletonRW(ServerWorld).ValueRW.Listen(ep); + var connectionEntities = new Entity[ClientWorlds.Length]; for (int i = 0; i < ClientWorlds.Length; ++i) - GetSingletonRW(ClientWorlds[i]).ValueRW.Connect(ClientWorlds[i].EntityManager, ep); + connectionEntities[i] = GetSingletonRW(ClientWorlds[i]).ValueRW.Connect(ClientWorlds[i].EntityManager, ep); for (int i = 0; i < ClientWorlds.Length; ++i) { while (TryGetSingletonEntity(ClientWorlds[i]) == Entity.Null) { if (maxSteps <= 0) - return false; + { + var streamDriver = GetSingleton(ClientWorlds[i]); + var nsc = ClientWorlds[i].EntityManager.GetComponentData(connectionEntities[i]); + Assert.Fail($"ClientWorld[{i}] failed to connect to the server after {maxSteps} ticks! Driver status: {streamDriver.GetConnectionState(nsc)}!"); + return; + } --maxSteps; Tick(dt); } } - - return true; } public void GoInGame(World w = null) @@ -616,11 +664,13 @@ public void GoInGame(World w = null) if (w == null) { if (ServerWorld != null) + { GoInGame(ServerWorld); - if (ClientWorlds != null) + } + if (ClientWorlds == null) return; + foreach (var clientWorld in ClientWorlds) { - for (int i = 0; i < ClientWorlds.Length; ++i) - GoInGame(ClientWorlds[i]); + GoInGame(clientWorld); } return; diff --git a/Tests/Utils/Proxies/GhostReceiveSystemProxy.cs b/Tests/Utils/Proxies/GhostReceiveSystemProxy.cs index 37806f7..05b3341 100644 --- a/Tests/Utils/Proxies/GhostReceiveSystemProxy.cs +++ b/Tests/Utils/Proxies/GhostReceiveSystemProxy.cs @@ -1,4 +1,5 @@ using Unity.Entities; +using Unity.PerformanceTesting; using Unity.Profiling; namespace Unity.NetCode.Tests diff --git a/Tests/Utils/Proxies/GhostSendSystemProxy.cs b/Tests/Utils/Proxies/GhostSendSystemProxy.cs index d93cacc..dad97d6 100644 --- a/Tests/Utils/Proxies/GhostSendSystemProxy.cs +++ b/Tests/Utils/Proxies/GhostSendSystemProxy.cs @@ -1,4 +1,3 @@ -#if NETCODE_ENABLE_PERF_TESTS using System.Collections.Generic; using Unity.Entities; using Unity.PerformanceTesting; @@ -190,4 +189,3 @@ protected override void OnUpdate() } #endif } -#endif diff --git a/Tests/Utils/Proxies/GhostUpdateSystemProxy.cs b/Tests/Utils/Proxies/GhostUpdateSystemProxy.cs index cfdfa49..f605425 100644 --- a/Tests/Utils/Proxies/GhostUpdateSystemProxy.cs +++ b/Tests/Utils/Proxies/GhostUpdateSystemProxy.cs @@ -7,8 +7,9 @@ namespace Unity.NetCode.Tests [DisableAutoCreation] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateAfter(typeof(GhostReceiveSystem))] + [UpdateBefore(typeof(GhostSpawnClassificationSystemGroup))] [UpdateBefore(typeof(GhostInputSystemGroup))] - [UpdateBefore(typeof(GhostUpdateSystem))] public partial class GhostUpdateSystemProxy : ComponentSystemGroup { static readonly ProfilerMarker k_Update = new ProfilerMarker("GhostUpdateSystem_OnUpdate"); diff --git a/Tests/Utils/SubSceneHelper.cs b/Tests/Utils/SubSceneHelper.cs index 666d0a4..a28a2ca 100644 --- a/Tests/Utils/SubSceneHelper.cs +++ b/Tests/Utils/SubSceneHelper.cs @@ -38,7 +38,7 @@ static private Scene CreateSubScene(string subScenePath) return EditorSceneManager.OpenScene(subScenePath, OpenSceneMode.Additive); } - static public void WaitUntilSceneEntityPresent(Hash128 subSceneGUID, World world, int timeoutMs = 10000) + public static void WaitUntilSceneEntityPresent(Hash128 subSceneGUID, World world, int timeoutMs = 10000) { var ent = SceneSystem.LoadSceneAsync(world.Unmanaged, subSceneGUID, @@ -180,15 +180,17 @@ static public GameObject CreatePrefab(string path, GameObject go) //Load into the terget world a list of subscene. //if the subScenes list is empty, a list of all the SubScene gameobjects in the active scene is retrieved //and loaded in the target world instead. - static public void LoadSubScene(World world, params SubScene[] subScenes) + public static void LoadSubScene(World world, params SubScene[] subScenes) { - if(subScenes.Length == 0) + if (subScenes.Length == 0) + { subScenes = Object.FindObjectsByType(FindObjectsSortMode.None); + } var sceneEntities = new Entity[subScenes.Length]; - for(int i=0;i SceneSystem.IsSceneLoaded(world.Unmanaged, s))); + loaded = sceneEntities.All(s => SceneSystem.IsSceneLoaded(world.Unmanaged, s)); + } + + if (!loaded) + { + throw new Exception($"Failed to load SubScenes in world. {world.Name}"); } - if(!loaded) - throw new System.Exception($"Failed to load subscenes in world. {world.Name}"); } static public Entity LoadSubSceneAsync(World world, in NetCodeTestWorld testWorld, Hash128 subSceneGUID, float frameTime, int maxTicks=256) @@ -224,14 +229,16 @@ static public Entity LoadSubSceneAsync(World world, in NetCodeTestWorld testWorl throw new System.Exception($"Failed to load subscene in world. {world.Name}"); } - //Load the specified subscene in the both clients and server world. - //if the subScenes list is empty, a list of all the SubScene gameobjects in the active scene is retrieved - //and loaded in the worlds. - static public void LoadSubSceneInWorlds(in NetCodeTestWorld testWorld, params SubScene[] subScenes) + // Load the specified SubScene in the both clients and server world. + // if the subScenes list is empty, a list of all the SubScene GameObjects in the active scene is retrieved + // and loaded in the worlds. + public static void LoadSubSceneInWorlds(in NetCodeTestWorld testWorld, params SubScene[] subScenes) { - SubSceneHelper.LoadSubScene(testWorld.ServerWorld, subScenes); - for (int i = 0; i < testWorld.ClientWorlds.Length; ++i) - SubSceneHelper.LoadSubScene(testWorld.ClientWorlds[i], subScenes); + LoadSubScene(testWorld.ServerWorld, subScenes); + foreach (var clientWorld in testWorld.ClientWorlds) + { + LoadSubScene(clientWorld, subScenes); + } } //Load the scene entity and resolve the sections but not load the content. diff --git a/Tests/Utils/Unity.NetCode.TestsUtils.asmdef b/Tests/Utils/Unity.NetCode.TestsUtils.asmdef index f445968..3196260 100644 --- a/Tests/Utils/Unity.NetCode.TestsUtils.asmdef +++ b/Tests/Utils/Unity.NetCode.TestsUtils.asmdef @@ -24,12 +24,6 @@ "allowUnsafeCode": true, "overrideReferences": true, "autoReferenced": false, - "versionDefines": [ - { - "name": "com.unity.test-framework.performance", - "expression": "0.0", - "define": "NETCODE_ENABLE_PERF_TESTS" - } - ], + "versionDefines": [], "noEngineReferences": false } diff --git a/ValidationExceptions.json b/ValidationExceptions.json index 358d5ac..735224e 100644 --- a/ValidationExceptions.json +++ b/ValidationExceptions.json @@ -1,19 +1,9 @@ { "ErrorExceptions": [ - { - "ValidationTest": "Package Unity Version Validation", - "ExceptionMessage": "The Unity version requirement is more strict than in the previous version of the package. Increment the minor version of the package to leave patch versions available for previous version. Read more about this error and potential solutions at https://docs.unity3d.com/Packages/com.unity.package-validation-suite@latest/index.html?preview=1&subfolder=/manual/package_unity_version_validation_error.html#the-unity-version-requirement-is-more-strict-than-in-the-previous-version-of-the-package", - "PackageVersion": "1.0.17" - }, - { - "ValidationTest": "API Validation", - "ExceptionMessage": "New assembly \"Unity.NetCode.TestsUtils\" may only be added in a new minor or major version.", - "PackageVersion": "1.0.17" - }, { "ValidationTest": "API Validation", - "ExceptionMessage": "Additions require a new minor or major version.", - "PackageVersion": "1.0.17" + "ExceptionMessage": "For Experimental or Preview Packages, breaking changes require a new minor version.", + "PackageVersion": "1.1.0-exp.1" } ], "WarningExceptions": [] diff --git a/ValidationExceptions.json.meta b/ValidationExceptions.json.meta index 5eb45c6..2519576 100644 --- a/ValidationExceptions.json.meta +++ b/ValidationExceptions.json.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0397b8ea6a6607344a9b56d7b7f5d00e +guid: 0ff060cf4f3781a49abb183db689f05f TextScriptImporter: externalObjects: {} userData: diff --git a/package.json b/package.json index 08f23b0..f837b0b 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,26 @@ { "name": "com.unity.netcode", "displayName": "Netcode for Entities", - "version": "1.0.17", + "version": "1.1.0-exp.1", "unity": "2022.3", "unityRelease": "0f1", "description": "Unity's Data Oriented Technology Stack (DOTS) multiplayer netcode layer - a high level netcode system built on entities. This package provides a foundation for creating networked multiplayer applications within DOTS.", "dependencies": { "com.unity.transport": "2.0.1", - "com.unity.entities": "1.0.16", - "com.unity.logging": "1.0.16", + "com.unity.entities": "1.1.0-exp.1", + "com.unity.logging": "1.1.0-exp.1", "com.unity.modules.animation": "1.0.0" }, "_upm": { - "changelog": "### Added\n\n* defining ENABLE_UNITY_RPC_REGISTRATION_LOGGING will now log information about registered RPCs during netcode startup\n\n### Changed\n\n* NetcodePacket debug log filenames changed to include date/time and version information\n\n### Fixed\n\n* addressed a case where it was possible for an exception to be thrown on the server if an RPC was queued for a then dropped connection.\n* \"AssetDatabase.RegisterCustomDependency are restricted during importing\" exception thrown by the NetCodeClientSettings, NetCodeClientServerSettings, NetCodeServerSettings in their OnDisable method, when using 2023.2 or newer." + "changelog": "### Added\n\n* source generator can now be configure to enable/disable logs, report timings. It also possible to set the minimal log level (by default is now Error).\n* new public template specs and generator documentation\n* Added convenience methods for getting the clientworld / serverworld (or thin client list) added to ClientServerBootstrap\n* Additional analytics events. Multiplayer tools fields, prediction switching counters, tick rate configuration.\n* New method on the `PredictedFixedStepSimulationSystemGroup` class to initialise the rate as a multiple of a base tick rate.\n* `Packet Fuzz %` is now configurable via the Network Simulator. It's a security tool that should not be enabled during normal testing. It's purpose is to test against malicious MitM attacks, which aim to take down your server via triggering exceptions during packet deserialization. Thus, all deserialization code should be written with safeguards and tolerances, ensuring your logic will fail gracefully.\n* CopyInputToCommandBufferSystemGroup group, that contains all the system that copy IInputCommandData to the underlying ICommand buffer. This let you now target this group with the guarantee that all inputs are not copied after it run.\n* CopyCommandBufferToInputSystemGroup group, that contains all the system that copy ICommandData to their IInputCommandData representation. It runs first in the prediction loop and you can easily target it to do logic before or after the input are updated.\n* GhostSpawnClassificationSystemGroup, that is aimed to contains all your classification systems in one place.\n* Error messages to some missing `NetworkDriver.Begin/EndSend` locations.\n* defining `ENABLE_UNITY_RPC_REGISTRATION_LOGGING` will now log information about registered RPCs during netcode startup\n* We now automatically detect `Application.runInBackground` being set to false during multiplayer gameplay (a common error), and give advice via a suppressible error log as to why it should be enabled.\n* We introduced the new InputBufferData buffer, that is used as underlying container for all IInputComponentData.\n* conditional compilation for some public interfaces in DefaultDriverBuilder to exclude the use of RegisterServer methods for WebGL build (they can't listen). It is possible to do everything manually, but the helper methods are not present anymore.\n* new method for creating a NetworkDriver using WebSocketNetworkInterface.\n* Added two new values to the `NetworkStreamDisconnectReason` enum: `AuthenticationFailure` and `ProtocolError`. The former is returned when the transport is configured to use DTLS or TLS and it fails to establish a secure session. The latter is returned for low-level unexpected transport errors (e.g. malformed packets in a TCP stream).\n\n### Changed\n\n* relaxed public field condition for variants. When declaring a ghost component variations, the variant fields are not required to be public. This make the type pretty much unusable for any other purpose but declaring the type serialisation.\n* Increased the ThinClient cap on `MultiplayerPlayModePreferences.MaxNumThinClients` from 32 to 1k, to facilitate some amount of in-editor testing of high-player-counts.\n* NetcodeTestWorld updates the worlds in the same way the package does: first server, then all clients worlds.\n* When Dedicated Server package is installed, the PlayMode Type value is overridden by the active Multiplayer Role.\n\n### Deprecated\n\n* The public `PredictedFixedStepSimulationGroup.TimeStep`. You should always use the `PredictedFixedStepSimulationGroup.ConfigureTimeStep` to setup the rate of the `PredictedFixedStepSimulationSystemGroup.`.\n* the IInputBufferData interface (internal for code-gen use but public) has been deprecated and will be removed in the 1.2 release.\n\n### Fixed\n\n* incorrect code generated serialization and calculated ChangeMask bits for component and buffers when the GhostFieldAttribute.Composite flag is set to true (in some cases).\n* wrong check for typename in GhostComponentVariation\n* missing region in unquantized float template, causing errors when used for interpolated field.\n* improper check when the ClientServerSetting asset is saved, causing worker process not seeing the changes in the settings.\n* The server world was not setting the correct rate to the group, if that was not done as part of the bootstrap.\n* Exception thrown when the NetDbg tools is connecting to either the editor or player.\n* Renamed (and marginally improved) the \"Multiplayer PlayMode Tools\" Window to the \"PlayMode Tools\" Window, to disambiguate it from \"[MPPM] Multiplayer Play Mode\" (an Engine feature).\n* Attempting to access internals of Netcode for Entities (e.g. via Assembly Definition References) would cause compiler errors due to `MonoPInvokeCallbackAttribute` being ambiguous between AOT and Unity.Entities.\n* Packet dump logging exception when using relevancy, despawns, and packet dumps enabled. Also fixed performance overhead (as it was erroneously logging stack traces).\n* An issue with variant hash calculation in release build, causing ghost prefab hash being different in between development/editor and release build.\n* GhostUpdateSystem.RestoreFromBackup does not always invalidate/bump the chunk version for a component, but only if the chunk as changed since the last time the restore occurred.\n* Issue in TryGetHashElseZero, that was using the ComponentType.GetDebugName to calculate the variant hash, leading incorrect results in a release player build\n* A `NetworkDriver.BeginSend` error causing an infinite loop in the `RpcSystem`.\n* Deprecated Analytics API when using 2023.2 or newer.\n* compilation issue when using 2023.2, caused by an ambiguous symbol (define in both Editor and in Entities.Editor assembly)\n* Errant netcode systems no longer show up in the DefaultWorld: `PhysicsWorldHistory`, `SwitchPredictionSmoothingPhysicsOrderingSystem`, `SwitchPredictionSmoothingSystem`, `GhostPresentationGameObjectTransformSystem`, `GhostPresentationGameObjectSystem`, and `SetLocalPlayerGraphicsColorsSystem`.\n* Previous was hard to retrieve the generated buffer for a given IInputComponentData. Now is easy as doing something like InputBufferData.\n* Compilation error when building for WebGL\n* SnapshotDataLookupCache not created in the correct order, causing custom classification system using the SnapshotBufferHelper to throw exceptions, because the cache was not initialised.\n* A replicated `[GhostEnabledBit]` flag component would throw an `ArgumentException` when added to a Prespawned Ghost due to `ArchetypeChunk.GetDynamicComponentDataArrayReinterpret`." }, "upmCi": { - "footprint": "a56f2a21ac675cb9d7151fd20c66c7f7fabf1770" + "footprint": "142b6d5f84e0d29136fe7e018a8ed953c8e74762" }, - "documentationUrl": "https://docs.unity3d.com/Packages/com.unity.netcode@1.0/manual/index.html", + "documentationUrl": "https://docs.unity3d.com/Packages/com.unity.netcode@1.1/manual/index.html", "repository": { "url": "https://github.cds.internal.unity3d.com/unity/dots.git", "type": "git", - "revision": "8f9a34fef643cca2b5f84470ac61c171c1831168" + "revision": "c3274ed2015eeec85914d62bfbd32e39f0dd112b" } }