diff --git a/.signature b/.signature
new file mode 100644
index 0000000..972d0f4
--- /dev/null
+++ b/.signature
@@ -0,0 +1 @@
+{"timestamp":1732701351,"signature":"IrH1nffs3MGChXx6DN0RphllQUeB3SOeBKHu3vI7wVYa7Ilehw6PrCpM+mzn/Kg5NOAi6mwvZgUNo89saSbbswh3aWerGVrCjsNMVSQHf0p4KoSVwMxl5gAffChMbwXDqF8YkZN8HJQJ08HrjkPd/AfyBIUPQKHpMGiqv02NnZJwAQjbEUiapkotCo7HaN5kcxDK6RG2xeQagAJL8kZEvwMNQv4s/MtAX0Sb3Gf/kT3IfW0WNCdamIh+9WTqRFxlnROZoTpWxrV3vVoU44Bc4tugyvI4ckAx2wOiIe56PiuR3GSPk0EQYkDcrCae9XCPnMOcWjf44icB+IbhJ3fen8CHClhQGwNKU8ut2y2puCzIDohkq7jOgJ63Ma42iwCee7jds4qYMUFp2k7+Ipl7FhlNlS8zd+VJ/q/EcuKkNdYA0ViX6hfc1FJlj0Ljsg/e4EFc8YYAQDZ1NhcYfS7z9iK/lOZEUP1qD32tzZ7GMwg6NuJtUnan4FJ+h3i51A+6","publicKey":"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUFzdUhXYUhsZ0I1cVF4ZEJjTlJKSAordHR4SmoxcVY1NTdvMlZaRE1XaXhYRVBkRTBEMVFkT1JIRXNSS1RscmplUXlERU83ZlNQS0ZwZ1A3MU5TTnJCCkFHM2NFSU45aHNQVDhOVmllZmdWem5QTkVMenFkVmdEbFhpb2VpUnV6OERKWFgvblpmU1JWKytwbk9ySTRibG4KS0twelJlNW14OTc1SjhxZ1FvRktKT0NNRlpHdkJMR2MxSzZZaEIzOHJFODZCZzgzbUovWjBEYkVmQjBxZm13cgo2ZDVFUXFsd0E5Y3JZT1YyV1VpWXprSnBLNmJZNzRZNmM1TmpBcEFKeGNiaTFOaDlRVEhUcU44N0ZtMDF0R1ZwCjVNd1pXSWZuYVRUemEvTGZLelR5U0pka0tldEZMVGdkYXpMYlpzUEE2aHBSK0FJRTJhc0tLTi84UUk1N3UzU2cKL2xyMnZKS1IvU2l5eEN1Q20vQWJkYnJMbXk0WjlSdm1jMGdpclA4T0lLQWxBRWZ2TzV5Z2hSKy8vd1RpTFlzUQp1SllDM0V2UE16ZGdKUzdGR2FscnFLZzlPTCsxVzROY05yNWdveVdSUUJ0cktKaWlTZEJVWmVxb0RvSUY5NHpCCndGbzJJT1JFdXFqcU51M3diMWZIM3p1dGdtalFra3IxVjJhd3hmcExLWlROQWdNQkFBRT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg"}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9f9c5a..e397a81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,63 @@
uid: changelog
---
+## [1.4.0] - 2024-11-14
+
+### Added
+
+* A togglable warning to display when the server is batching ticks.
+* PhysicGroupRunMode property to the NetcodePhysicsConfigAuthoring to let the user configure when the predicted physics loop should run.
+* PredictionLoopUpdateMode property to the ClientTickRate to let the user configure when the PredictionSimulationSystemGroup should update. In particular, it is allow now to have the prediction loop running all the time, regardless of the presence of predicted ghost.
+* `GhostSendSystemData.MaxIterateChunks`, which denotes the maximum number of chunks the `GhostSendSystem` will iterate over in a single tick, for a given connection, within a single `NetworkTickRate` snapshot send interval. It's an optimization in use-cases where you have many thousands of static ghosts (and thus hundreds of static chunks which are iterated over unnecessarily to find ones containing possible changes), but can lead to empty snapshots if set too low. Pairs well with `MaxSendChunks`, and defaults to 0 (OFF) to avoid a behaviour change.
+* Many Unity Transport Package `NetworkConfigParameters` have been added to the `NetCodeConfig`. They are ignored if using a custom driver, unless said driver calls the new static method `DefaultDriverBuilder.AddNetcodePackageNetworkConfigParameters`.
+* `ClientServerTickRate.SnapshotAckMaskCapacity` configures the length of the ack mask history (in `ServerTicks`). It is used by the snapshot system to determine whether or not a ghost has an acked baseline snapshot, and only queried when said chunk is attempting to be resent. Its new default (of 4096, up from 256) supports ~1.1 minutes (up from ~4.26 seconds) under default settings (i.e. assuming a `SimulationTickRate` of 60Hz). Increasing this value further can protect against the aforementioned snapshot acking errors when sending tens of thousands of ghosts to an individual client connection.
+* `GhostAuthoringComponent.MaxSendRate`, which denotes the maximum possible send frequency (in Hz) for ghost chunks of this ghost prefab type. Note, however, that other factors (like `NetworkTickRate`, ghost instance count, the use of Static-Optimization vs Dynamic, `Importance`, Importance-Scaling, `DefaultSnapshotPacketSize` etc.) will determine the final send rate. Use `MaxSendRate` to brute-force reduce the bandwidth consumption of your most impactful ghost types.
+* `GhostCountInstantiatedOnClient` and `GhostCountReceivedOnClient` to the `GhostCount` struct to differentiate ghosts which we have only received the data for, from fully instantiated ghosts (i.e. ghosts with entities). See deprecation entry and `PendingSpawnPlaceholder`.
+* The `AutomaticThinClientWorldsUtility` class, which facilitates runtime creation (and management) of thin clients. It is available to user-code, and when in `PlayType.Server`.
+
+### Changed
+
+* The error for `NetworkProtocolVersion` mismatches will now better indicate what exactly went wrong, and what steps can be taken to resolve the error.
+* Incremental UI improvement to the `MultiplayerPlayModeWindow` netcode worlds display. The server now lists ghost counts (details in tooltip), the client `GhostCount` singleton is now available via hovering over the ping tooltip (as it's often something you want to know), and the `DriverStore` drivers are now displayed consistently.
+* Re-enabled disabled LoadScenes_AllScenesShouldConnect and LoadScenes_NoScenesShouldLog tests randomly failing that were failing because of the CommandSendSystemGroup issue.
+* **Behaviour Breaking Change:** `GhostSendSystemData.MaxSendChunks` no longer limits the max number of chunks to iterate over (i.e. query) - unless `GhostSendSystemData.MaxIterateChunks` is zero - as it no longer counts cancelled chunk snapshot writes towards its total. Therefore, use `GhostSendSystemData.MaxIterateChunks` instead to denote that limit. This should lead to fewer emptier packets, particularly when used in conjunction with many static and irrelevant ghosts.
+* **API & Behaviour Breaking Change:** The netcode package `DefaultDriverConstructor` will now default to the transports `NetworkParameterConstants.SendQueueCapacity` and `ReceiveQueueCapacity` respectively (each `512`), rather than our own package implementation of `max(playerCount * 4, 64)` where `playerCount` is an optional parameter defaulting to 0. This optional parameter has since been removed from `CreateServerNetworkDriver` and `GetNetworkServerSettings`, but you can instead override them via the `NetCodeConfig` additions (see entry). This prevents the common fatal error case when playtesting with higher player counts, and removes the most common need for a per-project `INetworkStreamDriverConstructor`, but is a small regression in memory consumption (~1.8MB) on both the client and the server, when using any built-in `INetworkStreamDriverConstructor`. We recommend configuring them back to 64 if that previously did not cause any issues.
+* The verbose "Delta time was negative. To avoid undefined behaviour the frame is skipped." log has been moved behind `NetDebug.DebugLog` and re-worded.
+* Merged the two internal batched and unbatched `GatherGhostChunks` methods. Performance characteristics of both should be practically identical.
+* Placeholder ghosts are now given the name `GHOST-PLACEHOLDER-{ghostType}` to aid in debugging.
+* Copy editing and improvements to the Setting up client and server worlds section of the documentation.
+* **Behaviour Breaking Change:** The client will now ignore the `HandshakeApprovalTimeoutMS` until it has completed the `Handshake` phase, as it should respect this servers value, rather than assuming its own. Relatedly: Be aware that client worlds will not fetch the `ClientServerTickRate` values from a `NetCodeConfig.Global` config, they will only accept values sent to it by the server during handshake.
+* **Behaviour Breaking Change:** The `AddCommandData` method will now reject inputs with `Invalid` Tick values, preventing runtime exceptions in rare cases.
+* **Behaviour Breaking Change:** The `DefaultDriverConstructor` no longer removes the IPC driver when `RequestedPlayType == Server`, as thin clients can now be instantiated on DGS builds (assuming supported by user-code).
+
+### Deprecated
+
+* `NetworkDriverInstance.simulatorEnabled` setter, as writing to it did not effectively enable and disable the simulator.
+* **Behaviour Breaking Change:** `GhostSendSystemData.MaxSendEntities` no longer functions, as it was somewhat misleading, and less precise than `MaxSendChunks` and `MaxIterateChunks`.
+* Renamed `GetNetworkSettings` to `GetNetworkClientSettings`.
+* `GhostCount.GhostCountOnClient` has been deprecated as it is ambiguous: Its value is the same as the new `GhostCountReceivedOnClient`, but its tooltip incorrectly implied that it was the `GhostCountInstantiatedOnClient`.
+
+### Fixed
+
+* `MultiplayerPlayModeWindow` issue where the width of the server world buttons were erroneously causing a Horizontal Scrollbar. Also removed slightly excessive repainting.
+* Limitation preventing the `MultiplayerPlayModeWindow` from being resized when undocked.
+* CommandSendSystemGroup running systems when the current server tick is invalid, CommandSendPacketSystem (and other system potentially) throwing exceptions.
+* an issue when using physics interpolation, causing graphical jitter on the replicated ghost when the physics system run on partial ticks.
+* It is possible now to allow physics to run in the prediction loop even in case no predicted ghosts are present. This can be achieved by combining the PredictionLoopUpdateMode and PhysicGroupRunMode options.
+* an issue with netcode source generated files, causing multiple Burst.CompileAsync invocation, ending up in stalling the editor and the player for long time, and / or causing crashes.
+* Critical `GhostSendSystem` and `GhostChunkSerializer` issue preventing ghosts from successfully acking their own previous snapshots, in cases where the next attempted resend of a ghost chunk exceeded 256 ticks (easily encountered when attempting to replicate thousands of ghosts to a single connection). Whenever a ghost chunk is unable to ack, larger deltas must be resent, and static optimization early-outing logic cannot be applied, causing unnecessary bandwidth and CPU consumption. While this issue did tend to stabilize over time, our initial fix is to increase this ack window considerably (see `ClientServerTickRate.SnapshotAckMaskCapacity` entry).
+* Prevented the `GhostAuthoringInspectionComponent` from erroneously re-baking the ghost while the user is editing a property on said ghost prefab (applicable only when in 'Auto-Refresh' mode).
+* `MinSendImportance` no longer artificially delays the initial send of ghosts with low importance values (although this was mitigatable via `FirstSendImportanceMultiplier`).
+* Issue with ElapsedTime in server worlds where it could fall behind compared to InitializationSystemGroup's if the frame's deltaTime was going over MaxSimulationStepsPerFrame * MaxSimulationStepBatchSize settings. This changes the catch up behaviour server side. Previously, the server would skip ticks if batching wasn't enough while now it'll do its best to catchup on those missing ticks on the subsequent frames if time allows.
+* Issue where Netcode's ElapsedTime could be ahead of the InitializationSystemGroup elapsed time in server worlds. It should now either always be equal to or slightly behind if not enough time has accumulated for a tick to execute.
+* Issue where disconnecting while in the process of spawning prefabs raised the following error: "Found a ghost in the ghost map which does not have an entity connected to it. This can happen if you delete ghost entities on the client."
+* Overzealous RPC validation error when broadcasting an RPC on the same frame as a disconnection.
+* The `AutomaticThinClientWorldsUtility` now allows you to disable automatic in-editor thin client creation by setting `BootstrapInitialization` and `RuntimeInitialization` to null during bootstrapping.
+* Removed the limitation preventing thin clients from being created when in mode `Server`, including DGS builds. Ensure thin client systems are in assemblies that will be loaded on the server.
+* Bug causing user-created thin client worlds to be automatically cleaned up by the netcode package due to `RequestedNumThinClients`. Now, only worlds which are created via the `AutomaticThinClientWorldsUtility` (or manually added by user-code to its tracking list) will be automatically disposed.
+
+
+
## [1.3.6] - 2024-10-16
### Changed
@@ -15,6 +72,7 @@ uid: changelog
* Issue where `OverrideAutomaticNetcodeBootstrap` instances in scenes would be ignored in the Editor if 'Fast Enter Play-Mode Options' is disabled (i.e. when domain reloads triggered after clicking to enter play-mode).
* Longstanding API documentation errors across Netcode for Entities API documentation.
+
## [1.3.2] - 2024-09-06
### Changed
diff --git a/Documentation~/TableOfContents.md b/Documentation~/TableOfContents.md
index b15a350..5e93dce 100644
--- a/Documentation~/TableOfContents.md
+++ b/Documentation~/TableOfContents.md
@@ -23,6 +23,7 @@
* [Testing and debugging your game](debugging.md)
* [Logging](logging.md)
* [Using the PlayMode Tool](playmode-tool.md)
+ * [Testing with thin clients](thin-clients.md)
* [Gathering metrics with MetricsMonitorComponent](metrics.md)
* [Using source generators](source-generators.md)
* [Optimizing performance](optimizing.md)
diff --git a/Documentation~/client-server-worlds.md b/Documentation~/client-server-worlds.md
index d19d23b..e947ddf 100644
--- a/Documentation~/client-server-worlds.md
+++ b/Documentation~/client-server-worlds.md
@@ -1,17 +1,19 @@
# Client and server worlds networking model
-The Netcode for Entities package has a separation between client and server logic, and splits logic into multiple worlds (the "client world", and the "server world").
-It does this using concepts laid out in the [hierarchical update system](https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/systems-update-order.html) of Unity’s Entity Component System (ECS).
+Understand the client and server networking model that the Netcode for Entities package uses.
-## Declaring in which world the system should update
+Netcode for Entities separates client and server logic into two worlds, referred to as the client world and the server world respectively. The concept of [worlds](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/manual/concepts-worlds.html) is inherited from Unity's Entity Component System (ECS), and refers to a collection of [entities](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/manual/concepts-entities.html) and [systems](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/manual/concepts-systems.html) arranged into [system groups](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/manual/systems-update-order.html).
-By default, systems are created into (and updated in) the `SimulationSystemGroup`, and created for both client and server worlds. If you want to override that behavior (for example, to have your system
-created and run only on the client world), there are two different ways to do it.
+In addition to the standard client and server worlds, Netcode for Entities also supports [thin clients](thin-clients.md) which you can use to test your game during development.
-### Targeting specific system groups
+## Configuring system creation and updates
+
+By default, systems are created and updated in the [`SimulationSystemGroup`](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/api/Unity.Entities.SimulationSystemGroup.html) for both client and server worlds. If you want to override this behavior (for example, to have your system created and run only on the client world), there are two different methods available.
+
+### Target specific system groups
+
+When you specify a system group that your system belongs in, Unity automatically filters out your system in worlds where this system group isn't present. This means that systems in a system group inherit the world filter of said system group. For example:
-By specifying that your system belongs in a specific system group (that is present only in the desired world), your system will automatically **not** be created in worlds where this system group is not present.
-In other words: Systems in a system group inherit system group world filtering. For example:
```csharp
[UpdateInGroup(typeof(GhostInputSystemGroup))]
public class MyInputSystem : SystemBase
@@ -19,26 +21,27 @@ public class MyInputSystem : SystemBase
...
}
```
-Because the `GhostInputSystemGroup` exists only for client worlds, the `MyInputSystem` will **only** be present on the client world (caveat: this includes both `Client` and `Thin Client` worlds).
-> [!NOTE]
-> Systems that update in the `PresentationSystemGroup` are only added to the client world, since the `PresentationSystemGroup` is not created for `Server` and `Thin Client` worlds.
+If you examine the `WorldSystemFilter` attribute on [`GhostInputSystemGroup`](https://docs.unity3d.com/Packages/com.unity.netcode@latest?subfolder=/api/Unity.NetCode.GhostInputSystemGroup.html), you will find that this system group only exists for client, thin client, and local simulation (offline) worlds. It also has a `childDefaultFlags` argument which specifies the flags that child systems, such as the example `MyInputSystem`, inherit (and this argument doesn't contain thin client worlds). Therefore, `MyInputSystem` will be present on full client and local simulation worlds exclusively (unless a `WorldSystemFilter` is added to `MyInputSystem` overriding this default).
+> [!NOTE]
+> Systems that update in the [`PresentationSystemGroup`](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/api/Unity.Entities.PresentationSystemGroup.html) are only added to the client world because the `PresentationSystemGroup` isn't created for server and thin client worlds.
### Use WorldSystemFilter
-When more granularity is necessary (or you just want to be more explicit about which world type(s) the system belongs to), you should use the
-[WorldSystemFilter](https://docs.unity3d.com/Packages/com.unity.entities@latest/index.html?subfolder=/api/Unity.Entities.WorldSystemFilter.html) attribute.
+Use the [`WorldSystemFilter`](https://docs.unity3d.com/Packages/com.unity.entities@latest/index.html?subfolder=/api/Unity.Entities.WorldSystemFilter.html) attribute to specify the world type(s) that the system belongs to in more detail.
-When an entity `World` is created, users tag it with specific [WorldFlags](https://docs.unity3d.com/Packages/com.unity.entities@latest/index.html?subfolder=/api/Unity.Entities.WorldFlags.html),
-that can then be used by the Entities package to distinguish them (for example, to apply filtering and update logic).
+When a world is created, you can tag it with specific [`WorldFlags`](https://docs.unity3d.com/Packages/com.unity.entities@latest/index.html?subfolder=/api/Unity.Entities.WorldFlags.html) that Netcode for Entities uses to distinguish between worlds (for example, to apply filtering and update logic).
-By using the `WorldSystemFilter`, you can declare (at compile time) which world types your system belongs to:
-- `LocalSimulation`: a world that does not run any Netcode systems, and that's not used to run the multiplayer simulation.
+Use `WorldSystemFilter` to declare (at compile time) which of the following world types your system belongs to:
+
+- `LocalSimulation`: a world that doesn't run any Netcode systems, and that's not used to run the multiplayer simulation.
- `ServerSimulation`: a world used to run the server simulation.
- `ClientSimulation`: a world used to run the client simulation.
- `ThinClientSimulation`: a world used to run the thin client simulation.
+In the following example, `MySystem` is defined such that it's only present for worlds that can be used to run the client simulation (any world that has the `WorldFlags.GameClient` set). `WorldSystemFilterFlags.Default` is used when this attribute isn't present and automatically inherits its filtering rules from its parent system group (in this case, that's the `SimulationSystemGroup`, because no `UpdateInGroup` attribute is specified).
+
```csharp
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public class MySystem : SystemBase
@@ -46,13 +49,13 @@ public class MySystem : SystemBase
...
}
```
-In the example above, we declared that the `MySystem` system should **only** be present for worlds that can be used for running the client simulation. That is, the world that has the `WorldFlags.GameClient` set. `WorldSystemFilterFlags.Default` is used when this attribute is not present.
-## Bootstrap
+## Creating client and server worlds with bootstrapping
-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.
+When you add Netcode for Entities to your project, the default [`ClientServerBootstrap` class](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerBootstrap.html) is added to the project. This bootstrapping class configures and creates the server and client worlds at runtime when your game starts (or when entering Play mode in the Unity Editor).
The default bootstrap creates the client and server worlds automatically at startup:
+
```c#
public virtual bool Initialize(string defaultWorldName)
{
@@ -61,15 +64,17 @@ The default bootstrap creates the client and server worlds automatically at star
}
```
-It populates them with the systems defined by the `[WorldSystemFilter(...)]` attributes you have set. This is useful when you're working in the Editor, and you enter Play Mode with your game scene opened. However, in a standalone game - where you typically want to use some sort of frontend menu - you might want to delay world creation, or choose which Netcode worlds to spawn.
+`ClientServerBootstrap` uses the same bootstrapping flows as defined by [Entities](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/manual/index.html), which means that new worlds are populated using all the systems defined by the relevant world filtering set (such as `[WorldSystemFilter(...)]` attributes you have defined, `WorldSystemFilterFlags` rules your systems inherit, and other attributes like `DisableAutoCreation`). Netcode for Entities also injects many systems (and groups) automatically.
+
+This automatic world creation is most useful when you're working in the Editor and enter Play mode with your game scene opened, because it allows immediate Editor iteration testing of your multiplayer game. However, in a standalone game where you typically want to use some sort of front-end menu, you might want to delay world creation, or choose which Netcode worlds to spawn.
-For example, 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.
+For example, consider a "Hosting a client-hosted server" flow versus a "Connect as a client to a dedicated server via matchmaking" flow. In the first scenario, you want to add (and connect via IPC to) an in-process server world. In second scenario, you only want to create a client world. In these cases, you can choose to customize the bootstrapping flow.
-It's possible to create your own bootstrap class and customize your game flow by creating a class that extends `ClientServerBootstrap` (such as `MyGameSpecificBootstrap`), and overriding 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. For more details, refer to [ClientServerBootstrap methods](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerBootstrap.html).
+### Customize the bootstrapping flow
-The following code example shows how to override the default bootstrap to prevent automatic creation of the client server worlds:
+You can create your own bootstrap class and customize your game flow by creating a class that extends `ClientServerBootstrap` (such as `MyGameSpecificBootstrap`), and overriding the default `Initialize` method implementation. In your derived class, you can reuse the provided helper methods, which let you create `client`, `server`, `thin-client` and `local simulation` worlds. For more details, refer to [`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 and server worlds:
```c#
public class MyGameSpecificBootstrap : ClientServerBootstrap
@@ -90,14 +95,13 @@ Then, when you're ready to create the various Netcode worlds, call:
void OnPlayButtonClicked()
{
// Typically this:
- var clientWorld = ClientServerBoostrap.CreateClientWorld();
+ var clientWorld = ClientServerBootstrap.CreateClientWorld();
// And/Or this:
- var serverWorld = ClientServerBoostrap.CreateServerWorld();
+ var serverWorld = ClientServerBootstrap.CreateServerWorld();
// And/Or something like this, for soak testing:
- const int numThinClientWorldsForStressTest = 10;
- for(int i = 0; i < numThinClientWorldsForStressTest; i++)
- ClientServerBoostrap.CreateThinClientWorld();
+ AutomaticThinClientWorldsUtility.NumThinClientsRequested = 10;
+ AutomaticThinClientWorldsUtility.BootstrapThinClientWorlds();
// Or the following, which creates worlds smartly based on:
// - The Playmode Tool setting specified in the editor.
@@ -106,61 +110,55 @@ void OnPlayButtonClicked()
}
```
-There are 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).
+There are [Netcode samples](https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/master/NetcodeSamples/README.md) showcasing how to manage scene and subscene loading with this world creation setup, as well as proper Netcode world disposal (when leaving the gameplay loop).
-## Fixed and dynamic time-step
+## Updating the client and server
-When using 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 ensure that the server doesn't end up in a state where it takes several seconds to simulate a single frame.
+When using Netcode for Entities, the server always updates on a fixed timestep to ensure a baseline level of determinism for client prediction (although it's not strict determinism), for physics stability, and for frame rate independence. The package also limits the maximum number of fixed-step iterations per frame to ensure that the server doesn't end up in a state where it takes several seconds to simulate a single frame.
-It's therefore important to understand that the fixed update does not use the [standard Unity update frequency](https://docs.unity3d.com/Manual/class-TimeManager.html).
+Importantly, the fixed update doesn't use the [standard Unity update frequency](https://docs.unity3d.com/Manual/class-TimeManager.html), nor the physics system __Fixed Timestep__ frequency. It uses its own `ClientServerTickRate.SimulationTickRate` frequency (which` Unity.Physics` - if in use - must be an integer multiple of, refer to `ClientServerTickRate.PredictedFixedStepSimulationTickRatio`).
-### Configuring the server fixed update loop
+Clients, however, update at a dynamic timestep, except for [prediction code](intro-to-prediction.md), which always runs at the same fixed timestep as the server, attempting to maintain a deterministic relationship between the two simulations.
-The [ClientServerTickRate](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html) singleton component (in the server world) controls this tick-rate.
+Refer to [partial ticks](intro-to-prediction.md#partial-ticks) to understand how prediction is handled for refresh rates that aren't in sync with full ticks.
-By using the `ClientServerTickRate`, you can control different aspects of the server simulation loop. For example:
-- The `SimulationTickRate` lets you configure the number of simulation ticks per second.
-- The `NetworkTickRate` lets you configure how frequently the server sends snapshots to the clients (by default the `NetworkTickRate` is identical to the `SimulationTickRate`).
+### Configuring the server fixed update loop
-**The default number of simulation ticks is 60**.
+The [`ClientServerTickRate`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html) singleton component (in the server world) controls the server tick-rate.
-If the server updates at a lower rate than the simulation tick rate, it will perform multiple ticks in the same frame. For example, if the last server update took 50ms (instead of the usual 16ms), the server will need to catch up and will do ~3 simulation steps on the next frame (16ms * 3 ≈ 50ms).
+Using `ClientServerTickRate`, you can control different aspects of the server simulation loop. For example:
-This behavior can lead to what is known as 'the spiral of death' (or 'death spiral'): the server update becomes slower and slower (because it's executing more steps per update, to catch up), causing it to become even further behind (creating more problems).
-The `ClientServerTickRate` allows you to customize how the server runs in this particular situation (that is, when the server can't maintain the desired tick-rate).
+- `SimulationTickRate` configures the number of simulation ticks per second. The default number of simulation ticks is 60 per second.
+- `NetworkTickRate` configures how frequently the server sends snapshots to the clients (by default, the `NetworkTickRate` is identical to the `SimulationTickRate`).
-By setting the [MaxSimulationStepsPerFrame](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html#ClientServerTickRate_MaxSimulationStepsPerFrame)
-you can control how many simulation steps the server can run in a single frame.
-By using the [MaxSimulationStepBatchSize](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html#MaxSimulationStepBatchSize)
-you can instruct the server loop to `batch` together multiple ticks into a single step, but with a multiplier on the delta time. For example, instead of running two steps, you can run only one (but with double the delta time).
+#### Avoiding performance issues
-> [!NOTE]
-> This batching only works under specific conditions, and has its own nuances and considerations. Ensure that your game doesn't assume that one simulation step is equivalent to one tick (nor should you hardcode deltaTime).
-> This type of situation can happen when your server is having performance issues. This produces mispredictions, since the simulation granularity won't be the same on both client and server side.
+If the server updates at a lower rate than the simulation tick rate, it will perform multiple ticks in the same frame. For example, if the last server update took 50 ms (instead of the usual 16 ms), the server will need to catch up and will do ~3 simulation steps on the next frame (16 ms * 3 ≈ 50 ms).
-Finally, you can configure how the server should consume the idle time to target the desired frame rate.
-The [TargetFrameRateMode](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html#TargetFrameRateMode) controls how the server should keep the tick rate. Available values are:
-* `BusyWait` to run at maximum speed
-* `Sleep` for `Application.TargetFrameRate` to reduce CPU load
-* `Auto` to use `Sleep` on headless servers and `BusyWait` otherwise
+This behavior can lead to compounding performance issues: the server update becomes slower and slower (because it's executing more steps per update, to catch up), causing it to become even further behind, creating more problems. `ClientServerTickRate` allows you to customize how the server behaves in this situation when the server can't maintain the desired tick-rate.
+- Setting [`MaxSimulationStepsPerFrame`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html#ClientServerTickRate_MaxSimulationStepsPerFrame) controls how many simulation steps the server can run in a single frame.
+- Setting [`MaxSimulationStepBatchSize`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html#MaxSimulationStepBatchSize) instructs the server loop to batch together multiple ticks into a single step, but with a multiplier on the delta time. For example, instead of running two steps, the server runs only one (but with double the delta time).
-### Configuring the client update loop
+> [!NOTE]
+> The batching enabled with `MaxSimulationStepBatchSize` only works under specific conditions and has its own nuances and considerations. Ensure that your game doesn't assume that one simulation step is equivalent to one tick and don't hard code `TimeData.DeltaTime`.
+> This type of situation can happen when your server is having performance issues. This produces mispredictions because the simulation granularity won't be the same on both client and server side.
-The client updates at a dynamic time step, with the exception of prediction code (which always runs at the same fixed time step as the server, attempting to maintain a somewhat deterministic relationship between the two simulations).
-The prediction runs in the [PredictedSimulationSystemGroup](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.PredictedSimulationSystemGroup.html), which applies this unique fixed time step for prediction.
+Finally, you can configure how the server consumes the idle time to target the desired frame rate. [`TargetFrameRateMode`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.ClientServerTickRate.html#TargetFrameRateMode) controls how the server maintains the tick rate. Available values are:
-**The `ClientServerTickRate` configuration is sent (by the server, to the client) during the initial connection handshake. The client prediction loop runs at the exact same `SimulationTickRate` as the server (as mentioned).**
+- `BusyWait` to run at maximum speed.
+- `Sleep` for `Application.TargetFrameRate` to reduce CPU load.
+- `Auto` to use `Sleep` on headless servers and `BusyWait` otherwise.
-## Standalone builds
+### Configuring the client update loop
-Netcode exposes build configuration options inside **ProjectSettings** > **Entities** > **Build**.
+Clients update at a dynamic timestep, with the exception of [prediction code](intro-to-prediction.md), which always runs at the same fixed timestep as the server in an attempt to maintain a deterministic relationship between the two simulations. Prediction runs in the [`PredictedSimulationSystemGroup`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.PredictedSimulationSystemGroup.html), which applies this unique fixed timestep for prediction.
-[Please refer to the Project Settings page for details.](project-settings.md)
+The `ClientServerTickRate` configuration is sent (by the server, to the client) during the initial connection handshake. The client prediction loop runs at the exact same `SimulationTickRate` as the server.
## World migration
-Sometimes you want to destroy the world you're in and spin up another world without losing the connection state. You can use `DriverMigrationSystem` for this, which allows you to store and load Transport-related information so a smooth world transition can be made.
+If you want to destroy the world you're in and spin up another world without losing the connection state, you can use `DriverMigrationSystem`, which allows you to store and load transport-related information so a smooth world transition can be made.
```
public World MigrateWorld(World sourceWorld)
@@ -185,53 +183,8 @@ 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 alongside your normal client and server worlds.
-See the _Playmode Tools_ section above for how to configure them.
+## Additional resources
-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.
-
-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.
-
-### Thin client workflow recommendations
-
-Thin clients can be used in a variety of ways to help test multiplayer games. We recommend the following:
-
-* Thin clients allow you to quickly test client flows, things like team assignment, spawn locations, leaderboards, UI etc.
-* Thin clients created in built players, allowing stress and soak testing of your game servers. For example, 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.
-* Thin clients controlled by a second input source. 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.
-
-### 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 clients don't work out of the box with `AutoCommandTarget`, because `AutoCommandTarget` requires the same ghost to exist on both the client and the server, and thin clients don't create ghosts. So you need to set up the `CommandTarget` component on the connection entity yourself.
-
-`IInputComponentData` is the 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, 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.**
-1. Set up 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.
-```
-1. On the server (where you spawn the actual character controller ghost for the thin client, 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 });
-```
+- [Entities overview](https://docs.unity3d.com/Packages/com.unity.entities@latest?subfolder=/manual/index.html)
+- [Thin clients](thin-clients.md)
+- [Introduction to prediction](intro-to-prediction.md)
diff --git a/Documentation~/ghost-snapshots.md b/Documentation~/ghost-snapshots.md
index d22e954..990fdaa 100644
--- a/Documentation~/ghost-snapshots.md
+++ b/Documentation~/ghost-snapshots.md
@@ -42,7 +42,18 @@ Ghost can be authored in the Editor by creating a prefab with a [GhostAuthoringC
The __GhostAuthoringComponent__ has a small editor that you can use to configure how Netcode for Entities synchronizes the prefab.
You must set the __Name__, __Importance__, __Supported Ghost Mode__, __Default Ghost Mode__ and __Optimization Mode__ property on each ghost.
-Netcode for Entities uses the __Importance__ property to control which entities are sent when there is not enough bandwidth to send all. A higher value makes it more likely that the ghost will be sent.
+Netcode for Entities uses the __Importance__ property to control which entities are sent when there is not enough bandwidth to send all instantiated ghosts. A higher value makes it more likely that the ghost will be sent.
+
+The (optional) __MaxSendRate__ property denotes the absolute maximum send frequency (in Hz) for ghost chunks of this ghost prefab type (excluding a few nuanced exceptions).
+__Important Note:__ `MaxSendRate` only denotes the maximum *possible* replication frequency, and cannot be enforced in all cases.
+I.e. Other factors (like `ClientServerTickRate.NetworkTickRate`, ghost instance count, __Importance__,
+Importance-Scaling, `GhostSendSystemData.DefaultSnapshotPacketSize`, and structural changes etc.) will determine the final send rate.
+
+Examples:
+* A ghost with a `MaxSendRate` of 100Hz will still be rate limited by the `NetworkTickRate` itself, which is 60Hz by default.
+* Similarly, a ghost with a `MaxSendRate` of 60Hz instantiated in a project with a `NetworkTickRate` of 30Hz will be sent at a maximum of 30Hz.
+* As this calculation can only be performed on integer/whole `ticksSinceLastSent` ticks, a ghost with a `MaxSendRate` in-between multiples of the `NetworkTickRate` will be rounded down to the next multiple.
+E.g. `NetworkTickRate:30Hz`, `MaxSendRate:45` means 30Hz is the actual maximum send rate.
You can select from three different __Supported Ghost Mode__ types:
diff --git a/Documentation~/network-protocol-checks.md b/Documentation~/network-protocol-checks.md
new file mode 100644
index 0000000..07ec8a2
--- /dev/null
+++ b/Documentation~/network-protocol-checks.md
@@ -0,0 +1,46 @@
+# Network protocol checks
+
+Understand network protocol checks in Netcode for Entities and how to disable them if required.
+
+When a client connects to a server, they exchange a handshake protocol ([`NetworkProtocolVersion`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.NetworkProtocolVersion.html)) that contains the Netcode for Entities version, game version, and a hash representing the remote procedure call (RPC) and serialized component collections present on the server/client. This protocol check is a preventative measure to stop incompatible versions of games from connecting to each other, which can lead to undefined behavior.
+
+## Calculating hashes
+
+The RPC collection is a hash calculated from all RPCs that are compiled into (present in) all loaded assemblies, based on their type and their members. Similarly, the hash of serialized components is based on all the ghost components that are compiled into all loaded assemblies and picked up by Netcode for Entities. Using the types and the type members of RPCs and serialized components, two hashes are calculated, which are then shared as part of the protocol.
+
+## Protocol validation
+
+By default, Netcode for Entities requires the exchanged protocol hashes to be deterministic (fully identical) to prevent mis-match exceptions and enable bandwidth optimizations. However, due to the strictness of the determinism requirement, the protocol check can frequently flag potentially compatible builds as incompatible during development (by producing false positive hits when testing).
+
+For example, when testing a standalone Player against an in-Editor world, the Unity Editor might have some test assemblies loaded (which might contain RPC types, ghost component types, or runtime ghost types) that aren't included in the build. This causes a hash mismatch, and therefore a disconnection. To avoid these issues, the strict protocol version check can [be disabled](#disabling-strict-protocol-checks).
+
+When a protocol version error occurs, the client disconnects itself from the remote via `NetworkStreamDisconnectReason.BadProtocolVersion`, which user code can read and use to signal to the player that their build is incompatible with the target server. In development builds, Netcode for Entities also outputs error logs that contain the full and sorted lists of RPCs and ghost types loaded on the local client. You can cross reference these logs against the logs from the server to troubleshoot type mismatches.
+
+## Disabling strict protocol checks
+
+To disable strict protocol checking, set [`RpcCollection.DynamicAssemblyList`](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.RpcCollection.html#Unity_NetCode_RpcCollection_DynamicAssemblyList)
+to true as in the following example:
+
+```csharp
+[BurstCompile] // BurstCompile is optional
+[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;
+ }
+}
+```
+
+Because this change modifies the `RpcCollection` (which is itself instantiated by the `RpcSystem`), this flag needs to be set before `RpcSystem.OnUpdate` has run, but after `RpcSystem.OnCreate` has run (which is why the `CreateAfter` attribute is used). This flag must also match on both the client and the server before beginning communication, because Netcode for Entities changes its RPC encoding based on the flag's value, including for the `NetworkProtocolVersion` RPC itself. Attempting to connect to a world with a different flag value than your own will lead to a similar (but less explicit) forceful disconnect error.
+
+> [!NOTE]
+> Enabling this flag adds six bytes to each RPC sent because it sends the full RPC hash instead of a `ushort` index into a guaranteed deterministic lookup. This can result in Netcode for Entities throwing a mid-game runtime error if it receives a ghost or RPC with an unknown type hash, and only then forcibly disconnecting. This can cause clients to be kicked hours into a game session if they receive a ghost or RPC that they're not aware of (rather than having this data validated during the connection attempt handshake).
+
+## Additional resources
+
+- [`NetworkProtocolVersion` API documentation](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.NetworkProtocolVersion.html)
\ No newline at end of file
diff --git a/Documentation~/optimizations.md b/Documentation~/optimizations.md
index 8f53b01..2fe1a46 100644
--- a/Documentation~/optimizations.md
+++ b/Documentation~/optimizations.md
@@ -31,6 +31,11 @@ The cost of prediction increases with each predicted ghost.
Thus, as an optimization, we can opt-out of predicting a ghost given some set of criteria (e.g. distance to your clients character controller).
See [Prediction Switching](prediction-switching.md) for details.
+### Using `MaxSendRate` to reduce Client Prediction Costs
+Predicted ghosts are particularly impacted by the `GhostAuthoringComponent.MaxSendRate` setting, because we only rollback and re-simulate a predicted ghost after it is received in a snapshot.
+Therefore, reducing the frequency by which a ghost chunk is added to the snapshot indirectly reduces predicted ghost re-simulation rate, saving client CPU cycles in aggregate.
+However, it may cause larger client misprediction errors, which leads to larger corrections, which can be observed by players. As always, it is a trade-off.
+
## Executing Expensive Operations during Off Frames
On client-hosted servers, your game can be set at a tick rate of 30Hz and a frame rate of 60Hz (if your ClientServerTickRate.TargetFrameRateMode is set to BusyWait). Your host would execute 2 frames for every tick. In other words, your game would be less busy one frame out of two. This can be used to do extra operations during those "off frames".
To access whether a tick will execute during the frame, you can access the server world's rate manager to get that info.
@@ -90,18 +95,29 @@ relevancy.ValueRW.DefaultRelevancyQuery = GetEntityQuery(typeof(AsteroidScore));
## Limiting Snapshot Size
-* The per-connection component `NetworkStreamSnapshotTargetSize` will stop serializing entities into a snapshot if/when the snapshot goes above the specified byte size (`Value`). This is a way to try to enforce a (soft) limit on per-connection bandwidth consumption.
+* Use `GhostAuthoringComponent.MaxSendRate` to broadly reduce/clamp the resend rate of each of your ghost prefab types.
+It is an effective tool to reduce total bandwidth consumption, particularly in cases where your snapshot is always filling up with large ghosts with high priorities.
+_For example: A "LootItem" ghost prefab type can be told to only replicate - at most - on every tenth snapshot, by setting `MaxSendRate` to 10._
+
+* The per-connection component `NetworkStreamSnapshotTargetSize` will stop serializing entities into a snapshot if/when the snapshot goes above the specified byte size (`Value`).
+This is a way to try to enforce a (soft) limit on per-connection bandwidth consumption.
+To apply this limit globally, set a non-zero value in `GhostSendSystemData.DefaultSnapshotPacketSize`.
+
+> [!NOTE]
+> Note that `MaxSendRate` is distinct from `Importance`: The former enforces a cap on the resend interval, whereas the latter informs the `GhostSendSystem` of which ghost chunks should be prioritized in the next snapshot.
+> Therefore, `MaxSendRate` can be thought of as a gating mechanism (much like its predecessor; `MinSendImportance`).
> [!NOTE]
> Snapshots do have a minimum send size. This is because - per snapshot - we ensure that _some_ new and destroyed entities are replicated, and we ensure that at least one ghost has been replicated.
-* `GhostSendSystemData.MaxSendEntities` can be used to limit the max number of entities added to any given snapshot.
+* `GhostSendSystemData.MaxSendChunks` can be used to limit the max number of chunks added to any given snapshot.
-* Similarly, `GhostSendSystemData.MaxSendChunks` can be used to limit the max number of chunks added to any given snapshot.
+* `GhostSendSystemData.MaxIterateChunks` can be used to limit the total number of chunks the `GhostSendSystem` will iterate over & serialize when looking for ghosts to replicate.
+Very useful when dealing with thousands of static ghosts.
-* `GhostSendSystemData.MinSendImportance` can be used to prevent a chunks entities from being sent too frequently.
- _For example: A "DroppedItems" ghostType can be told to only replicate on every tenth snapshot, by setting `MinSendImportance` to 10, and dropped item `Importance` to 1._
- `GhostSendSystemData.FirstSendImportanceMultiplier` can be used to bump the priority of chunks containing new entities, to ensure they're replicated quickly, regardless of the above setting.
+* `GhostSendSystemData.MinSendImportance` can be used to prevent a chunks entities from being sent too frequently.
+__As of 1.4, prefer `GhostAuthoringComponent.MaxSendRate` over this global.__
+`GhostSendSystemData.FirstSendImportanceMultiplier` can be used to bump the priority of chunks containing new entities, to ensure they're replicated quickly, regardless of the above setting.
> [!NOTE]
> The above optimizations are applied on the per-chunk level, and they kick in **_after_** a chunks contents have been added to the snapshot. Thus, in practice, real send values will be higher.
diff --git a/Documentation~/set-up-client-server-worlds.md b/Documentation~/set-up-client-server-worlds.md
index b426c56..677aebd 100644
--- a/Documentation~/set-up-client-server-worlds.md
+++ b/Documentation~/set-up-client-server-worlds.md
@@ -4,5 +4,5 @@ Set up your clients and server using Netcode for Entities' networking model.
| **Topic** | **Description** |
| :------------------------------ | :------------------------------- |
-| **[Client and server worlds networking model](client-server-worlds.md)** | An introduction to the networking model in Netcode for Entities. |
-| **[Network protocol checks](network-protocol-checks.md)** | When a client connects to a server, they exchange a protocol ([NetworkProtocolVersion](https://docs.unity3d.com/Packages/com.unity.netcode@latest/index.html?subfolder=/api/Unity.NetCode.NetworkProtocolVersion.html)) that contains the netcode version, game version, RPC collection, and serialized component collections. |
+| **[Client and server worlds networking model](client-server-worlds.md)** | Understand the client and server networking model that the Netcode for Entities package uses. |
+| **[Network protocol checks](network-protocol-checks.md)** | Understand network protocol checks in Netcode for Entities and how to disable them if required. |
diff --git a/Documentation~/thin-clients.md b/Documentation~/thin-clients.md
new file mode 100644
index 0000000..92f65fc
--- /dev/null
+++ b/Documentation~/thin-clients.md
@@ -0,0 +1,49 @@
+# Testing with thin clients
+
+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.
+
+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.
+
+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, and note that `World.IsClient` returns true for both thin and full clients.
+
+## Thin client workflow recommendations
+
+Thin clients can be used in a variety of ways to help test multiplayer games. We recommend the following:
+
+* Thin clients allow you to quickly test client flows, things like team assignment, spawn locations, leaderboards, UI etc.
+* Thin clients created in built players, allowing stress and soak testing of your game servers. For example, 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.
+* Thin clients controlled by a second input source. 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.
+
+## 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 clients don't work out of the box with `AutoCommandTarget`, because `AutoCommandTarget` requires the same ghost to exist on both the client and the server, and thin clients don't create ghosts. So you need to set up the `CommandTarget` component on the connection entity yourself.
+
+`IInputComponentData` is the 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, 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.**
+1. Set up 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.
+```
+1. On the server (where you spawn the actual character controller ghost for the thin client, 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/Editor/Authoring/GhostAuthoringComponentEditor.cs b/Editor/Authoring/GhostAuthoringComponentEditor.cs
index ec6c31d..f10f646 100644
--- a/Editor/Authoring/GhostAuthoringComponentEditor.cs
+++ b/Editor/Authoring/GhostAuthoringComponentEditor.cs
@@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using Unity.Collections;
using Unity.Entities.Conversion;
+using Unity.Mathematics;
+using Unity.NetCode.Hybrid;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
@@ -8,6 +12,7 @@
namespace Unity.NetCode.Editor
{
[CustomEditor(typeof(GhostAuthoringComponent))]
+ [CanEditMultipleObjects]
internal class GhostAuthoringComponentEditor : UnityEditor.Editor
{
SerializedProperty DefaultGhostMode;
@@ -19,12 +24,14 @@ internal class GhostAuthoringComponentEditor : UnityEditor.Editor
SerializedProperty GhostGroup;
SerializedProperty UsePreSerialization;
SerializedProperty Importance;
+ SerializedProperty MaxSendRate;
SerializedProperty PredictedSpawnedGhostRollbackToSpawnTick;
SerializedProperty RollbackPredictionOnStructuralChanges;
internal static Color brokenColor = new Color(1f, 0.56f, 0.54f);
internal static Color brokenColorUIToolkit = new Color(0.35f, 0.19f, 0.19f);
internal static Color brokenColorUIToolkitText = new Color(0.9f, 0.64f, 0.61f);
+ private static readonly GUILayoutOption s_HelperWidth = GUILayout.Width(180);
/// Aligned with NetCode for GameObjects.
public static Color netcodeColor => new Color(0.91f, 0.55f, 0.86f, 1f);
@@ -40,6 +47,7 @@ void OnEnable()
GhostGroup = serializedObject.FindProperty(nameof(GhostAuthoringComponent.GhostGroup));
UsePreSerialization = serializedObject.FindProperty(nameof(GhostAuthoringComponent.UsePreSerialization));
Importance = serializedObject.FindProperty(nameof(GhostAuthoringComponent.Importance));
+ MaxSendRate = serializedObject.FindProperty(nameof(GhostAuthoringComponent.MaxSendRate));
PredictedSpawnedGhostRollbackToSpawnTick = serializedObject.FindProperty(nameof(GhostAuthoringComponent.RollbackPredictedSpawnedGhostState));
RollbackPredictionOnStructuralChanges = serializedObject.FindProperty(nameof(GhostAuthoringComponent.RollbackPredictionOnStructuralChanges));
}
@@ -67,11 +75,54 @@ public override void OnInspectorGUI()
}
}
-
var originalColor = GUI.color;
-
GUI.color = originalColor;
- EditorGUILayout.PropertyField(Importance);
+ // Importance:
+ {
+ EditorGUILayout.BeginHorizontal();
+ var importanceContent = new GUIContent(nameof(Importance), GetImportanceFieldTooltip());
+ EditorGUILayout.PropertyField(Importance, importanceContent);
+ var editorImportanceSuggestion = ImportanceInlineTooltip(authoringComponent.Importance);
+ importanceContent.text = editorImportanceSuggestion.Name;
+ GUILayout.Box(importanceContent, s_HelperWidth);
+ EditorGUILayout.EndHorizontal();
+ }
+ // MaxSendRate:
+ {
+ var hasMaxSendRate = authoringComponent.MaxSendRate != 0;
+ EditorGUILayout.BeginHorizontal();
+ EditorGUILayout.PropertyField(MaxSendRate);
+ var globalConfig = NetCodeClientAndServerSettings.instance?.GlobalNetCodeConfig;
+ var tickRate = globalConfig != null ? globalConfig.ClientServerTickRate : new ClientServerTickRate();
+ tickRate.ResolveDefaults();
+ var clientTickRate = globalConfig != null ? NetCodeClientAndServerSettings.instance.GlobalNetCodeConfig.ClientTickRate : NetworkTimeSystem.DefaultClientTickRate;
+ var sendInterval = tickRate.CalculateNetworkSendIntervalOfGhostInTicks(authoringComponent.MaxSendRate);
+ var label = new GUIContent(SendRateInlineTooltip(), MaxSendRate.tooltip);
+ GUILayout.Box(label, s_HelperWidth);
+
+ string SendRateInlineTooltip() =>
+ (sendInterval, hasMaxSendRate) switch
+ {
+ (_, false) => "OFF | Every Snapshot",
+ (1, true) => "Every Snapshot",
+ (2, true) => "Every Other Snapshot",
+ (_, true) => $"Every {WithOrdinalSuffix(sendInterval)} Snapshot",
+ } + $" @ {tickRate.NetworkTickRate}Hz";
+
+ EditorGUILayout.EndHorizontal();
+
+ // MaxSendRate warning:
+ if (authoringComponent.SupportedGhostModes != GhostModeMask.Predicted)
+ {
+ var interpolationBufferWindowInTicks = clientTickRate.CalculateInterpolationBufferTimeInTicks(in tickRate);
+ var delta = sendInterval - interpolationBufferWindowInTicks;
+ if (delta > 0)
+ {
+ EditorGUILayout.HelpBox($"This ghost prefab is using a MaxSendRate value of {authoringComponent.MaxSendRate}, which leads to a maximum send interval of '{label.text}' i.e. every {sendInterval}ms, which is {delta} ticks longer than your maximum interpolation buffer window of {interpolationBufferWindowInTicks} ticks. You are therefore not replicating this ghost often enough to allow it to smoothly interpolate. To fix; either increase MaxSendRate, or increase the size of the interpolation buffer window globally.", MessageType.Warning);
+ }
+ }
+ }
+
EditorGUILayout.PropertyField(SupportedGhostModes);
var self = (GhostAuthoringComponent) target;
@@ -139,5 +190,46 @@ internal static bool IsPrefabEditable(GameObject go)
return true;
return !PrefabUtility.IsPartOfPrefabInstance(go);
}
+
+ internal string GetImportanceFieldTooltip()
+ {
+ var suggestions = NetCodeClientAndServerSettings.instance.CurrentImportanceSuggestions;
+ var s = Importance.tooltip;
+ foreach (var eis in suggestions)
+ {
+ var value = eis.MaxValue == uint.MaxValue || eis.MaxValue == eis.MinValue || eis.MaxValue == 0
+ ? $"~{eis.MinValue}" : $"{eis.MinValue} ~ {eis.MaxValue}";
+ s += $"\n\n {value} for {eis.Name}\n{eis.Tooltip}";
+ }
+ return s;
+ }
+
+ internal static EditorImportanceSuggestion ImportanceInlineTooltip(long importance)
+ {
+ var suggestions = NetCodeClientAndServerSettings.instance.CurrentImportanceSuggestions;
+ foreach (var eis in suggestions)
+ {
+ if (importance <= eis.MaxValue)
+ {
+ return eis;
+ }
+ }
+ return suggestions.LastOrDefault();
+ }
+
+ /// Adds the ordinal indicator/suffix to an integer.
+ internal static string WithOrdinalSuffix(long number)
+ {
+ // Numbers in the teens always end with "th".
+ if((number % 100 > 10 && number % 100 < 20))
+ return number + "th";
+ return (number % 10) switch
+ {
+ 1 => number + "st",
+ 2 => number + "nd",
+ 3 => number + "rd",
+ _ => number + "th",
+ };
+ }
}
}
diff --git a/Editor/Authoring/GhostAuthoringInspectionComponentEditor.cs b/Editor/Authoring/GhostAuthoringInspectionComponentEditor.cs
index d94ca1a..56959af 100644
--- a/Editor/Authoring/GhostAuthoringInspectionComponentEditor.cs
+++ b/Editor/Authoring/GhostAuthoringInspectionComponentEditor.cs
@@ -78,7 +78,7 @@ void OnUpdate()
}
}
- if (GhostAuthoringInspectionComponent.forceBake)
+ if (GhostAuthoringInspectionComponent.forceBake && !EditorGUIUtility.editingTextField)
BakeNetCodePrefab();
if (GhostAuthoringInspectionComponent.forceSave)
@@ -114,7 +114,7 @@ internal bool TryGetBakedResultAssociatedWithAuthoringGameObject(out BakedResult
return true;
}
- if (GhostAuthoringInspectionComponent.forceBake)
+ if (GhostAuthoringInspectionComponent.forceBake && !EditorGUIUtility.editingTextField)
{
BakeNetCodePrefab();
if (cachedBakedResults.TryGetValue(inspection, out result))
@@ -164,6 +164,7 @@ public override VisualElement CreateInspectorGUI()
m_Root.style.flexShrink = 1;
var ss = AssetDatabase.LoadAssetAtPath(Path.Combine(k_PackageId, "Editor/Authoring/GhostAuthoringEditor.uss"));
+ if (!ss) return m_Root;
m_Root.styleSheets.Add(ss);
m_BakeButton = new Button(HandleBakeButtonClicked);
diff --git a/Editor/Authoring/GhostComponentAnalytics.cs b/Editor/Authoring/GhostComponentAnalytics.cs
index e7ad470..9e6e366 100644
--- a/Editor/Authoring/GhostComponentAnalytics.cs
+++ b/Editor/Authoring/GhostComponentAnalytics.cs
@@ -378,8 +378,8 @@ static class GhostComponentAnalytics
public const string k_VendorKey = "unity.netcode";
public const string k_Scale = "NetcodeGhostComponentScale";
public const int k_ScaleVersion = 3;
- public const int k_ConfigurationVersion = 1;
public const string k_Configuration = "NetcodeGhostComponentConfiguration";
+ public const int k_ConfigurationVersion = 2;
///
/// This will add or update the buffer containing the configuration data from a .
@@ -395,6 +395,7 @@ public static void BufferConfigurationData(GhostAuthoringComponent ghostComponen
optimizationMode = ghostComponent.OptimizationMode.ToString(),
ghostMode = ghostComponent.DefaultGhostMode.ToString(),
importance = ghostComponent.Importance,
+ maxSendRateHz = ghostComponent.MaxSendRate,
variance = numVariants,
};
NetCodeAnalytics.StoreGhostComponent(analyticsData);
diff --git a/Editor/MultiplayerPlayModeWindow.cs b/Editor/MultiplayerPlayModeWindow.cs
index ff0a47e..1ea5e29 100644
--- a/Editor/MultiplayerPlayModeWindow.cs
+++ b/Editor/MultiplayerPlayModeWindow.cs
@@ -27,40 +27,62 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu
{
const string k_Title = "PlayMode Tools";
const int k_MaxWorldsToDisplay = 8;
- const int k_InitialThinClientWorldCreationInterval = 1;
- const int k_ThinClientWorldCreationFailureRetryInterval = 5;
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 Color s_Blue => new Color(0.5f, 0.84f, 0.99f); // TODO: netCode color into this view. GhostAuthoringComponentEditor.netcodeColor;
+ static Color s_Green => new Color(0.51f, 0.85f, 0.49f);
+ static Color s_Red => new Color(1f, 0.25f, 0.22f);
+ static Color s_Orange => new Color(1f, 0.68f, 0f);
+ static Color s_Pink => new Color(1f, .49f, 0.95f);
+
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 GUILayoutOption s_WorldNameWidth = GUILayout.Width(120);
static GUIContent s_TitleContent = new GUIContent(k_Title, "Netcode for Entities editor playmode tools. View and control world creation, connection status and flows etc.\n\nIt has no impact on builds.");
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 Editor playmode session. Has no impact on builds.\n\n\"Client & Server\" is recommended for most workflows.");
static GUIContent s_ServerEmulation = new GUIContent("Server Emulation", $"Denotes how the ServerWorld should load data when in PlayMode in the Editor. This setting does not affect builds (see {k_ProjectSettingsConfigPath} for build configuration).");
static GUIContent[] s_ServerEmulationContents;
- static GUIContent s_NumThinClients = new GUIContent("Num Thin Clients", "Thin clients are clients that receive snapshots, but do not attempt to process game logic. They can send arbitrary inputs though, and are useful to simulate opponents (to test connection & game logic).\n\nThin clients are instantiated on boot and at runtime. I.e. This value can be tweaked during playmode.");
- static GUIContent s_InstantiationFrequency = new GUIContent("Instantiation Frequency", "How many thin client worlds to instantiate per second. Runtime thin client instantiation can be disabled by setting `RuntimeThinClientWorldInitialization` to null. Does not affect thin clients created during boot.");
- static GUIContent s_RuntimeInstantiationDisabled = new GUIContent("Runtime Instantiation Disabled", "Enable it by setting `MultiplayerPlayModeWindow.RuntimeThinClientWorldInitialization`.");
+ static GUIContent s_NumThinClients = new GUIContent("Num Thin Clients", "Thin clients are clients that receive snapshots, but do not attempt to process game logic. They can send arbitrary inputs though, and are useful to simulate opponents (to test connection & game logic).\n\nThin clients are instantiated on boot and at runtime via the AutomaticThinClientWorldsUtility. I.e. This value can be tweaked during Play Mode.");
+ static GUIContent s_InstantiationFrequency = new GUIContent("Instantiation Frequency", "How many thin client worlds to instantiate per second (via the AutomaticThinClientWorldsUtility). Runtime thin client instantiation can be disabled by setting AutomaticThinClientWorldsUtility.RuntimeThinClientWorldInitialization to null.");
+ static GUIContent s_RuntimeInstantiationDisabled = new GUIContent("AutomaticThinClientWorldsUtility Disabled", "Enable it by setting the AutomaticThinClientWorldsUtility.RuntimeThinClientWorldInitialization delegate.");
+ static GUIContent s_Auto = new GUIContent("[Auto]", "Denotes that this world is managed by the AutomaticThinClientWorldsUtility.");
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("Client 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_SimulatorTitle = new GUIContent("Client 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 SimulatorUtility.Parameter into the NetworkSettings during driver 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_WebSocket = new GUIContent("[WebSocket]", "WebSocket\nThis World is using Unity's WebSocket NetworkInterface to communicate with the server.");
- static GUIContent s_UdpSocket = new GUIContent("[UDP]", "UDP | User Datagram Protocol\nThis World is using Unity's UDP socket NetworkInterface (formerly 'baselib') to communicate with the server.");
- static GUIContent s_Ipc = new GUIContent("[IPC]", "IPC | Intra-Process Communication\nThis World is using an IPC NetworkInterface to communicate with the server. IPC is an in-memory, socket-like wrapper, emulating the Transport API but without any OS overhead and unreliability.\n\nTherefore, IPC operations will be instantaneous, but can only be used to communicate with other NetworkDriver instances inside the same process (which is why IPC really means intra-process and not inter-process here). Useful for testing, or to implement a single player mode in a multiplayer game.");
+ static GUIContent s_DriverDisplayInfo = new GUIContent("", @"Denotes DriverStore driver instance information for this world, as well as the target NetworkEndpoint address (if applicable).
+
+IPC | Intra-Process Communication
+Unity's UTP (Unity Transport Package) IPCNetworkInterface implementation. IPC is an in-proc, in-memory, socket-like wrapper, emulating the Transport API, but without any OS overhead and unreliability. IPC operations are instantaneous, but can only be used to communicate with other NetworkDriver instances inside the same process (which is why IPC really means 'intra-process' and not 'inter-process' here).
+
+UDP | User Datagram Protocol
+Unity's UTP UDPNetworkInterface implementation (formerly 'baselib'). Unreliable by default (see UTP Pipelining).
+
+WebSocket
+Unity's UTP WebSocketNetworkInterface implementation.
+
+Custom
+Denotes a user-specified, custom INetworkInterface is being used.
+
+BoundOnly (Server-Only)
+Denotes that the driver bound successfully, but Listen either did not succeed, or was never invoked. Note: Client drivers do also call Bind when they call Connect, but this isn't currently displayed.
+
+Closed (Server-Only)
+Denotes that the server driver is closed i.e. not currently listening.
+");
static GUIContent s_PendingDc = new GUIContent("[Pending DC]", "You triggered a disconnect on this client. Waiting for said disconnect request to trigger transport driver change.");
- static GUIContent s_Awaiting = new GUIContent(string.Empty, "We must wait for the previous `NetworkStreamConnection` to be disposed, before we can connect this client to this address.");
- static GUIContent s_NetworkEmulation = new GUIContent(string.Empty, "Denotes whether or not this world uses Network Emulation with the above settings.");
- static GUIContent s_Unknown = new GUIContent("[No Connection Entity]", "No entity exists containing a `NetworkStreamConnection` component. Call `Connect` to create one.");
+ static GUIContent s_Awaiting = new GUIContent(string.Empty, "We must wait for the previous NetworkStreamConnection to be disposed, before we can connect this client to this address.");
+ static GUIContent s_LagSpikeOccuring = new GUIContent("[Lag Spike]", "Denotes that a lag spike is currently occuring. No packets are getting through.");
+ static GUIContent s_NetworkEmulation = new GUIContent(string.Empty, "Denotes whether or not this client uses Client Network Emulation with the above settings.");
+ static GUIContent s_NoNetworkConnectionEntity = new GUIContent("[No Connection Entity]", "No entity exists containing a NetworkStreamConnection component. Call Connect to create one.");
static GUIContent s_SimulatorView = new GUIContent(string.Empty, string.Empty);
const string s_SimulatorExplanation = "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 ";
@@ -90,11 +112,11 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu
new GUIContent("Client", "Only instantiate a client (with a configurable number of thin clients) that'll automatically attempt to connect to the listed address and port." + k_PlayModeTooltip),
new GUIContent("Server", "Only instantiate a server. Expects that clients will be instantiated in another process." + k_PlayModeTooltip),
};
- static GUILayoutOption s_DontExpandWidth = GUILayout.ExpandWidth(false);
- static GUIContent s_ServerName = new GUIContent("", "Name of server world.");
- static GUIContent s_ServerPort = new GUIContent("", "Listening Port");
- static GUIContent s_ServerPlayers = new GUIContent("", "Count of connected players. | Count of players who have registered as 'in-game' via the `NetworkStreamInGame` component, on the Server.");
+ static GUIContent s_WorldName = new GUIContent("", "The World.Name.");
+ static GUIContent s_NetworkId = new GUIContent("", "The NetworkId associated with this client. The server uses the reserved value 0.");
+ internal static GUIContent s_ServerStats = new GUIContent("", "Client Connections | Connections In-Game (via NetworkStreamInGame)");
static GUIContent s_ClientConnect = new GUIContent("", "Trigger all clients to disconnect from the server they're connected to and [re]connect to the specified address and port.");
+ static GUIContent s_ClientConnectionState = new GUIContent("", "Denotes the ConnectionState.State enum value for this NetworkStreamConnection.");
static GUIContent s_ServerDcAllClients = new GUIContent("DC All", "Trigger the server to attempt to gracefully disconnect all clients. Useful to batch-test a bunch of client disconnect scenarios (e.g. mid-game).");
static GUIContent s_ServerReconnectAllClients = new GUIContent("Reconnect All", "Trigger the server to attempt to gracefully disconnect all clients, then have them automatically reconnect. Useful to batch-test player rejoining scenarios (e.g. people dropping out mid-match).\n\nNote that clients will also disconnect themselves from the server in the same frame as they're attempting to reconnect, so you can test same frame DCing.");
static GUIContent s_ServerLogRelevancy = new GUIContent("Log Relevancy", "Log the current relevancy rules for this server. Useful to debug why a client is not receiving a specific ghost.");
@@ -105,43 +127,34 @@ internal class MultiplayerPlayModeWindow : EditorWindow, IHasCustomMenu
static GUIContent s_Timeout = new GUIContent("Force Timeout", "Simulate a timeout (i.e. the client and server stop communicating instantly, and critically, without either being able to send graceful disconnect control messages). A.k.a. An \"ungraceful\" disconnection or \"Server unreachable\".\n\n- Clients should notify the player of the internet issue, and provide automatic (or triggerable) reconnect or quit flows.\n\n - Servers should ensure they handle clients timing out as a valid form of disconnection, and (if supported) ensure that 'same client reconnections' are properly handled.\n\n - Transport settings will inform how quickly all parties detect a lost connection.");
static GUIContent s_LogFileLocation = new GUIContent("Open Log Folder", string.Empty);
- static GUIContent s_ForceLogLevel = new GUIContent("Force Log Settings", "Force all `NetDebug` loggers to a specified setting, clobbering any `NetCodeDebugConfig` singleton.");
- const string k_NetcodeNDebugTooltip = "\n\nDisable this functionality (and related CPU overhead) by defining `NETCODE_NDEBUG` in your project.";
- static GUIContent s_LogLevel = new GUIContent("Log Level", "Every `NetDebug` log is raised with a specific severity. Use this to discard logs below this severity level." + k_NetcodeNDebugTooltip);
- static GUIContent s_DumpPacketLogs = new GUIContent("Dump Packet Logs", "Denotes whether Netcode will dump packet logs to `NetDebug.LogFolderForPlatform`.\n\nIf 'Force Log Settings' is disabled, the editor will use whatever logging configuration values are already set." + k_NetcodeNDebugTooltip);
+ static GUIContent s_ForceLogLevel = new GUIContent("Force Log Settings", "Force all NetDebug loggers to a specified setting, clobbering any NetCodeDebugConfig singleton.");
+ const string k_NetcodeNDebugTooltip = "\n\nDisable this functionality (and related CPU overhead) by defining NETCODE_NDEBUG in your project.";
+ static GUIContent s_LogLevel = new GUIContent("Log Level", "Every NetDebug log is raised with a specific severity. Use this to discard logs below this severity level." + k_NetcodeNDebugTooltip);
+ static GUIContent s_DumpPacketLogs = new GUIContent("Dump Packet Logs", "Denotes whether Netcode will dump packet logs to NetDebug.LogFolderForPlatform.\n\nIf 'Force Log Settings' is disabled, the editor will use whatever logging configuration values are already set." + k_NetcodeNDebugTooltip);
static GUIContent s_LagSpike = new GUIContent("", "In playmode, press the shortcut key to toggle 'total packet loss' for the specified duration.\n\nUseful when testing short periods of lost connection (e.g. while in a tunnel) and to see how well your client and server handle an \"ungraceful\" disconnect (e.g. internet going down).\n\n- This window must be open for this tool to work.\n- Will only be applied to the \"full\" (i.e.: rendering) clients.\n- Depending on timeouts specified, this may cause the actual driver to timeout. Ensure you handle reconnections.");
+ static GUIContent s_WarnBatchedTicks = new GUIContent("Warn When Ticks Batched", "Display a warning in the console when network ticks are batched.\nThis can be useful for tracking down if 'stuttering' issues are linked to tick batching.\n\nBatching occurs when the server is unable to process enough 'network ticks' to keep up with the simulation rate. If a network tick represent say 33ms of time but takes 66ms to process the server will fall behind. It now needs to make up a frame so next update it will simulate 2 ticks, one for the current time slice and one to make up for the lost time, if these two frames can't be processed in time then a spiral will occur where each 'tick' requires more and more 'network ticks' to keep up. This is mitigated via batching, instead of calculating multiple ticks, ticks are batched by increasing the length of a 'network tick' to simulate enough time to keep pace with the desired update rate (66ms in this case to capture two 33ms updates). While effective as curbing runaway performance issues this can cause many interpolation issues - expect client input loss, and a reduction in gameplay, physics, prediction and interpolation quality.");
+ static GUIContent s_WarnBatchedTicksRollingWindow = new GUIContent("Rolling Avg Window Size", "Specifies the number of frames the average is calculated over.");
+ static GUIContent s_WarnAboveAverageBatchedTicksPerFrame = new GUIContent("Above Ticks Per Frame", "Sets the minimum number of batched ticks per frame for displaying the warning. A value of 1 will display a warning for every batched tick.");
+
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 ulong s_LastNextSequenceNumber;
- static float s_SecondsTillCanCreateThinClient;
- static bool s_UserIsInteractingWithMenu;
- static TimeSpan s_RepaintDelayTimeSpan = TimeSpan.FromSeconds(1);
-
static GUIStyle s_BoxStyleHack;
+ static GUILayoutOption s_RightButtonWidth = GUILayout.Width(120);
+ static DateTime s_LastWrittenUtc;
static DateTime s_LastRepaintedUtc;
- static bool s_ForceRepaint;
+ static bool s_ShouldUpdateStatusTexts;
+ public static bool s_ForceRepaint;
Vector2 m_WorldScrollPosition;
- bool m_DidRepaint;
+ int m_PreviousFrameCount;
- ///
- public delegate bool RuntimeThinClientWorldInitializationDelegate(World world);
public delegate void SimulatorPresetsSelectionDelegate(out string presetGroupName, List appendPresets);
- ///
- /// If your thin clients need custom initialization due to scene management settings, modify this delegate.
- /// Set to null to disable the runtime ThinClient feature.
- ///
- public static RuntimeThinClientWorldInitializationDelegate RuntimeThinClientWorldInitialization = DefaultRuntimeThinClientWorldInitialization;
-
/// If your team would prefer to use other Simulator Presets, override this.
/// Defaults to:
public static SimulatorPresetsSelectionDelegate InUseSimulatorPresets = SimulatorPreset.DefaultInUseSimulatorPresets;
- static GUILayoutOption s_RightButtonWidth = GUILayout.Width(120);
- private int m_PreviousFrameCount;
-
[MenuItem("Window/Multiplayer/PlayMode Tools", priority = 3007)]
private static void ShowWindow()
{
@@ -199,31 +212,33 @@ void PlayModeStateChanged(PlayModeStateChange playModeStateChange)
if (playModeStateChange == PlayModeStateChange.EnteredPlayMode)
EditorApplication.update += PlayModeUpdate;
- s_SecondsTillCanCreateThinClient = k_InitialThinClientWorldCreationInterval;
-
PlayModeUpdate();
Repaint();
}
void PlayModeUpdate()
{
- UpdateNumThinClientWorlds();
+ var didCreateOrDestroyWorlds = AutomaticThinClientWorldsUtility.UpdateAutomaticThinClientWorlds();
+ s_ForceRepaint |= didCreateOrDestroyWorlds;
var utcNow = DateTime.UtcNow;
// Don't repaint if not playing, except when we tick.
- var frameCountChanged = false;
+ var frameCountChangedWhilePaused = false;
+ var hitRepaintTimerWhileResumed = false;
if (EditorApplication.isPaused)
{
var frameCount = Time.frameCount;
- frameCountChanged = frameCount != m_PreviousFrameCount;
+ frameCountChangedWhilePaused = frameCount != m_PreviousFrameCount;
m_PreviousFrameCount = frameCount;
}
- m_DidRepaint = utcNow - s_LastRepaintedUtc >= s_RepaintDelayTimeSpan && (!EditorApplication.isPaused || frameCountChanged || s_ForceRepaint);
- if (m_DidRepaint)
+ else
+ {
+ hitRepaintTimerWhileResumed = utcNow - s_LastRepaintedUtc >= TimeSpan.FromSeconds(1);
+ }
+
+ if (hitRepaintTimerWhileResumed || frameCountChangedWhilePaused || s_ForceRepaint)
{
s_ForceRepaint = false;
- s_LastRepaintedUtc = utcNow;
- s_UserIsInteractingWithMenu = false;
Repaint();
}
}
@@ -235,85 +250,10 @@ static void ToggleLagSpikeSimulatorShortcut()
{
var system = ClientServerBootstrap.ClientWorld.GetExistingSystemManaged();
system.ToggleLagSpikeSimulator();
- ForceRepaint();
+ s_ForceRepaint = true;
}
}
- /// 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 = 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.");
- return false;
- }
-
- using var serverWorldScenesQuery = worldToCopyFrom.EntityManager.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly());
- var serverWorldScenes = serverWorldScenesQuery.ToComponentDataArray(Allocator.Temp);
- for (int i = 0; i < serverWorldScenes.Length; i++)
- {
- var desiredGoSceneReferenceGuid = serverWorldScenes[i];
- SceneSystem.LoadSceneAsync(newThinClientWorld.Unmanaged,
- desiredGoSceneReferenceGuid.SceneGUID,
- new SceneSystem.LoadParameters
- {
- Flags = SceneLoadFlags.BlockOnImport | SceneLoadFlags.BlockOnStreamIn,
- AutoLoad = true,
- });
- }
-
- return true;
- }
-
-
- /// Will Create or Dispose thin client worlds until the final count is equal to .
- void UpdateNumThinClientWorlds()
- {
- if (Prefs.RequestedPlayType == ClientServerBootstrap.PlayType.Server || !EditorApplication.isPlaying || EditorApplication.isCompiling || EditorApplication.isPaused) return;
-
- s_SecondsTillCanCreateThinClient -= Time.deltaTime;
-
- var requestedNumThinClients = MultiplayerPlayModePreferences.RequestedNumThinClients;
-
- // Dispose if too many:
- while(ClientServerBootstrap.ThinClientWorlds.Count > requestedNumThinClients)
- {
- var index = ClientServerBootstrap.ThinClientWorlds.Count - 1;
- var world = ClientServerBootstrap.ThinClientWorlds[index];
- if (world.IsCreated)
- world.Dispose();
- ForceRepaint();
- }
-
- // Create new:
- var hasServerOrClient = ClientServerBootstrap.ServerWorld != null || ClientServerBootstrap.ClientWorld != null;
- if (hasServerOrClient && !s_UserIsInteractingWithMenu)
- {
- for(var i = ClientServerBootstrap.ThinClientWorlds.Count; i < requestedNumThinClients && s_SecondsTillCanCreateThinClient <= 0; i++)
- {
- var thinClientWorld = ClientServerBootstrap.CreateThinClientWorld();
- ForceRepaint();
-
- var success = RuntimeThinClientWorldInitialization(thinClientWorld);
-
- if (MultiplayerPlayModePreferences.ThinClientCreationFrequency > 0)
- s_SecondsTillCanCreateThinClient = 1f / MultiplayerPlayModePreferences.ThinClientCreationFrequency;
-
- if (!success)
- {
- s_SecondsTillCanCreateThinClient = math.max(s_SecondsTillCanCreateThinClient, k_ThinClientWorldCreationFailureRetryInterval);
- return;
- }
- }
- }
- }
-
- internal static void ForceRepaint()
- {
- s_ForceRepaint = true;
- }
-
// This interface implementation is automatically called by Unity.
void IHasCustomMenu.AddItemsToMenu(GenericMenu menu)
{
@@ -324,12 +264,17 @@ static void ToggleShowingAllSimulatorPresets()
InUseSimulatorPresets = SimulatorPreset.DefaultInUseSimulatorPresets;
Prefs.ShowAllSimulatorPresets ^= true;
RefreshSimulatorPresets();
- ForceRepaint();
+ s_ForceRepaint = true;
}
}
void OnGUI()
{
+ var utcNow = DateTime.UtcNow;
+ s_LastRepaintedUtc = utcNow;
+ s_ShouldUpdateStatusTexts = (utcNow - s_LastWrittenUtc) >= TimeSpan.FromSeconds(.98f) || s_ForceRepaint;
+ if (s_ShouldUpdateStatusTexts) s_LastWrittenUtc = utcNow;
+
HackFixBoxStyle();
HandleWindowProperties();
@@ -443,11 +388,7 @@ void HandleWindowProperties()
{
// Window:
minSize = new Vector2(600, 210);
- maxSize = new Vector2(600, maxSize.y);
-
- // Avoid creating new thin clients while the user is interacting with the menu.
- var e = Event.current;
- s_UserIsInteractingWithMenu |= e.type == EventType.MouseDrag || e.type == EventType.MouseDown;
+ maxSize = new Vector2(1200, maxSize.y);
}
static void DrawClientAutoConnect()
@@ -492,7 +433,11 @@ static void DrawClientAutoConnect()
// Notifying of code vs editor overrides:
if (EditorApplication.isPlaying)
{
- if (!ClientServerBootstrap.WillServerAutoListen)
+ if (!ClientServerBootstrap.DetermineIfBootstrappingEnabled())
+ {
+ EditorGUILayout.HelpBox("Bootstrapping is disabled for this project or scene. I.e. Waiting for you to create netcode worlds yourself, which will then appear here.", MessageType.Warning);
+ }
+ else if (!ClientServerBootstrap.WillServerAutoListen)
{
var anyConnected = ClientServerBootstrap.ServerWorlds.Any(x => x.IsCreated && x.GetExistingSystemManaged().IsListening)
|| ClientServerBootstrap.ClientWorlds.Concat(ClientServerBootstrap.ThinClientWorlds).Any(x => x.IsCreated && x.GetExistingSystemManaged().ClientConnectionState != ConnectionState.State.Disconnected);
@@ -519,32 +464,24 @@ static void DrawClientAutoConnect()
static void DrawThinClientSelector()
{
- if (Prefs.RequestedPlayType == ClientServerBootstrap.PlayType.Server)
- return;
-
GUI.color = Color.white;
GUILayout.BeginHorizontal();
{
- GUI.enabled = !EditorApplication.isPlaying || RuntimeThinClientWorldInitialization != null;
+ // Thin clients are only enabled if the delegates are hooked up.
+ // As there are two types (bootstap vs runtime), if we're not in Play Mode,
+ // we can check if either are present.
+ GUI.enabled = EditorApplication.isPlaying
+ ? AutomaticThinClientWorldsUtility.IsRuntimeInitializationEnabled
+ : AutomaticThinClientWorldsUtility.IsBootstrapInitializationEnabled || AutomaticThinClientWorldsUtility.IsRuntimeInitializationEnabled;
Prefs.RequestedNumThinClients = EditorGUILayout.IntField(s_NumThinClients, Prefs.RequestedNumThinClients);
-
+ Prefs.ThinClientCreationFrequency = EditorGUILayout.FloatField(s_InstantiationFrequency, Prefs.ThinClientCreationFrequency);
GUI.enabled = true;
-
- if(RuntimeThinClientWorldInitialization != null)
- Prefs.ThinClientCreationFrequency = EditorGUILayout.FloatField(s_InstantiationFrequency, Prefs.ThinClientCreationFrequency);
- else
- {
- GUI.enabled = false;
- GUILayout.Box(s_RuntimeInstantiationDisabled, s_BoxStyleHack);
- 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);
+ EditorGUILayout.HelpBox("Enabling many in-process thin clients will slow down 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()
@@ -595,7 +532,7 @@ void DrawSimulator()
// Simulator Toggle:
{
EditorGUI.BeginChangeCheck();
- GUI.color = Prefs.SimulatorEnabled ? ActiveColor : Color.white;
+ GUI.color = Prefs.SimulatorEnabled ? s_Blue : Color.white;
var wasSimulatorEnabled = Prefs.SimulatorEnabled;
Prefs.SimulatorEnabled = EditorGUILayout.Toggle(s_SimulatorTitle, wasSimulatorEnabled);
if (EditorGUI.EndChangeCheck())
@@ -752,15 +689,16 @@ void DrawSimulator()
DrawSeparator();
var firstClient = ClientServerBootstrap.ClientWorld ?? ClientServerBootstrap.ThinClientWorlds?.FirstOrDefault();
- var connSystem = firstClient?.GetExistingSystemManaged();
+ var connSystem = firstClient != null && firstClient.IsCreated ? firstClient.GetExistingSystemManaged() : null;
GUILayout.BeginHorizontal();
{
- var keyBinding = UnityEditor.ShortcutManagement.ShortcutManager.instance.GetShortcutBinding(k_ToggleLagSpikeSimulatorBindingKey);
-
- s_LagSpike.text = $"Lag Spike Simulator [{keyBinding.ToString()}]";
+ var keyBinding = UnityEditor.ShortcutManagement.ShortcutManager.instance.GetShortcutBinding(k_ToggleLagSpikeSimulatorBindingKey).ToString();
+ if (string.IsNullOrWhiteSpace(keyBinding))
+ keyBinding = "no shortcut";
+ s_LagSpike.text = $"Lag Spike Simulator [{keyBinding}]";
var isSimulatingLagSpike = connSystem != null && connSystem.IsSimulatingLagSpike;
- GUI.color = isSimulatingLagSpike ? GhostAuthoringComponentEditor.brokenColor : ActiveColor;
+ GUI.color = isSimulatingLagSpike ? GhostAuthoringComponentEditor.brokenColor : s_Blue;
GUILayout.Label(s_LagSpike);
GUILayout.FlexibleSpace();
@@ -786,6 +724,7 @@ void DrawClientWorld(World world)
if (world == default || !world.IsCreated) return;
var conSystem = world.GetExistingSystemManaged();
+ if (s_ShouldUpdateStatusTexts) conSystem.UpdateStatusText();
var isConnected = conSystem.ClientConnectionState == ConnectionState.State.Connected;
var isHandshakeOrApproval = conSystem.NetworkStreamConnection.IsHandshakeOrApproval;
@@ -793,49 +732,36 @@ void DrawClientWorld(World world)
GUILayout.BeginHorizontal();
{
GUI.color = connectionColor;
- GUILayout.Box(isConnected && !isHandshakeOrApproval ? conSystem.NetworkId.Value.ToString() : "-", s_BoxStyleHack, s_NetworkIdWidth);
+ s_NetworkId.text = isConnected && !isHandshakeOrApproval ? conSystem.NetworkId.Value.ToString() : "-";
+ GUILayout.Box(s_NetworkId, s_BoxStyleHack, s_NetworkIdWidth);
- GUILayout.Label(world.Name, s_WorldNameWidth);
+ s_WorldName.text = world.Name;
+ GUILayout.Label(s_WorldName, s_WorldNameWidth);
GUI.color = Color.white;
- if(conSystem.IsUsingIpc)
- GUILayout.Label(s_Ipc);
- if (conSystem.IsUsingSocket)
+ DrawDriverDisplayInfo(ref conSystem.DriverInfos, conSystem.NetworkStreamConnection);
+
+ if (conSystem.IsSimulatingLagSpike)
{
- GUILayout.Label(conSystem.IsUsingWebSocket ? s_WebSocket : s_UdpSocket);
+ GUI.color = GhostAuthoringComponentEditor.brokenColor;
+ GUILayout.Label(s_LagSpikeOccuring);
}
- switch (conSystem.SocketFamily)
+ GUI.color = connectionColor;
+ if (conSystem.ClientConnectionState == ConnectionState.State.Unknown)
{
- case NetworkFamily.Invalid:
- break;
- case NetworkFamily.Ipv4:
- GUILayout.Label("[IPv4]");
- break;
- case NetworkFamily.Ipv6:
- GUILayout.Label("[IPv6]");
- break;
- case NetworkFamily.Custom:
- GUILayout.Label("[Custom]");
- break;
- default:
- throw new ArgumentOutOfRangeException();
+ GUILayout.Label(s_NoNetworkConnectionEntity);
}
-
- if (conSystem.IsSimulatingLagSpike)
+ else
{
- GUI.color = GhostAuthoringComponentEditor.brokenColor;
- GUILayout.Label("[Lag Spike]");
+ s_ClientConnectionState.text = $"[{conSystem.ClientConnectionState.ToString()}]";
+ GUILayout.Label(s_ClientConnectionState);
}
- s_NetworkEmulation.text = conSystem.IsAnyUsingSimulator ? "[Using Network Emulation]" : "[No Emulation]";
- GUILayout.Label(s_NetworkEmulation);
-
- GUI.color = connectionColor;
- if(conSystem.LastEndpoint != default)
- GUILayout.Label($"[{conSystem.LastEndpoint}]");
- if(conSystem.ClientConnectionState == ConnectionState.State.Unknown)
- GUILayout.Label(s_Unknown);
- else GUILayout.Label($"[{conSystem.ClientConnectionState.ToString()}]");
+ if (world.IsThinClient() && AutomaticThinClientWorldsUtility.AutomaticallyManagedWorlds.Contains(world))
+ {
+ GUI.color = s_Blue;
+ GUILayout.Label(s_Auto);
+ }
if (conSystem.DisconnectPending)
{
@@ -888,8 +814,6 @@ void DrawClientWorld(World world)
conSystem.ToggleTimeoutSimulation();
GUI.color = connectionColor;
- if (m_DidRepaint)
- conSystem.UpdatePingText();
GUILayout.Box(conSystem.PingText, s_BoxStyleHack, s_PingWidth);
EditorGUILayout.EndHorizontal();
@@ -905,47 +829,45 @@ private static Color GetConnectionStateColor(ConnectionState.State state)
case ConnectionState.State.Unknown:
return GhostAuthoringComponentEditor.brokenColor;
case ConnectionState.State.Disconnected:
- return new Color(1f, 0.25f, 0.22f);
+ return s_Red;
case ConnectionState.State.Connecting:
return Color.yellow;
case ConnectionState.State.Handshake:
- return new Color(1f, 0.68f, 0f);
+ return s_Orange;
case ConnectionState.State.Approval:
- return new Color(1f, .49f, 0.95f);
+ return s_Pink;
case ConnectionState.State.Connected:
- return ActiveColor;
+ return s_Blue;
default: throw new NotImplementedException(state.ToString());
}
}
- static void DrawServerWorld(World serverWorld)
+ void DrawServerWorld(World serverWorld)
{
if (serverWorld == default || !serverWorld.IsCreated) return;
var conSystem = serverWorld.GetExistingSystemManaged();
+ if (s_ShouldUpdateStatusTexts) conSystem.UpdateStatusText();
+ var connectingColor = conSystem.IsListening ? s_Green : GhostAuthoringComponentEditor.brokenColor;
GUILayout.BeginHorizontal();
{
- GUI.color = Color.white;
- s_ServerName.text = serverWorld.Name;
- EditorGUILayout.LabelField(s_ServerName, s_WorldNameWidth);
+ GUILayout.BeginVertical();
+ GUILayout.BeginHorizontal();
+ GUI.color = connectingColor;
+ s_NetworkId.text = "-";
+ GUILayout.Box(s_NetworkId, s_BoxStyleHack, s_NetworkIdWidth);
- if (conSystem.IsListening)
- {
- s_ServerPort.text = $"[{conSystem.LastEndpoint.Address}]";
- GUILayout.Label(s_ServerPort, s_DontExpandWidth);
+ s_WorldName.text = serverWorld.Name;
+ EditorGUILayout.LabelField(s_WorldName, s_WorldNameWidth);
- GUILayout.Label("[Listening]");
- }
- else GUILayout.Label("[Not Listening]");
+ DrawDriverDisplayInfo(ref conSystem.DriverInfos, null);
- var numConnections = conSystem.NumActiveConnections;
- var numInGame = conSystem.NumActiveConnectionsInGame;
- GUI.color = numConnections > 0 ? ActiveColor : Color.white;
- s_ServerPlayers.text = $"[{numConnections} Connected | {numInGame} In Game]";
- GUILayout.Label(s_ServerPlayers, s_DontExpandWidth);
+ GUILayout.FlexibleSpace();
- GUI.color = Color.white;
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+ GUI.color = Color.white;
GUILayout.EndHorizontal();
}
@@ -964,6 +886,7 @@ static void DrawServerWorld(World serverWorld)
foreach (var clientWorld in ClientServerBootstrap.ClientWorlds.Concat(ClientServerBootstrap.ThinClientWorlds))
{
+ if (!clientWorld.IsCreated) continue;
var connSystem = clientWorld.GetExistingSystemManaged();
Prefs.IsEditorInputtedAddressValidForConnect(out var ep);
connSystem.ChangeStateImmediate(connSystem.LastEndpoint ?? ep);
@@ -979,7 +902,11 @@ static void DrawServerWorld(World serverWorld)
LogCommandStats(serverWorld);
}
- GUILayout.FlexibleSpace();
+ GUILayout.EndVertical();
+ GUILayout.EndHorizontal();
+
+ GUI.color = connectingColor;
+ GUILayout.Box(s_ServerStats, s_BoxStyleHack, s_PingWidth);
}
GUILayout.EndHorizontal();
@@ -990,7 +917,8 @@ private static void DrawConnectionEvents(List connection
{
if (connectionEvents.Count == 0)
return;
- GUI.color = new Color(0.51f, 0.85f, 0.49f);
+
+ GUI.color = s_Green;
FixedString4096Bytes s = "";
for (int i = 0; i < connectionEvents.Count; i++)
{
@@ -1017,6 +945,60 @@ private static void DrawConnectionEvents(List connection
GUILayout.Label(s.ToString(), EditorStyles.wordWrappedLabel);
}
+ private static void DrawDriverDisplayInfo(ref FixedList512Bytes displayInfos, NetworkStreamConnection? clientConnection)
+ {
+ for (int i = 0; i < displayInfos.Length; i++)
+ {
+ ref var ddi = ref displayInfos.ElementAt(i);
+ const char separator = ':';
+ FixedString32Bytes text = default;
+
+ // Family & TransportType:
+ var type = ddi.TransportType switch
+ {
+ TransportType.IPC => "IPC",
+ TransportType.Socket => "UDP", // We assume UDP!
+ TransportType.Invalid => "Invalid",
+ _ => throw new NotImplementedException(ddi.TransportType.ToString()),
+ };
+ var family = ddi.NetworkFamily switch
+ {
+ // TODO - Transport does not reset the Bound field when disconnecting a client,
+ // so don't display that here as it's misleading.
+ NetworkFamily.Invalid => clientConnection.HasValue ? type : $"{type}{separator}{(ddi.Bound ? "BoundOnly" : "Closed")}",
+ NetworkFamily.Ipv4 => type,
+ NetworkFamily.Ipv6 => type,
+ NetworkFamily.Custom => ddi.IsWebSocket ? "WebSocket" : "Custom",
+ _ => throw new NotImplementedException(ddi.NetworkFamily.ToString()),
+ };
+ text += family;
+
+ // Address:
+ string address = null;
+ if (ddi.Endpoint.IsValid) address += $"{separator}{ddi.Endpoint.Address}";
+
+ GUI.color = (type: ddi.TransportType, family: ddi.NetworkFamily, isWebSocket: ddi.IsWebSocket) switch
+ {
+ (TransportType.IPC, _, _) => s_Green,
+ (_, _, true) => Color.yellow,
+ (_, NetworkFamily.Custom, false) => s_Orange,
+ (_, NetworkFamily.Ipv4, _) => s_Blue,
+ (_, NetworkFamily.Ipv6, _) => s_Pink,
+ _ => GhostAuthoringComponentEditor.brokenColor,
+ };
+ s_DriverDisplayInfo.text = $"[{text}{address}]";
+ GUILayout.Label(s_DriverDisplayInfo);
+
+ // Emulation:
+ if (clientConnection.HasValue)
+ {
+ GUI.color = Color.white;
+ s_NetworkEmulation.text = ddi.SimulatorEnabled ? "[Emulation Enabled]" : "[No Emulation]";
+ GUILayout.Label(s_NetworkEmulation);
+ }
+ }
+ }
+
static string EditorPopup(GUIContent content, GUIContent[] list, string value)
{
var index = 0;
@@ -1065,7 +1047,7 @@ static void HandleSimulatorValuesChanged(bool isUsingCustomValues)
void DrawLoggingGroup()
{
GUILayout.BeginHorizontal();
- GUI.color = Prefs.ApplyLoggerSettings ? ActiveColor : Color.white;
+ GUI.color = Prefs.ApplyLoggerSettings ? s_Blue : Color.white;
Prefs.ApplyLoggerSettings = EditorGUILayout.Toggle(s_ForceLogLevel, Prefs.ApplyLoggerSettings);
if (!Prefs.ApplyLoggerSettings)
DrawLogFileLocationButton();
@@ -1091,6 +1073,20 @@ void DrawLoggingGroup()
EditorGUILayout.HelpBox("`NETCODE_NDEBUG` is currently defined, so netcode packet dump functionality (and related CPU overhead) is removed.", MessageType.Info);
#endif
+ DrawSeparator();
+
+ GUILayout.BeginHorizontal();
+ Prefs.WarnBatchedTicks = EditorGUILayout.Toggle(s_WarnBatchedTicks, Prefs.WarnBatchedTicks);
+ GUILayout.EndHorizontal();
+
+ if (Prefs.WarnBatchedTicks)
+ {
+ GUILayout.BeginHorizontal();
+ Prefs.WarnBatchedTicksRollingWindow = EditorGUILayout.IntField(s_WarnBatchedTicksRollingWindow, Prefs.WarnBatchedTicksRollingWindow);
+ Prefs.WarnAboveAverageBatchedTicksPerFrame = EditorGUILayout.FloatField(s_WarnAboveAverageBatchedTicksPerFrame, Prefs.WarnAboveAverageBatchedTicksPerFrame);
+ GUILayout.EndHorizontal();
+ }
+
static void DrawLogFileLocationButton()
{
GUI.enabled = true;
@@ -1117,7 +1113,7 @@ void DrawDebugGizmosDrawer()
visitor.DetailsVisible = EditorGUILayout.BeginFoldoutHeaderGroup(visitor.DetailsVisible, visitor.Name);
GUI.enabled = true;
- GUI.color = visitor.Enabled ? ActiveColor : Color.grey;
+ GUI.color = visitor.Enabled ? s_Blue : Color.grey;
if (GUILayout.Button(visitor.Enabled ? "Drawing" : "Disabled", s_RightButtonWidth))
visitor.Enabled ^= true;
}
@@ -1182,7 +1178,7 @@ static void LogCommandStats(World serverWorld)
for (var i = 0; i < networkSnapshotAcks.Length; i++)
{
var ack = networkSnapshotAcks[i];
- message += $"\n- Client {networkIds[i].ToFixedString()} with ping {(int)ack.EstimatedRTT}±{(int)ack.DeviationRTT} has {ack.CommandArrivalStatistics.ToFixedString()}";
+ message += $"\n- Client {networkIds[i].ToFixedString()} with ping {(int)ack.EstimatedRTT}±{(int)ack.DeviationRTT} has {ack.CommandArrivalStatistics.ToFixedString()} and {ack.SnapshotPacketLoss.ToFixedString()}";
}
Debug.Log(message);
}
@@ -1215,7 +1211,7 @@ static void ServerDisconnectNetworkId(MultiplayerClientPlayModeConnectionSystem
foreach (var serverWorld in ClientServerBootstrap.ServerWorlds)
{
serverWorld.GetExistingSystemManaged().TryDisconnectImmediate(connSystem.NetworkId);
- GetNetDbgForWorld(serverWorld).DebugLog($"{serverWorld.Name} triggered '{nameof(ServerDisconnectNetworkId)}' on NetworkId '{connSystem.NetworkId.Value}' via {nameof(MultiplayerPlayModeWindow)}!");
+ GetNetDbgForWorld(serverWorld).DebugLog($"{serverWorld.Name} triggered `{nameof(ServerDisconnectNetworkId)}` on NetworkId `{connSystem.NetworkId.Value}` via `{nameof(MultiplayerPlayModeWindow)}`!");
connSystem.DisconnectPending = true;
}
}
@@ -1226,23 +1222,23 @@ static void ServerDisconnectNetworkId(MultiplayerClientPlayModeConnectionSystem
internal partial class MultiplayerClientPlayModeConnectionSystem : SystemBase
{
internal GUIContent PingText = new GUIContent();
- internal NetworkStreamConnection NetworkStreamConnection;
+ internal NetworkStreamConnection NetworkStreamConnection { get; private set; }
internal ConnectionState.State ClientConnectionState => NetworkStreamConnection.CurrentState;
+ internal GhostCount GhostCount { get; private set; }
internal NetworkSnapshotAck ClientNetworkSnapshotAck;
internal NetworkId NetworkId;
public bool UpdateSimulator;
public bool DisconnectPending;
+ public FixedList512Bytes DriverInfos;
public bool IsAnyUsingSimulator {get; private set;}
public List ConnectionEventsForTick { get; } = new(4);
public NetworkEndpoint? LastEndpoint {get; private set;}
public NetworkEndpoint? TargetEp {get; private set;}
- internal bool IsUsingIpc { get; private set; }
- internal bool IsUsingWebSocket { get; private set; }
- internal bool IsUsingSocket { get; private set; }
+ internal TransportType SocketType { get; private set; }
internal NetworkFamily SocketFamily { get; private set; }
internal int LagSpikeMillisecondsLeft { get; private set; } = -1;
@@ -1251,9 +1247,12 @@ internal partial class MultiplayerClientPlayModeConnectionSystem : SystemBase
internal bool IsSimulatingTimeout => TimeoutSimulationDurationSeconds >= 0;
internal bool IsSimulatingLagSpike => LagSpikeMillisecondsLeft >= 0;
+ EntityQuery m_PredictedGhostsQuery;
+
protected override void OnCreate()
{
- UpdatePingText();
+ m_PredictedGhostsQuery = GetEntityQuery(ComponentType.ReadOnly());
+ UpdateStatusText();
}
protected override void OnUpdate()
@@ -1276,14 +1275,21 @@ protected override void OnUpdate()
LagSpikeMillisecondsLeft = -1;
UpdateSimulator = true;
netDebug.DebugLog("Lag Spike Simulator: Finished dropping packets!");
- MultiplayerPlayModeWindow.ForceRepaint();
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
}
}
+ var lastState = ClientConnectionState;
+ NetworkStreamConnection = SystemAPI.TryGetSingleton(out NetworkStreamConnection conn) ? conn : default;
+ GhostCount = SystemAPI.TryGetSingleton(out GhostCount ghostCount) ? ghostCount : default;
+
+ DriverInfos.Length = 0;
var hasNetworkStreamDriver = SystemAPI.TryGetSingletonRW(out var netStream);
if (hasNetworkStreamDriver)
{
- ref var driverStore = ref netStream.ValueRO.DriverStore;
+ ref var driverStore = ref netStream.ValueRO.DriverStore;
+ DriverDisplayInfo.Read(ref driverStore, ref DriverInfos, NetworkStreamConnection.Value);
+
LastEndpoint = netStream.ValueRO.LastEndPoint;
IsAnyUsingSimulator = driverStore.IsAnyUsingSimulator;
ConnectionEventsForTick.Clear();
@@ -1292,39 +1298,13 @@ protected override void OnUpdate()
if (netStream.ValueRO.ConnectionEventsForTick.Length > 0)
{
ConnectionEventsForTick.AddRange(netStream.ValueRO.ConnectionEventsForTick);
- MultiplayerPlayModeWindow.ForceRepaint();
- }
- }
- for (int i = driverStore.FirstDriver; i < driverStore.LastDriver; i++)
- {
- switch (driverStore.GetDriverType(i))
- {
- case TransportType.IPC:
- IsUsingIpc = true;
- break;
- case TransportType.Socket:
- IsUsingSocket = true;
- // TODO - Fetch as readonly when inner methods are marked as readonly (to prevent copy).
- SocketFamily = driverStore.GetDriverRW(i).GetLocalEndpoint().Family;
-
- // todo: Fetch the NetworkInterface from the driver directly, by Type name, to future proof this.
-#if UNITY_WEBGL
- IsUsingWebSocket = true;
-#else
- IsUsingWebSocket = false;
-#endif
- break;
- default:
- netDebug.LogError($"{World.Name} has unknown or invalid driver type passed into DriverStore!");
- break;
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
}
}
}
- var lastState = ClientConnectionState;
- NetworkStreamConnection = SystemAPI.TryGetSingleton(out NetworkStreamConnection conn) ? conn : default;
if (ClientConnectionState != lastState)
- MultiplayerPlayModeWindow.ForceRepaint();
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
if (ClientConnectionState != ConnectionState.State.Disconnected && SystemAPI.TryGetSingletonEntity(out var singletonEntity) && EntityManager.HasComponent(singletonEntity))
{
@@ -1354,14 +1334,29 @@ protected override void OnUpdate()
ChangeStateImmediate(TargetEp);
}
- internal void UpdatePingText()
+ internal void UpdateStatusText()
{
if (ClientConnectionState == ConnectionState.State.Connected)
{
var estimatedRTT = (int) ClientNetworkSnapshotAck.EstimatedRTT;
var deviationRTT = (int) ClientNetworkSnapshotAck.DeviationRTT;
PingText.text = estimatedRTT < 1000 ? $"{estimatedRTT}±{deviationRTT}ms" : $"~{estimatedRTT + deviationRTT}ms";
- PingText.tooltip = ClientNetworkSnapshotAck.SnapshotPacketLoss.ToFixedString().ToString();
+
+ var snapshotPacketLoss = ClientNetworkSnapshotAck.SnapshotPacketLoss.ToFixedString().ToString();
+ var predictedGhostCount = m_PredictedGhostsQuery.CalculateEntityCount();
+ var interpolatedGhostCount = GhostCount.IsCreated ? GhostCount.GhostCountInstantiatedOnClient - predictedGhostCount : 0;
+
+ var ghostCount = World.IsThinClient() ? "n/a" : $"{GhostCount}\n{predictedGhostCount} Predicted, {interpolatedGhostCount} Interpolated";
+ PingText.tooltip =
+$@"GhostCount Singleton
+{ghostCount}
+ • Note1: Received % can be greater than 100%, as the server can despawn many ghosts at once.
+ • Note2: Thin clients do not fully process received snapshots (to remain lightweight), and therefore don't spawn any ghosts.
+
+SnapshotPacketLossStatistics Singleton
+{snapshotPacketLoss}
+ • Note3: Packet clobbering can be mitigated. See Manual.
+";
}
else
{
@@ -1374,7 +1369,7 @@ public void ToggleLagSpikeSimulator()
{
if (!IsAnyUsingSimulator)
{
- SystemAPI.GetSingletonRW().ValueRW.LogError($"Cannot enable LagSpike simulator as Simulator disabled!");
+ SystemAPI.GetSingletonRW().ValueRW.LogError($"Cannot enable LagSpike simulator as Client Network Emulation is disabled!");
return;
}
@@ -1384,14 +1379,14 @@ public void ToggleLagSpikeSimulator()
LagSpikeMillisecondsLeft = IsSimulatingLagSpike ? -1 : MultiplayerPlayModeWindow.k_LagSpikeDurationsSeconds[Prefs.LagSpikeSelectionIndex];
UpdateSimulator = true;
SystemAPI.GetSingletonRW().ValueRW.DebugLog($"Lag Spike Simulator: Toggled! Dropping packets for {Mathf.CeilToInt(LagSpikeMillisecondsLeft)}ms!");
- MultiplayerPlayModeWindow.ForceRepaint();
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
}
public void ToggleTimeoutSimulation()
{
if (!IsAnyUsingSimulator)
{
- SystemAPI.GetSingletonRW().ValueRW.LogError($"Cannot enable Timeout Simulation as Simulator disabled!");
+ SystemAPI.GetSingletonRW().ValueRW.LogError($"Cannot enable Timeout Simulation as Client Network Emulation is disabled!");
return;
}
@@ -1406,7 +1401,7 @@ public void ToggleTimeoutSimulation()
TimeoutSimulationDurationSeconds = -1;
else TimeoutSimulationDurationSeconds = 0;
- MultiplayerPlayModeWindow.ForceRepaint();
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
}
public void ChangeStateImmediate(NetworkEndpoint? targetEp)
@@ -1431,10 +1426,10 @@ public void ChangeStateImmediate(NetworkEndpoint? targetEp)
if (ClientConnectionState != ConnectionState.State.Disconnected)
{
UnityEngine.Debug.Log($"[{World.Name}] You triggered a disconnection of {existingConn.Value.ToFixedString()} (on {connectedEntity.ToFixedString()}) via {nameof(MultiplayerPlayModeWindow)}!");
- MultiplayerPlayModeWindow.ForceRepaint();
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
netStream.ValueRW.DriverStore.Disconnect(existingConn);
DisconnectPending = true;
- UpdatePingText();
+ UpdateStatusText();
}
}
// Wait 1 frame before reconnecting:
@@ -1450,13 +1445,13 @@ public void ChangeStateImmediate(NetworkEndpoint? targetEp)
LagSpikeMillisecondsLeft = -1;
UpdateSimulator = true;
UnityEngine.Debug.Log($"[{World.Name}] You triggered a reconnection to {targetEp.Value.Address} via {nameof(MultiplayerPlayModeWindow)}!");
- MultiplayerPlayModeWindow.ForceRepaint();
+ MultiplayerPlayModeWindow.s_ForceRepaint = true;
var connEntity = netStream.ValueRW.Connect(EntityManager, targetEp.Value);
NetworkStreamConnection = EntityManager.GetComponentData(connEntity);
}
else
{
- UnityEngine.Debug.LogError($"[{World.Name}] You triggered a reconnection, but {targetEp.Value.Address} is not valid!");
+ UnityEngine.Debug.LogError($"[{World.Name}] You triggered a reconnection, but targetEp:{targetEp.Value.Address} is not valid!");
}
}
TargetEp = null;
@@ -1468,29 +1463,31 @@ public void ChangeStateImmediate(NetworkEndpoint? targetEp)
internal partial class MultiplayerServerPlayModeConnectionSystem : SystemBase
{
public bool IsListening { get; private set; }
- public NetworkEndpoint LastEndpoint { get; private set; }
- public int NumActiveConnections => m_activeConnectionsQuery.CalculateEntityCount();
-
- public int NumActiveConnectionsInGame => NumActiveConnections - m_notInGameQuery.CalculateEntityCount();
+ public FixedList512Bytes DriverInfos;
public List ConnectionEventsForTick { get; } = new(4);
- private EntityQuery m_activeConnectionsQuery;
- private EntityQuery m_notInGameQuery;
+ private EntityQuery m_ActiveConnectionsQuery;
+ private EntityQuery m_NotInGameQuery;
+ private EntityQuery m_GhostsQuery;
+ private EntityQuery m_GhostPrefabsQuery;
protected override void OnCreate()
{
- m_activeConnectionsQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude());
- m_notInGameQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude(), ComponentType.Exclude());
+ m_ActiveConnectionsQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude());
+ m_NotInGameQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude(), ComponentType.Exclude());
+ m_GhostsQuery = GetEntityQuery(ComponentType.ReadOnly());
+ m_GhostPrefabsQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly());
+ UpdateStatusText();
}
internal void TryDisconnectImmediate(params NetworkId[] networkIdsToDisconnect)
{
Dependency.Complete();
- m_activeConnectionsQuery.CompleteDependency();
- var networkIdEntities = m_activeConnectionsQuery.ToEntityArray(WorldUpdateAllocator);
- var networkIdValues = m_activeConnectionsQuery.ToComponentDataArray(WorldUpdateAllocator);
+ m_ActiveConnectionsQuery.CompleteDependency();
+ var networkIdEntities = m_ActiveConnectionsQuery.ToEntityArray(WorldUpdateAllocator);
+ var networkIdValues = m_ActiveConnectionsQuery.ToComponentDataArray(WorldUpdateAllocator);
ref readonly var netStream = ref SystemAPI.GetSingletonRW().ValueRW;
var connectionLookup = SystemAPI.GetComponentLookup(true);
@@ -1520,11 +1517,31 @@ internal void TryDisconnectImmediate(params NetworkId[] networkIdsToDisconnect)
protected override void OnUpdate()
{
ref readonly var netStream = ref SystemAPI.GetSingletonRW().ValueRW;
+ ref var driverStore = ref netStream.DriverStore;
IsListening = netStream.DriverStore.GetDriverInstanceRO(netStream.DriverStore.FirstDriver).driver.Listening;
- LastEndpoint = netStream.LastEndPoint;
ConnectionEventsForTick.Clear();
- if(EditorApplication.isPaused) // Can't see one frame events when unpaused anyway.
+ if (EditorApplication.isPaused) // Can't see one frame events when unpaused anyway.
ConnectionEventsForTick.AddRange(netStream.ConnectionEventsForTick);
+ Editor.DriverDisplayInfo.Read(ref driverStore, ref DriverInfos, null);
+ }
+
+ public void UpdateStatusText()
+ {
+ var ghostChunkCount = m_GhostsQuery.CalculateChunkCount();
+ var ghostCount = m_GhostsQuery.CalculateEntityCount();
+ var ghostPrefabCount = m_GhostPrefabsQuery.CalculateEntityCount();
+ var numConnections = m_ActiveConnectionsQuery.CalculateEntityCount();
+ var numInGame = numConnections - m_NotInGameQuery.CalculateEntityCount();
+ MultiplayerPlayModeWindow.s_ServerStats.text = $"{numConnections} Clients\n{ghostCount} Ghosts";
+ var ghostsPerChunk = ghostChunkCount > 0 ? $"\n~{(int)(ghostCount / (float)ghostChunkCount)} Ghosts Per Chunk" : "";
+ MultiplayerPlayModeWindow.s_ServerStats.tooltip = $@"Client Connections
+{numConnections} Connected
+{numInGame} In-Game
+
+Ghosts
+{ghostCount} Ghost Instances
+Across {ghostChunkCount} Chunks{ghostsPerChunk}
+{ghostPrefabCount} Ghost Types";
}
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation | WorldSystemFilterFlags.Editor)]
@@ -1566,4 +1583,38 @@ public void OnCreate(ref SystemState state)
}
}
}
+
+ internal struct DriverDisplayInfo
+ {
+ public TransportType TransportType;
+ public NetworkFamily NetworkFamily;
+ public byte DriverIndex;
+ public bool IsWebSocket;
+ public bool Listening;
+ public bool Bound;
+ public bool SimulatorEnabled;
+ public NetworkEndpoint Endpoint;
+
+ public static void Read(ref NetworkDriverStore driverStore, ref FixedList512Bytes list, NetworkConnection? clientConnection)
+ {
+ UnityEngine.Assertions.Assert.IsTrue(list.Capacity >= NetworkDriverStore.Capacity);
+ list.Length = math.min(driverStore.DriversCount, list.Capacity);
+ for (int entryIdx = 0; entryIdx < list.Length; entryIdx++)
+ {
+ ref var entry = ref list.ElementAt(entryIdx);
+ var driverIdx = entryIdx + driverStore.FirstDriver;
+ entry.DriverIndex = (byte)driverIdx;
+ entry.TransportType = driverStore.GetDriverType(driverIdx);
+ ref var driver = ref driverStore.GetDriverRW(driverIdx); // RW as calling non-readonly method!
+ entry.NetworkFamily = driver.GetLocalEndpoint().Family;
+ entry.IsWebSocket = driver.CurrentSettings.TryGet(out _);
+ entry.SimulatorEnabled = driverStore.GetDriverInstanceRO(driverIdx).simulatorEnabled;
+ entry.Listening = driver.Listening;
+ entry.Bound = driver.Bound;
+ entry.Endpoint = clientConnection.HasValue
+ ? driver.GetRemoteEndpoint(clientConnection.Value)
+ : driver.GetLocalEndpoint();
+ }
+ }
+ }
}
diff --git a/Editor/NetcodeConfigEditor.cs b/Editor/NetcodeConfigEditor.cs
index a1936dc..042f5a4 100644
--- a/Editor/NetcodeConfigEditor.cs
+++ b/Editor/NetcodeConfigEditor.cs
@@ -20,7 +20,7 @@ namespace Unity.NetCode.Editor
internal class NetcodeConfigEditor : UnityEditor.Editor, IPreprocessBuildWithReport, IPostprocessBuildWithReport
{
private const string k_LiveEditingWarning = " Therefore, be aware that the Global config is applied project-wide automatically:\n - In the Editor; this config is set every frame, enabling live editing. Note that this invalidates (by replacing) any C# code of yours that modifies these NetCode configuration singleton components manually.\n - In a build; this config is applied once (during Server & Client World system creation).";
- private static readonly GUILayoutOption s_ButtonWidth = GUILayout.Width(70);
+ private static readonly GUILayoutOption s_ButtonWidth = GUILayout.Width(90);
bool m_RemoveFromPreloadedAssets;
public int callbackOrder => 0;
@@ -42,6 +42,7 @@ internal static void CreateNetcodeSettingsAsset()
{
var assetPath = AssetDatabase.GenerateUniqueAssetPath("Assets/NetcodeConfig.asset");
var netCodeConfig = CreateInstance();
+ netCodeConfig.IsGlobalConfig = true; // Prevent warning when first creating it.
AssetDatabase.CreateAsset(netCodeConfig, assetPath);
Selection.activeObject = SavedConfig = AssetDatabase.LoadAssetAtPath(assetPath);
}
@@ -92,10 +93,11 @@ public static SettingsProvider CreateNetcodeConfigSettingsProvider()
Links();
GUILayout.BeginHorizontal();
+ var inst = NetCodeClientAndServerSettings.instance;
{
EditorGUI.BeginChangeCheck();
GUI.enabled = !Application.isPlaying;
- NetCodeClientAndServerSettings.instance.GlobalNetCodeConfig = EditorGUILayout.ObjectField(new GUIContent(string.Empty, "Select the asset that NetCode will use, by default."), NetCodeClientAndServerSettings.instance.GlobalNetCodeConfig, typeof(NetCodeConfig), allowSceneObjects: false) as NetCodeConfig;
+ inst.GlobalNetCodeConfig = EditorGUILayout.ObjectField(new GUIContent(string.Empty, "Select the asset that NetCode will use, by default."), inst.GlobalNetCodeConfig, typeof(NetCodeConfig), allowSceneObjects: false) as NetCodeConfig;
if (GUILayout.Button("Find & Set", s_ButtonWidth))
{
@@ -111,7 +113,7 @@ public static SettingsProvider CreateNetcodeConfigSettingsProvider()
}
}
- if (GUILayout.Button("Create", s_ButtonWidth))
+ if (GUILayout.Button("Create & Set", s_ButtonWidth))
{
CreateNetcodeSettingsAsset();
}
@@ -131,6 +133,21 @@ public static SettingsProvider CreateNetcodeConfigSettingsProvider()
{
EditorGUILayout.HelpBox("You have now set a Global NetCodeConfig asset." + k_LiveEditingWarning, MessageType.Warning);
}
+
+ EditorGUILayout.Separator();
+
+ // CurrentImportanceSuggestions:
+ var prevFlags = inst.hideFlags;
+ inst.hideFlags = HideFlags.None; // Allow editing of it.
+ var clientAndServerSettingsSO = new SerializedObject(inst, inst);
+ clientAndServerSettingsSO.Update();
+ var CurrentImportanceSuggestionsProperty = clientAndServerSettingsSO.FindProperty(nameof(inst.CurrentImportanceSuggestions));
+ EditorGUILayout.PropertyField(CurrentImportanceSuggestionsProperty);
+ if (clientAndServerSettingsSO.ApplyModifiedProperties())
+ {
+ inst.Save();
+ }
+ inst.hideFlags = prevFlags;
},
// Populate the search keywords to enable smart search filtering and label highlighting:
@@ -226,6 +243,8 @@ static void ClearPlayerSettingsDirtyFlag()
private static readonly GUIContent s_ClientServerTickRate = new GUIContent("ClientServerTickRate", "General multiplayer settings.\n\nServer Authoritative - Thus, when a client connects, the server will send an RPC clobbering any existing client values.");
private static readonly GUIContent s_ClientTickRate = new GUIContent("ClientTickRate", "General multiplayer settings for the client.\n\nCan be configured on a per-client basis (via use of multiple configs, or direct C# component manipulation).");
private static readonly GUIContent s_GhostSendSystemData = new GUIContent("GhostSendSystemData", "Specific optimization (and debug) settings for the GhostSendSystem to reduce bandwidth and CPU consumption.");
+ private static readonly GUIContent s_TransportSettings = new GUIContent("NetworkConfigParameter (Unity Transport)", "Configures various UTP NetworkConfigParameter configuration values, but only when user-code uses one of the built-in INetworkStreamDriverConstructor's.\n\nTo read this config in your own driver constructors, call DefaultDriverBuilder.AddNetcodePackageDefaultNetworkConfigParameters.");
+ private static bool s_TransportSettingsFoldedOut = true;
public override void OnInspectorGUI()
{
@@ -255,6 +274,28 @@ public override void OnInspectorGUI()
ValidateGhostSendSystemData(config.GhostSendSystemData);
GUILayout.Space(15);
+ //.
+ GUI.enabled = !Application.isPlaying;
+ s_TransportSettingsFoldedOut = EditorGUILayout.Foldout(s_TransportSettingsFoldedOut, s_TransportSettings, toggleOnLabelClick: true);
+ if (s_TransportSettingsFoldedOut)
+ {
+ EditorGUI.indentLevel += 2;
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.ConnectTimeoutMS)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.MaxConnectAttempts)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.DisconnectTimeoutMS)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.HeartbeatTimeoutMS)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.ReconnectionTimeoutMS)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.ClientSendQueueCapacity)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.ClientReceiveQueueCapacity)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.ServerSendQueueCapacity)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.ServerReceiveQueueCapacity)));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(NetCodeConfig.MaxMessageSize)));
+ GUI.enabled = true;
+ EditorGUI.indentLevel -= 2;
+ }
+
+ GUILayout.Space(15);
+
//.
Links();
serializedObject.ApplyModifiedProperties();
diff --git a/Runtime/Analytics/GhostConfigurationAnalyticsData.cs b/Runtime/Analytics/GhostConfigurationAnalyticsData.cs
index 9fe0116..02cc5e2 100644
--- a/Runtime/Analytics/GhostConfigurationAnalyticsData.cs
+++ b/Runtime/Analytics/GhostConfigurationAnalyticsData.cs
@@ -17,6 +17,7 @@ struct GhostConfigurationAnalyticsData
public bool autoCommandTarget;
public int variance;
public int importance;
+ public int maxSendRateHz;
public override string ToString()
{
@@ -26,6 +27,7 @@ public override string ToString()
$"{nameof(prespawnedCount)}: {prespawnedCount}, " +
$"{nameof(autoCommandTarget)}: {autoCommandTarget}, " +
$"{nameof(importance)}: {importance}, " +
+ $"{nameof(maxSendRateHz)}: {maxSendRateHz}, " +
$"{nameof(variance)}: {variance}";
}
}
diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs
index c7a349c..58697e6 100644
--- a/Runtime/AssemblyInfo.cs
+++ b/Runtime/AssemblyInfo.cs
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Unity.NetCode.Editor")]
[assembly: InternalsVisibleTo("Unity.NetCode.EditorTests")]
+[assembly: InternalsVisibleTo("Unity.NetCode.Physics.EditorTests")]
[assembly: InternalsVisibleTo("Unity.NetCode.TestsUtils")]
[assembly: InternalsVisibleTo("Unity.NetCode.Authoring.Hybrid")]
[assembly: InternalsVisibleTo("Unity.NetCode.Physics")]
diff --git a/Runtime/Authoring/DefaultVariantSystemBase.cs b/Runtime/Authoring/DefaultVariantSystemBase.cs
index fb0745c..6281ab4 100644
--- a/Runtime/Authoring/DefaultVariantSystemBase.cs
+++ b/Runtime/Authoring/DefaultVariantSystemBase.cs
@@ -48,28 +48,28 @@ public readonly struct Rule
/// This rule will only add the variant to parent entities with this component type.
/// Children with this component will remain (which is the default for children).
/// This is the recommended approach.
- ///
- ///
+ /// Parent entities with this component type will receive the variant
+ /// Updated rule
public static Rule OnlyParents(Type variantForParentOnly) => new Rule(variantForParentOnly, default);
/// This rule will add the same variant to all entities with this component type (i.e. both parent and children a.k.a. regardless of hierarchy).
/// Note: It is not recommended to serialize child entities as it is relatively slow to serialize them!
- ///
- ///
+ /// All entities with this component type will receive the variant
+ /// Updated rule
public static Rule ForAll(Type variantForBoth) => new Rule(variantForBoth, variantForBoth);
/// This rule will add one variant for parents, and another variant for children, by default.
/// Note: It is not recommended to serialize child entities as it is relatively slow to serialize them!
- ///
- ///
- ///
+ /// Parent entities with this component type will receive the variant
+ /// Child entities with this component type will receive the variant
+ /// Updated rule
public static Rule Unique(Type variantForParents, Type variantForChildren) => new Rule(variantForParents, variantForChildren);
/// This rule will only add this variant to child entities with this component.
/// The parent entities with this component will use the default serializer.
/// Note: It is not recommended to serialize child entities as it is relatively slow to serialize them!
- ///
- ///
+ /// Child entities with this component type will receive the variant
+ /// Updated rule
public static Rule OnlyChildren(Type variantForChildrenOnly) => new Rule(default, variantForChildrenOnly);
/// Use the static builder methods instead!
@@ -90,12 +90,12 @@ private Rule(Type variantForParents, Type variantForChildren)
///
/// Compare two rules ana check if their parent and child types are identical.
///
- ///
- ///
+ /// Rule to test equality against
+ /// Whether they variant type for parents and children match.
public bool Equals(Rule other) => VariantForParents == other.VariantForParents && VariantForChildren == other.VariantForChildren;
/// Unique HashCode if Variant fields are set.
- ///
+ /// A unique hashcode if variant fields are set. Otherwise 0.
public override int GetHashCode()
{
unchecked
@@ -167,7 +167,7 @@ protected sealed override void OnUpdate()
/// Implement this method by adding to the mapping your
/// default type->variant .
///
- ///
+ /// Mapping default types to a variant.
protected abstract void RegisterDefaultVariants(Dictionary defaultVariants);
}
diff --git a/Runtime/Authoring/GhostSerializerAttribute.cs b/Runtime/Authoring/GhostSerializerAttribute.cs
index 534108d..8fd7ef7 100644
--- a/Runtime/Authoring/GhostSerializerAttribute.cs
+++ b/Runtime/Authoring/GhostSerializerAttribute.cs
@@ -22,8 +22,8 @@ public class GhostSerializerAttribute : Attribute
///
/// Construct the attribute and assign the component and variant hash.
///
- ///
- ///
+ /// The component type this serializer is for.
+ /// The calculated variant hash for this serializer.
public GhostSerializerAttribute(Type componentType, ulong variantHash)
{
ComponentType = componentType;
diff --git a/Runtime/Authoring/Hybrid/BakerExtension.cs b/Runtime/Authoring/Hybrid/BakerExtension.cs
index 8643677..3af0961 100644
--- a/Runtime/Authoring/Hybrid/BakerExtension.cs
+++ b/Runtime/Authoring/Hybrid/BakerExtension.cs
@@ -21,7 +21,7 @@ public static class BakerExtensions
///
/// an instance of the baker
/// state is we are converting a prefab or not
- ///
+ /// Baker type
/// In the editor, if a is present in the build configuration used for conversion,
/// the target specified by the build component is used.
///
@@ -29,7 +29,7 @@ public static class BakerExtensions
/// is nothing apply or for prefabs.
///
///
- ///
+ /// Conversion target to use for the baking.
public static NetcodeConversionTarget GetNetcodeTarget(this Baker self, bool isPrefab) where T : Component
{
// Detect target using build settings (This is used from sub scenes)
diff --git a/Runtime/Authoring/Hybrid/GhostAuthoringComponent.cs b/Runtime/Authoring/Hybrid/GhostAuthoringComponent.cs
index cc8e3b4..561289f 100644
--- a/Runtime/Authoring/Hybrid/GhostAuthoringComponent.cs
+++ b/Runtime/Authoring/Hybrid/GhostAuthoringComponent.cs
@@ -3,6 +3,7 @@
using Unity.Entities;
using UnityEngine;
using Unity.Entities.Hybrid.Baking;
+using Unity.NetCode.Hybrid;
using UnityEngine.Serialization;
namespace Unity.NetCode
@@ -63,8 +64,31 @@ void OnValidate()
///
/// If not all ghosts can fit in a snapshot only the most important ghosts will be sent. Higher importance means the ghost is more likely to be sent.
///
- [Tooltip("Importance determines which ghosts are selected to be added to the snapshot, in the case where there is not enough space to include all ghosts in the snapshot. Many caveats apply, but generally, higher values are sent more frequently.\n\nExample: A 'Player' ghost with an Importance of 100 is roughly 100x more likely to be sent in any given snapshot than a 'Barrel' ghost with an Importance of 1. In other words, expect the 'Player' ghost to have been replicated 100 times for every one time the 'Barrel' is replicated.\n\nApplied at the chunk level.")]
+ [Tooltip(@"Importance determines how ghost chunks are prioritized against each other when working out what to send in the upcoming snapshot. Higher values are sent more frequently. Applied at the chunk level.
+Simplified example: When comparing a gameplay-critical Player ghost with an Importance of 100 to a cosmetic Cone ghost with an Importance of 1, the Player ghost will likely be sent 100 times for every 1 time the Cone will be.")]
+ [Min(1)]
public int Importance = 1;
+
+ ///
+ /// The theoretical maximum send frequency (in Hz) for ghost chunks of this ghost prefab type (excluding a few nuanced exceptions).
+ /// Important Note: The MaxSendRate only denotes the maximum possible replication frequency, and cannot be enforced in all cases.
+ /// Other factors (like , ghost instance count, ,
+ /// Importance-Scaling, , and structural changes etc.)
+ /// will determine the final/live send rate.
+ ///
+ ///
+ /// Use this to brute-force reduce the bandwidth consumption of your most impactful ghost types.
+ /// Note: Predicted ghosts are particularly impacted by this, as a lower value here reduces rollback and re-simulation frequency
+ /// (as we only rollback and re-simulate a predicted ghost after it is received), which can save client CPU cycles in aggregate.
+ /// However, it may cause larger client misprediction errors, which leads to larger corrections.
+ ///
+ [Tooltip(@"The theoretical maximum send frequency (in Hertz) for ghost chunks of this ghost prefab type.
+
+Important Note: The MaxSendRate only denotes the maximum possible replication frequency. Other factors (like NetworkTickRate, ghost instance count, Importance, Importance-Scaling, DefaultSnapshotPacketSize etc.) will determine the live send rate.
+
+Use this to brute-force reduce the bandwidth consumption of your most impactful ghost types.")]
+ public byte MaxSendRate;
+
///
/// For internal use only, the prefab GUID used to distinguish between different variant of the same prefab.
///
@@ -145,5 +169,24 @@ public FixedString64Bytes GetAndValidateGhostName(out ulong ghostNameHash)
}
/// True if we can apply the optimization on this Ghost.
public bool SupportsSendTypeOptimization => SupportedGhostModes != GhostModeMask.All || DefaultGhostMode == GhostMode.OwnerPredicted;
+
+ /// Helper.
+ ///
+ ///
+ internal GhostPrefabCreation.Config AsConfig(FixedString64Bytes ghostName)
+ {
+ return new GhostPrefabCreation.Config
+ {
+ Name = ghostName,
+ Importance = Importance,
+ MaxSendRate = MaxSendRate,
+ SupportedGhostModes = SupportedGhostModes,
+ DefaultGhostMode = DefaultGhostMode,
+ OptimizationMode = OptimizationMode,
+ UsePreSerialization = UsePreSerialization,
+ PredictedSpawnedGhostRollbackToSpawnTick = RollbackPredictedSpawnedGhostState,
+ RollbackPredictionOnStructuralChanges = RollbackPredictionOnStructuralChanges,
+ };
+ }
}
}
diff --git a/Runtime/Authoring/Hybrid/GhostAuthoringComponentBaker.cs b/Runtime/Authoring/Hybrid/GhostAuthoringComponentBaker.cs
index 72f9eac..d616538 100644
--- a/Runtime/Authoring/Hybrid/GhostAuthoringComponentBaker.cs
+++ b/Runtime/Authoring/Hybrid/GhostAuthoringComponentBaker.cs
@@ -11,13 +11,7 @@ namespace Unity.NetCode
struct GhostPrefabConfigBaking
{
public UnityObjectRef Authoring;
- public int Importance;
- public GhostModeMask SupportedGhostModes;
- public GhostMode DefaultGhostMode;
- public GhostOptimizationMode OptimizationMode;
- public bool UsePreSerialization;
- public bool PredictedSpawnedGhostRollbackToSpawnTick;
- public bool RollbackPredictionOnStructuralChanges;
+ public GhostPrefabCreation.Config Config;
}
// This type contains all the information pulled from the authoring component in the baker
@@ -122,13 +116,7 @@ public override void Bake(GhostAuthoringComponent ghostAuthoring)
var bakingConfig = new GhostPrefabConfigBaking
{
Authoring = ghostAuthoring,
- Importance = ghostAuthoring.Importance,
- SupportedGhostModes = ghostAuthoring.SupportedGhostModes,
- DefaultGhostMode = ghostAuthoring.DefaultGhostMode,
- OptimizationMode = ghostAuthoring.OptimizationMode,
- UsePreSerialization = ghostAuthoring.UsePreSerialization,
- PredictedSpawnedGhostRollbackToSpawnTick = ghostAuthoring.RollbackPredictedSpawnedGhostState,
- RollbackPredictionOnStructuralChanges = ghostAuthoring.RollbackPredictionOnStructuralChanges
+ Config = ghostAuthoring.AsConfig(ghostName),
};
// Generate a ghost type component so the ghost can be identified by matching prefab asset guid
@@ -154,7 +142,7 @@ public override void Bake(GhostAuthoringComponent ghostAuthoring)
AddComponent(entity);
}
- if (isPrefab && (target != NetcodeConversionTarget.Server) && (bakingConfig.SupportedGhostModes != GhostModeMask.Interpolated))
+ if (isPrefab && (target != NetcodeConversionTarget.Server) && (bakingConfig.Config.SupportedGhostModes != GhostModeMask.Interpolated))
AddComponent(entity);
}
}
@@ -422,31 +410,19 @@ protected override void OnUpdate()
}
}
- GhostPrefabCreation.Config config = new GhostPrefabCreation.Config
- {
- Name = ghostAuthoringBakingData.GhostName,
- Importance = ghostAuthoringBakingData.BakingConfig.Importance,
- SupportedGhostModes = ghostAuthoringBakingData.BakingConfig.SupportedGhostModes,
- DefaultGhostMode = ghostAuthoringBakingData.BakingConfig.DefaultGhostMode,
- OptimizationMode = ghostAuthoringBakingData.BakingConfig.OptimizationMode,
- UsePreSerialization = ghostAuthoringBakingData.BakingConfig.UsePreSerialization,
- PredictedSpawnedGhostRollbackToSpawnTick = ghostAuthoringBakingData.BakingConfig.PredictedSpawnedGhostRollbackToSpawnTick,
- RollbackPredictionOnStructuralChanges = ghostAuthoringBakingData.BakingConfig.RollbackPredictionOnStructuralChanges
- };
-
- GhostPrefabCreation.FinalizePrefabComponents(config, EntityManager,
+ GhostPrefabCreation.FinalizePrefabComponents(ghostAuthoringBakingData.BakingConfig.Config, EntityManager,
rootEntity, ghostAuthoringBakingData.GhostType, linkedEntities,
allComponents, componentCounts, ghostAuthoringBakingData.Target, prefabTypes);
if (ghostAuthoringBakingData.IsPrefab)
{
- var contentHash = TypeHash.FNV1A64(ghostAuthoringBakingData.BakingConfig.Importance);
+ var contentHash = TypeHash.FNV1A64(ghostAuthoringBakingData.BakingConfig.Config.Importance);
contentHash = TypeHash.CombineFNV1A64(contentHash,
- TypeHash.FNV1A64((int) ghostAuthoringBakingData.BakingConfig.SupportedGhostModes));
+ TypeHash.FNV1A64((int) ghostAuthoringBakingData.BakingConfig.Config.SupportedGhostModes));
contentHash = TypeHash.CombineFNV1A64(contentHash,
- TypeHash.FNV1A64((int) ghostAuthoringBakingData.BakingConfig.DefaultGhostMode));
+ TypeHash.FNV1A64((int) ghostAuthoringBakingData.BakingConfig.Config.DefaultGhostMode));
contentHash = TypeHash.CombineFNV1A64(contentHash,
- TypeHash.FNV1A64((int) ghostAuthoringBakingData.BakingConfig.OptimizationMode));
+ TypeHash.FNV1A64((int) ghostAuthoringBakingData.BakingConfig.Config.OptimizationMode));
contentHash = TypeHash.CombineFNV1A64(contentHash, ghostAuthoringBakingData.GhostNameHash);
for (int i = 0; i < componentCounts[0]; ++i)
{
@@ -479,7 +455,7 @@ protected override void OnUpdate()
// instanceIds[0] contains the root GameObject instance id
if (context.NeedToComputeBlobAsset(blobHash))
{
- var blobAsset = GhostPrefabCreation.CreateBlobAsset(config,
+ var blobAsset = GhostPrefabCreation.CreateBlobAsset(ghostAuthoringBakingData.BakingConfig.Config,
EntityManager, rootEntity, linkedEntities,
allComponents, componentCounts, ghostAuthoringBakingData.Target, prefabTypes,
sendMasksOverride, variants);
diff --git a/Runtime/Authoring/Hybrid/GhostPresentationGameObjectAuthoring.cs b/Runtime/Authoring/Hybrid/GhostPresentationGameObjectAuthoring.cs
index c349c34..bd09937 100644
--- a/Runtime/Authoring/Hybrid/GhostPresentationGameObjectAuthoring.cs
+++ b/Runtime/Authoring/Hybrid/GhostPresentationGameObjectAuthoring.cs
@@ -39,7 +39,7 @@ public class GhostPresentationGameObjectAuthoring : MonoBehaviour
/// Implementation of . Should not be called directly. It is invoked as part
/// of the GhostAnimationController initialization.
///
- ///
+ /// PlayableComponent type
public void RegisterPlayableData() where T: unmanaged, IComponentData
{
regEntityManager.AddComponentData(regEntity, default(T));
diff --git a/Runtime/Authoring/Hybrid/NetCodeClientAndServerSettings.cs b/Runtime/Authoring/Hybrid/NetCodeClientAndServerSettings.cs
index 26d540e..ae9d1d3 100644
--- a/Runtime/Authoring/Hybrid/NetCodeClientAndServerSettings.cs
+++ b/Runtime/Authoring/Hybrid/NetCodeClientAndServerSettings.cs
@@ -1,5 +1,7 @@
#if UNITY_EDITOR
using System;
+using System.Collections.Generic;
+using Unity.Collections;
using Unity.Entities.Build;
using UnityEditor;
using UnityEngine;
@@ -27,6 +29,15 @@ public class NetCodeClientAndServerSettings : ScriptableSingleton
[SerializeField] public NetCodeConfig GlobalNetCodeConfig;
+ ///
+ [SerializeField] public List CurrentImportanceSuggestions = new List
+ {
+ new () { MinValue = 1, MaxValue = 4, Name = "Low Importance", Tooltip = "For cosmetic (i.e. visual-only) ghosts like glass bottles, signs, beach-balls, and cones etc. Typically Static.", },
+ new () { MinValue = 5, MaxValue = 40, Name = "Medium Importance", Tooltip = "For common gameplay-affecting ghosts like trees, doors, explosive barrels, dropped loot etc. Typically Static.", },
+ new () { MinValue = 50, MaxValue = 250, Name = "High Importance", Tooltip = "For per-player and objective-critical ghosts like Player Character Controllers and CTF flags etc. Typically for Dynamic i.e. Predicted ghosts. UsePreSerialization is likely a good fit.", },
+ new () { MinValue = 1000, MaxValue = 0, Name = "Critical Importance", Tooltip = "For gameplay critical singletons like the one keeping the current score, or the one denoting whether or not the current round has started etc. Choose UsePreSerialization, and use sparingly.", },
+ };
+
static Entities.Hash128 s_Guid;
///
public Entities.Hash128 GUID
@@ -115,5 +126,25 @@ private void OnDisable()
#endif
}
}
+
+ ///
+ /// Editor-only helper - allows you to configure the value-specific suggested ranges on the
+ /// tooltip.
+ ///
+ [Serializable]
+ public struct EditorImportanceSuggestion
+ {
+ /// Loose minimum value.
+ public float MinValue;
+ /// Loose maximum value.
+ public float MaxValue;
+ /// Short, inline name for this importance category/range.
+ public string Name;
+ /// Single-line example for when you'd want to use this.
+ public string Tooltip;
+ /// Helper.
+ /// Formatted string.
+ public override string ToString() => $"{MinValue} ~ {MaxValue} for {Name} - {Tooltip}";
+ }
}
#endif
diff --git a/Runtime/ClientServerWorld/AutomaticThinClientWorldsUtility.cs b/Runtime/ClientServerWorld/AutomaticThinClientWorldsUtility.cs
new file mode 100644
index 0000000..c97f792
--- /dev/null
+++ b/Runtime/ClientServerWorld/AutomaticThinClientWorldsUtility.cs
@@ -0,0 +1,234 @@
+using System.Collections.Generic;
+using Unity.Collections;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.Scenes;
+using UnityEngine;
+
+namespace Unity.NetCode
+{
+ ///
+ /// Have netcode automatically manage thin clients for you by assigning .
+ ///
+ public class AutomaticThinClientWorldsUtility
+ {
+ /// Set the desired number of thin client worlds.
+ ///
+ /// If null (the default), it'll use in the editor, else 0.
+ /// Worlds are only created in builds if you hook up .
+ ///
+ public static int? NumThinClientsRequested;
+
+ ///
+ /// The frequency with which we should create the thin client worlds (in hertz i.e. worlds per second).
+ /// 0 denotes 'create all immediately'.
+ /// If null (the default), it'll use in the editor, else 0.
+ ///
+ public static float? CreationFrequency;
+
+ ///
+ /// The world to use for data injection (like to know which sub-scene(s) to load).
+ /// If null, we'll try to use any existing client or server worlds, found via etc.
+ ///
+ public static World ReferenceWorld;
+
+ ///
+ /// If your automatic thin clients need custom initialization during bootstrap (e.g. due to custom scene management settings),
+ /// modify this delegate. Uses by default.
+ /// Set to null to disable the bootstrap initialization feature.
+ ///
+ public static ThinClientWorldInitializationDelegate BootstrapInitialization = DefaultBootstrapThinClientWorldInitialization;
+
+ ///
+ /// If your automatic thin clients need custom initialization at runtime (e.g. due to custom scene management settings),
+ /// modify this delegate. Uses by default.
+ /// Set to null to disable the runtime initialization feature.
+ ///
+ public static ThinClientWorldInitializationDelegate RuntimeInitialization = DefaultRuntimeThinClientWorldInitialization;
+
+ /// Denotes if automatic bootstrap thin client creation is enabled.
+ public static bool IsBootstrapInitializationEnabled => BootstrapInitialization != null;
+
+ /// Denotes if automatic RUNTIME thin client creation is enabled.
+ public static bool IsRuntimeInitializationEnabled => RuntimeInitialization != null;
+
+ ///
+ /// A list of all thin client worlds created by (and managed by) the netcode package itself.
+ /// If you add a thin client to this list, netcode will take ownership of it.
+ /// This list prevents the netcode package from deleting your thin client worlds.
+ ///
+ public static List AutomaticallyManagedWorlds { get; } = new();
+
+ private static double s_LastSpawnRealtime;
+
+ /// Delegate for and
+ /// .
+ /// The world to reference when creating this one (for the purposes of scene loading etc.).
+ /// The newly created world, otherwise null.
+ public delegate World ThinClientWorldInitializationDelegate(World referenceWorld);
+
+ ///
+ /// Resets the utility to starting values via
+ /// and .
+ ///
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void Init()
+ {
+ NumThinClientsRequested = default;
+ CreationFrequency = default;
+ s_LastSpawnRealtime = default;
+ ReferenceWorld = default;
+ BootstrapInitialization = DefaultBootstrapThinClientWorldInitialization;
+ RuntimeInitialization = DefaultRuntimeThinClientWorldInitialization;
+ CleanupWorlds();
+ }
+
+ /// Utility to remove all stale worlds from the list.
+ /// Num removed.
+ public static int CleanupWorlds() => AutomaticallyManagedWorlds.RemoveAll(x => x == null || !x.IsCreated);
+
+ ///
+ /// By default, thin clients created during the bootstrap will automatically be injected with the loaded scenes sub-scenes.
+ /// Thus, we do not need to do anything custom.
+ ///
+ /// The world to reference when creating this one (for the purposes of scene loading etc.).
+ /// The newly created world, otherwise null.
+ public static World DefaultBootstrapThinClientWorldInitialization(World referenceWorld)
+ {
+ return ClientServerBootstrap.CreateThinClientWorld();
+ }
+
+ ///
+ /// The world to reference when creating this one (for the purposes of scene loading etc.).
+ /// The newly created world, otherwise null.
+ public static World DefaultRuntimeThinClientWorldInitialization(World referenceWorld)
+ {
+ if (referenceWorld?.IsCreated != true)
+ {
+ UnityEngine.Debug.LogError($"Cannot properly initialize ThinClientWorld as referenceWorld:{referenceWorld} is null, so no idea which scenes to load.");
+ return null;
+ }
+
+ var newThinClientWorld = ClientServerBootstrap.CreateThinClientWorld();
+ using var serverWorldScenesQuery = referenceWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly());
+ var serverWorldScenes = serverWorldScenesQuery.ToComponentDataArray(Allocator.Temp);
+ for (int i = 0; i < serverWorldScenes.Length; i++)
+ {
+ var desiredGoSceneReferenceGuid = serverWorldScenes[i];
+ SceneSystem.LoadSceneAsync(newThinClientWorld.Unmanaged,
+ desiredGoSceneReferenceGuid.SceneGUID,
+ new SceneSystem.LoadParameters
+ {
+ Flags = SceneLoadFlags.BlockOnImport | SceneLoadFlags.BlockOnStreamIn,
+ AutoLoad = true,
+ });
+ }
+ return newThinClientWorld;
+ }
+
+ ///
+ /// Use this method when inside the flow.
+ ///
+ ///
+ /// This has to exist because Entities/Netcode uses a fast-path, where it loads the entity scene data (for all
+ /// loaded scenes) once, and then auto-injects said data into all appropriate bootstrapping worlds.
+ ///
+ public static void BootstrapThinClientWorlds()
+ {
+ if (!IsBootstrapInitializationEnabled) return;
+ var requestedNumThinClients = NumThinClientsRequested ?? 0;
+#if UNITY_EDITOR
+ if(NumThinClientsRequested == null) requestedNumThinClients = MultiplayerPlayModePreferences.RequestedNumThinClients;
+#endif
+ for (var i = 0; i < requestedNumThinClients; i++)
+ {
+ var newThinClientWorld = BootstrapInitialization(ReferenceWorld);
+ if (newThinClientWorld != null && newThinClientWorld.IsCreated)
+ AutomaticallyManagedWorlds.Add(newThinClientWorld);
+ }
+
+ }
+
+ ///
+ /// If you use this feature, call this method in a Update method.
+ /// It'll apply the current configured values.
+ ///
+ /// True if any worlds were created or destroyed.
+ public static bool UpdateAutomaticThinClientWorlds()
+ {
+ var requestedNumThinClients = NumThinClientsRequested ?? 0;
+ var instantiationFrequency = CreationFrequency ?? 0f;
+#if UNITY_EDITOR
+ if (!UnityEditor.EditorApplication.isPlaying || UnityEditor.EditorApplication.isCompiling || UnityEditor.EditorApplication.isPaused)
+ return false;
+ // Creating & destroying thin clients can be expensive, so prevent changes while editing the value.
+ if (UnityEditor.EditorGUIUtility.editingTextField)
+ return false;
+ if(NumThinClientsRequested == null) requestedNumThinClients = MultiplayerPlayModePreferences.RequestedNumThinClients;
+ if(CreationFrequency == null) instantiationFrequency = MultiplayerPlayModePreferences.ThinClientCreationFrequency;
+#endif
+ int maxAllowedToSpawn;
+ if (instantiationFrequency == 0)
+ {
+ maxAllowedToSpawn = int.MaxValue;
+ }
+ else
+ {
+ maxAllowedToSpawn = 1;
+ var elapsedSecondsSinceLastSpawn = Time.realtimeSinceStartupAsDouble - s_LastSpawnRealtime;
+ if (elapsedSecondsSinceLastSpawn < 1d / instantiationFrequency)
+ maxAllowedToSpawn = 0;
+ }
+ UpdateAutomaticThinClientWorldsImmediate(ReferenceWorld, requestedNumThinClients, maxAllowedToSpawn, out var didCreateOrDestroy);
+ return didCreateOrDestroy;
+ }
+
+ ///
+ /// Creates and/or Disposes thin client worlds until the final count is equal to .
+ ///
+ /// The desired world to use as a reference. If null, we'll try to use any existing client or server worlds.
+ /// The desired final count of thin clients.
+ /// Rate limiting feature. Worlds are disposed immediately, but only instantiated at this frequency.
+ /// True if worlds were created or destroyed.
+ /// The list of successfully created worlds, otherwise default.
+ public static NativeList UpdateAutomaticThinClientWorldsImmediate(World referenceWorld, int targetThinClientCount, int maxAllowedSpawn, out bool didCreateOrDestroy)
+ {
+ referenceWorld ??= ClientServerBootstrap.ServerWorld ?? ClientServerBootstrap.ClientWorld;
+ didCreateOrDestroy = false;
+
+ // Dispose if too many:
+ didCreateOrDestroy |= CleanupWorlds() > 0;
+ var autoWorlds = AutomaticallyManagedWorlds;
+ while(autoWorlds.Count > targetThinClientCount)
+ {
+ var index = autoWorlds.Count - 1;
+ var world = autoWorlds[index];
+ autoWorlds.RemoveAt(index);
+ if (world.IsCreated)
+ world.Dispose();
+ didCreateOrDestroy = true;
+ }
+
+ // Create new:
+ var maxAllowedToSpawn = math.clamp(targetThinClientCount - autoWorlds.Count, 0, maxAllowedSpawn);
+ NativeList newWorlds = default;
+ var runtimeCreationIsEnabled = RuntimeInitialization != null;
+ if (runtimeCreationIsEnabled && referenceWorld != null && referenceWorld.IsCreated)
+ {
+ newWorlds = new NativeList(maxAllowedToSpawn, Allocator.Temp);
+ for(var newIdx = 0; newIdx < maxAllowedToSpawn; newIdx++)
+ {
+ didCreateOrDestroy = true;
+ var newThinClientWorld = RuntimeInitialization(referenceWorld);
+ if (newThinClientWorld != null && newThinClientWorld.IsCreated)
+ {
+ autoWorlds.Add(newThinClientWorld);
+ newWorlds.Add(newThinClientWorld.Unmanaged);
+ }
+ s_LastSpawnRealtime = Time.realtimeSinceStartupAsDouble;
+ }
+ }
+ return newWorlds;
+ }
+ }
+}
diff --git a/Runtime/ClientServerWorld/AutomaticThinClientWorldsUtility.cs.meta b/Runtime/ClientServerWorld/AutomaticThinClientWorldsUtility.cs.meta
new file mode 100644
index 0000000..6bbd41e
--- /dev/null
+++ b/Runtime/ClientServerWorld/AutomaticThinClientWorldsUtility.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: b582b86434d84720b9e4ca891c4778e4
+timeCreated: 1729163756
\ No newline at end of file
diff --git a/Runtime/ClientServerWorld/ClientServerBootstrap.cs b/Runtime/ClientServerWorld/ClientServerBootstrap.cs
index de72542..8eaa487 100644
--- a/Runtime/ClientServerWorld/ClientServerBootstrap.cs
+++ b/Runtime/ClientServerWorld/ClientServerBootstrap.cs
@@ -66,23 +66,24 @@ public class ClientServerBootstrap : ICustomBootstrap
///
public static List ThinClientWorlds => ClientServerTracker.ThinClientWorlds;
-#if UNITY_EDITOR || !UNITY_SERVER
- private static int NextThinClientId;
+ private static int s_NextThinClientId;
+
+ private static OverrideAutomaticNetcodeBootstrap s_OverrideCache;
+ private static bool s_OverrideCacheHasResult;
+
///
/// Initialize the bootstrap class and reset the static data everytime a new instance is created.
///
public ClientServerBootstrap()
{
- NextThinClientId = 1;
- }
-#endif
+ s_NextThinClientId = 1;
+ s_OverrideCache = default;
+ s_OverrideCacheHasResult = default;
#if UNITY_SERVER && UNITY_CLIENT
- public ClientServerBootstrap()
- {
UnityEngine.Debug.LogError("Both UNITY_SERVER and UNITY_CLIENT defines are present. This is not allowed and will lead to undefined behaviour, they are for dedicated server or client only logic so can't work together.");
- }
#endif
+ }
///
/// Utility method for creating a local world without any netcode systems.
@@ -130,6 +131,10 @@ public virtual bool Initialize(string defaultWorldName)
/// The first override in the active scene.
public static OverrideAutomaticNetcodeBootstrap DiscoverAutomaticNetcodeBootstrap(bool logNonErrors = false)
{
+ if (s_OverrideCacheHasResult)
+ return s_OverrideCache;
+ s_OverrideCacheHasResult = true;
+
// Note that GetActiveScene will return invalid when domain reloads are ENABLED.
var activeScene = SceneManager.GetActiveScene();
// We must use `FindObjectsInactive.Include` here, otherwise we'll get zero results.
@@ -138,10 +143,9 @@ public static OverrideAutomaticNetcodeBootstrap DiscoverAutomaticNetcodeBootstra
{
if(logNonErrors)
UnityEngine.Debug.Log($"[DiscoverAutomaticNetcodeBootstrap] Did not find any instances of `OverrideAutomaticNetcodeBootstrap`.");
- return null;
+ return s_OverrideCache;
}
Array.Sort(sceneConfigurations); // Attempt to make the results somewhat deterministic and reliable via sorting by `name`, then `InstanceId`.
- OverrideAutomaticNetcodeBootstrap selectedConfig = null;
for (int i = 0; i < sceneConfigurations.Length; i++)
{
var config = sceneConfigurations[i];
@@ -151,10 +155,10 @@ public static OverrideAutomaticNetcodeBootstrap DiscoverAutomaticNetcodeBootstra
// Note: Double-click on a scene to set it as the Active scene.
var activeSceneIsValid = activeScene.IsValid() || SceneManager.loadedSceneCount == 1;
var isConfigInActiveScene = !activeSceneIsValid || !config.gameObject.scene.IsValid() || config.gameObject.scene == activeScene;
- if (selectedConfig != null)
+ if (s_OverrideCache)
{
- var msg = $"[DiscoverAutomaticNetcodeBootstrap] Cannot select `OverrideAutomaticNetcodeBootstrap` on GameObject '{config.name}' with value `{config.ForceAutomaticBootstrapInScene}` (in scene '{LogScene(config.gameObject.scene, activeScene)}') as we've already selected another ('{selectedConfig.name}' with value `{selectedConfig.ForceAutomaticBootstrapInScene}` in scene '{LogScene(selectedConfig.gameObject.scene, activeScene)}')!";
- if (config.gameObject.scene == selectedConfig.gameObject.scene || isConfigInActiveScene)
+ var msg = $"[DiscoverAutomaticNetcodeBootstrap] Cannot select `OverrideAutomaticNetcodeBootstrap` on GameObject '{config.name}' with value `{config.ForceAutomaticBootstrapInScene}` (in scene '{LogScene(config.gameObject.scene, activeScene)}') as we've already selected another ('{s_OverrideCache.name}' with value `{s_OverrideCache.ForceAutomaticBootstrapInScene}` in scene '{LogScene(s_OverrideCache.gameObject.scene, activeScene)}')!";
+ if (config.gameObject.scene == s_OverrideCache.gameObject.scene || isConfigInActiveScene)
{
msg += " It's erroneous to have multiple in the same scene!";
UnityEngine.Debug.LogError(msg, config);
@@ -172,16 +176,16 @@ public static OverrideAutomaticNetcodeBootstrap DiscoverAutomaticNetcodeBootstra
if (isConfigInActiveScene)
{
- selectedConfig = config;
+ s_OverrideCache = config;
if (logNonErrors)
- UnityEngine.Debug.Log($"[DiscoverAutomaticNetcodeBootstrap] Using discovered `OverrideAutomaticNetcodeBootstrap` on GameObject '{selectedConfig.name}' with value `{selectedConfig.ForceAutomaticBootstrapInScene}` (in scene '{LogScene(selectedConfig.gameObject.scene, activeScene)}') as it's in the active scene ({LogScene(activeScene, activeScene)})!");
+ UnityEngine.Debug.Log($"[DiscoverAutomaticNetcodeBootstrap] Using discovered `OverrideAutomaticNetcodeBootstrap` on GameObject '{s_OverrideCache.name}' with value `{s_OverrideCache.ForceAutomaticBootstrapInScene}` (in scene '{LogScene(s_OverrideCache.gameObject.scene, activeScene)}') as it's in the active scene ({LogScene(activeScene, activeScene)})!");
continue;
}
if (logNonErrors)
UnityEngine.Debug.Log($"[DiscoverAutomaticNetcodeBootstrap] Ignoring `OverrideAutomaticNetcodeBootstrap` on GameObject '{config.name}' with value `{config.ForceAutomaticBootstrapInScene}` (in scene '{LogScene(config.gameObject.scene, activeScene)}') as this scene is not the Active scene!");
}
- return selectedConfig;
+ return s_OverrideCache;
static string LogScene(Scene scene, Scene active)
{
@@ -196,7 +200,7 @@ static string LogScene(Scene scene, Scene active)
/// in the active scene, and if there is, uses its value to clobber the default.
///
/// If true, more details are logged, enabling debugging of flows.
- ///
+ /// Whether there is an . Otherwise false.
public static bool DetermineIfBootstrappingEnabled(bool logNonErrors = false)
{
var automaticNetcodeBootstrap = DiscoverAutomaticNetcodeBootstrap(logNonErrors);
@@ -224,11 +228,7 @@ protected virtual void CreateDefaultClientServerWorlds()
CreateClientWorld("ClientWorld");
#if UNITY_EDITOR
- var requestedNumThinClients = RequestedNumThinClients;
- for (var i = 0; i < requestedNumThinClients; i++)
- {
- CreateThinClientWorld();
- }
+ AutomaticThinClientWorldsUtility.BootstrapThinClientWorlds();
#endif
}
}
@@ -238,13 +238,13 @@ protected virtual void CreateDefaultClientServerWorlds()
/// Can be used in custom implementations of `Initialize` as well as at runtime
/// to add new clients dynamically.
///
- ///
+ /// Thin client world instance.
public static World CreateThinClientWorld()
{
#if UNITY_SERVER && !UNITY_EDITOR
- throw new PlatformNotSupportedException("This executable was built using a 'server-only' build target (likely DGS). Thus, cannot create thin client worlds.");
-#else
- var world = new World("ThinClientWorld" + NextThinClientId++, WorldFlags.GameThinClient);
+ Debug.LogWarning("This executable was built using a 'server-only' build target (likely DGS). Thus, may not be able to successfully initialize thin client world.");
+#endif
+ var world = new World("ThinClientWorld" + s_NextThinClientId++, WorldFlags.GameThinClient);
var systems = DefaultWorldInitialization.GetAllSystems(WorldSystemFilterFlags.ThinClientSimulation);
DefaultWorldInitialization.AddSystemsToRootLevelSystemGroups(world, systems);
@@ -253,7 +253,7 @@ public static World CreateThinClientWorld()
ThinClientWorlds.Add(world);
return world;
-#endif
+
}
///
@@ -261,7 +261,7 @@ public static World CreateThinClientWorld()
/// Can be used in custom implementations of `Initialize` as well as at runtime to add new clients dynamically.
///
/// The client world name
- ///
+ /// Client world instance.
public static World CreateClientWorld(string name)
{
#if UNITY_SERVER && !UNITY_EDITOR
@@ -294,6 +294,7 @@ public static bool TryFindAutoConnectEndPoint(out NetworkEndpoint autoConnectEp)
switch (RequestedPlayType)
{
+ case PlayType.Server:
case PlayType.ClientAndServer:
{
// Allow loopback + AutoConnectPort:
@@ -301,7 +302,7 @@ public static bool TryFindAutoConnectEndPoint(out NetworkEndpoint autoConnectEp)
{
if (!DefaultConnectAddress.IsLoopback)
{
- UnityEngine.Debug.LogWarning($"DefaultConnectAddress is set to `{DefaultConnectAddress.Address}`, but we expected it to be loopback as we're in mode '{RequestedPlayType}`. Using loopback instead!");
+ UnityEngine.Debug.LogWarning($"DefaultConnectAddress is set to `{DefaultConnectAddress.Address}`, but we expected it to be loopback as we're in mode `{RequestedPlayType}`. Using loopback instead!");
autoConnectEp = NetworkEndpoint.LoopbackIpv4;
}
@@ -326,8 +327,6 @@ public static bool TryFindAutoConnectEndPoint(out NetworkEndpoint autoConnectEp)
// Otherwise do nothing.
return false;
}
- case PlayType.Server:
- return false;
default:
throw new ArgumentOutOfRangeException(nameof(RequestedPlayType), RequestedPlayType, nameof(TryFindAutoConnectEndPoint));
}
@@ -356,7 +355,7 @@ public static bool HasDefaultAddressAndPortSet(out NetworkEndpoint autoConnectEp
/// when you need to create the server programmatically (for example, a frontend that allows selecting the role or other logic).
///
/// The server world name.
- ///
+ /// Server world instance.
public static World CreateServerWorld(string name)
{
#if UNITY_CLIENT && !UNITY_SERVER && !UNITY_EDITOR
@@ -472,6 +471,7 @@ internal struct ServerClientCount
public int clientWorlds;
}
internal static readonly SharedStatic WorldCounts = SharedStatic.GetOrCreate();
+
///
/// Check if a world with a is present.
///
@@ -506,7 +506,7 @@ public static class ClientServerWorldExtensions
/// Check if a world is a thin client.
///
/// A instance
- ///
+ /// Whether is a thin client world.
public static bool IsThinClient(this World world)
{
return (world.Flags&WorldFlags.GameThinClient) == WorldFlags.GameThinClient;
@@ -515,7 +515,7 @@ public static bool IsThinClient(this World world)
/// Check if an unmanaged world is a thin client.
///
/// A instance
- ///
+ /// Whether is a thin client world.
public static bool IsThinClient(this WorldUnmanaged world)
{
return (world.Flags&WorldFlags.GameThinClient) == WorldFlags.GameThinClient;
@@ -524,7 +524,7 @@ public static bool IsThinClient(this WorldUnmanaged world)
/// Check if a world is a client, will also return true for thin clients.
///
/// A instance
- ///
+ /// Whether is a client or a thin client world.
public static bool IsClient(this World world)
{
return ((world.Flags&WorldFlags.GameClient) == WorldFlags.GameClient) || world.IsThinClient();
@@ -533,7 +533,7 @@ public static bool IsClient(this World world)
/// Check if an unmanaged world is a client, will also return true for thin clients.
///
/// A instance
- ///
+ /// Whether is a client or a thin client world.
public static bool IsClient(this WorldUnmanaged world)
{
return ((world.Flags&WorldFlags.GameClient) == WorldFlags.GameClient) || world.IsThinClient();
@@ -542,7 +542,7 @@ public static bool IsClient(this WorldUnmanaged world)
/// Check if a world is a server.
///
/// A instance
- ///
+ /// Whether is a server world.
public static bool IsServer(this World world)
{
return (world.Flags&WorldFlags.GameServer) == WorldFlags.GameServer;
@@ -551,7 +551,7 @@ public static bool IsServer(this World world)
/// Check if an unmanaged world is a server.
///
/// A instance
- ///
+ /// Whether is a server world.
public static bool IsServer(this WorldUnmanaged world)
{
return (world.Flags&WorldFlags.GameServer) == WorldFlags.GameServer;
@@ -691,6 +691,7 @@ public void OnDestroy(ref SystemState state)
{
--ClientServerBootstrap.WorldCounts.Data.clientWorlds;
ClientServerBootstrap.ThinClientWorlds.Remove(state.World);
+ AutomaticThinClientWorldsUtility.AutomaticallyManagedWorlds.Remove(state.World);
}
}
}
diff --git a/Runtime/ClientServerWorld/ClientServerTickRate.cs b/Runtime/ClientServerWorld/ClientServerTickRate.cs
index d090da1..4ad086d 100644
--- a/Runtime/ClientServerWorld/ClientServerTickRate.cs
+++ b/Runtime/ClientServerWorld/ClientServerTickRate.cs
@@ -19,7 +19,7 @@ namespace Unity.NetCode
/// 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.
+ /// - Create the entity in a custom Unity.NetCode.ClientServerBootstrap 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
@@ -63,7 +63,7 @@ namespace Unity.NetCode
///
///
/// -
- /// Once the client is connected, changes to the are not replicated. If you change the settings are runtime, the same change must
+ /// Once the client is connected, changes to the ClientServerTickRate are not replicated. If you change the settings are runtime, the same change must
/// be done on both client and server.
///
/// -
@@ -193,6 +193,68 @@ public bool SendSnapshotsForCatchUpTicks
[SerializeField]
private bool m_SendSnapshotsForCatchUpTicks;
+ ///
+ /// Netcode needs to store a history of snapshot acknowledgements ("acks") on the server - one per connection.
+ /// This denotes the size of said history buffer, in bits, and is exposed only to allow further patching of an esoteric
+ /// issue (see remarks). Default value is 4096 bits (0.5KB), which should prevent this issue in the common case.
+ /// Previous hardcoded default was 256 bits.
+ ///
+ ///
+ ///
+ /// Due to priority queue mechanics, increasing this value may fix errors where:
+ ///
+ /// - Static ghosts never stop resending.
+ /// -
+ /// Static and dynamic ghosts do not correctly find their 'baselines' (i.e. previously send and acked
+ /// values), when attempting delta-compression.
+ ///
+ ///
+ ///
+ ///
+ /// Per connection, per chunk, netcode stores up to 32 previous snapshots (and thus baselines, and their
+ /// acks) in a circular/ring buffer ( and
+ /// ). This ring-buffer appends an entry
+ /// every time the chunk is successfully serialized into a snapshot writer.
+ ///
+ ///
+ /// The problem is: When you have tens of thousands of relevant ghosts for a single connection
+ /// (a case we strongly advise against), the priority queue will only "bubble up" a chunk to be resent
+ /// after many tens of seconds. You can very loosely approximate the lower bound of this via
+ /// (((numGhosts/avgNumGhostsPerChunk)*averageSizeOfChunkInBytes)/transportMTU)/NetworkTickRate
+ /// E.g. 100k well optimized ghosts, sent at 30Hz (Simulation 60Hz), is (((100000/40)*1200)/1400)/30 = ~72s
+ /// to replicate them all once. I.e. ~4285 simulation ticks will have occurred since the client
+ /// was sent the previously sent snapshot.
+ ///
+ ///
+ /// Thus, when we check the ack buffer ~72 seconds later, the ack has long since been bit-shifted off
+ /// the end of the 256 tick history buffer. The simplest solution (implemented here) is to store
+ /// an ack buffer that is considerably larger. It is now 4096 entries by default (i.e. ~1.1 minutes at 60Hz),
+ /// and 1024 entries at a minimum (~17s at 60Hz), whereas the previous default was 256 (i.e. ~4.26s at 60Hz).
+ /// This field configures said capacity.
+ ///
+ ///
+ /// Because we are now able to find acks for snapshots sent over 4.26s ago, this fixed a
+ /// regression in delta-compression performance (as, previously, the baseline was found,
+ /// but treated as un-acked, thus unable to be used).
+ ///
+ ///
+ /// We also previously failed to mark this chunk as having 'no changes' (via isZeroChange),
+ /// as a ghost having 'no change' relies on its current value being compared to any of its acked
+ /// baseline values. This means we previously could not early out via CanUseStaticOptimization
+ /// (which looks for zero change). As a result, we frequently saw resending of previously acked
+ /// static ghosts in these circumstances (at least until the server so happens to try to resend
+ /// the same chunk within ticks of a previous ack).
+ ///
+ ///
+ /// Similarly, if you implemented configuration options like ,
+ /// we would delay processing of a chunk artificially. If this delay happened to exceed capacity,
+ /// the chunk (and its ghosts) can never possibly ack. Thankfully, SnapshotAckMaskCapacity
+ /// is now far higher than we'd ever recommend setting MinSendImportance.
+ ///
+ ///
+ [Tooltip("Denotes how many entries the snapshot ack history BitArray stores. Default value: 4096 bits. Min: 1024 bits.\n\nSolves an emergent problem when replicating tens of thousands of relevant static ghosts to a single connection - a case we strongly advise against. See XML doc.")]
+ public uint SnapshotAckMaskCapacity;
+
///
/// On the client, Netcode attempts to align its own fixed step with the render refresh rate, with the goal of
/// reducing Partial ticks, and increasing stability. This setting denotes the window (in %) to snap and align.
@@ -231,6 +293,7 @@ public bool SendSnapshotsForCatchUpTicks
internal const int DefaultMaxSimulationStepsPerFrame = 1;
internal const int DefaultMaxSimulationStepBatchSize = 4;
internal const int DefaultPredictedFixedStepSimulationTickRatio = 1;
+ internal const int DefaultHandshakeApprovalTimeoutMS = 5_000;
///
/// Set all the properties that haven't been changed by the user (or that have invalid ranges) to a proper default value.
@@ -250,10 +313,12 @@ public void ResolveDefaults()
MaxSimulationStepsPerFrame = DefaultMaxSimulationStepsPerFrame;
if (MaxSimulationStepBatchSize <= 0)
MaxSimulationStepBatchSize = DefaultMaxSimulationStepBatchSize;
+ if (SnapshotAckMaskCapacity == 0)
+ SnapshotAckMaskCapacity = 4096;
if (ClampPartialTicksThreshold == 0)
ClampPartialTicksThreshold = 5;
if (HandshakeApprovalTimeoutMS == 0)
- HandshakeApprovalTimeoutMS = 5_000;
+ HandshakeApprovalTimeoutMS = DefaultHandshakeApprovalTimeoutMS;
}
[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
@@ -283,10 +348,12 @@ internal readonly void ValidateAll(ref FixedList4096Bytes er
errors.Add($"{nameof(MaxSimulationStepsPerFrame)} must always be > 0");
if (MaxSimulationStepBatchSize <= 0)
errors.Add($"{nameof(MaxSimulationStepBatchSize)} must always be > 0");
+ if (SnapshotAckMaskCapacity < 1024)
+ errors.Add($"{nameof(SnapshotAckMaskCapacity)} has a minimum size of 1024");
if (ClampPartialTicksThreshold > 50)
errors.Add($"{nameof(ClampPartialTicksThreshold)} must always within be [-1, 50]");
if(HandshakeApprovalTimeoutMS < 1000)
- errors.Add($"{nameof(HandshakeApprovalTimeoutMS)} must be >= 1000ms.");
+ errors.Add($"{nameof(HandshakeApprovalTimeoutMS)} must be >= 1000ms");
// ReSharper restore ConditionIsAlwaysTrueOrFalse
}
@@ -297,6 +364,22 @@ internal readonly void ValidateAll(ref FixedList4096Bytes er
///
/// The snapshot send interval.
public int CalculateNetworkSendRateInterval() => (SimulationTickRate + NetworkTickRate - 1) / NetworkTickRate;
+
+ ///
+ /// Returns the as a interval UNTIL you can resend this chunk.
+ ///
+ /// From the GhostAuthoring.
+ /// The interval i.e. every nth tick.
+ public byte CalculateNetworkSendIntervalOfGhostInTicks(ushort MaxSendRate)
+ {
+ if (MaxSendRate == 0)
+ return 1; // Every SimulationTickRate tick.
+ var maxSendRateMs = 1000f / MaxSendRate; // E.g. 9hz 111ms
+ var networkTickRateDelayMS = 1000f / NetworkTickRate; // 60hz 16ms
+ return (byte)math.ceil((maxSendRateMs - 0.001f) / (networkTickRateDelayMS)); // = 111/16 = 6.9375 = 7
+ // = You send on every 7th tick
+ // i.e. you wait 6 ticks.
+ }
}
///
@@ -315,6 +398,8 @@ internal struct ClientServerTickRateRefreshRequest : IComponentData
public int MaxSimulationStepsPerFrame;
///
public int MaxSimulationStepBatchSize;
+ ///
+ public uint HandshakeApprovalTimeoutMS;
internal readonly void Serialize(ref DataStreamWriter writer, in StreamCompressionModel compressionModel)
{
@@ -323,6 +408,7 @@ internal readonly void Serialize(ref DataStreamWriter writer, in StreamCompressi
writer.WritePackedUIntDelta((uint) MaxSimulationStepBatchSize, ClientServerTickRate.DefaultMaxSimulationStepBatchSize, compressionModel);
writer.WritePackedUIntDelta((uint) MaxSimulationStepsPerFrame, ClientServerTickRate.DefaultMaxSimulationStepsPerFrame, compressionModel);
writer.WritePackedUIntDelta((uint) PredictedFixedStepSimulationTickRatio, ClientServerTickRate.DefaultPredictedFixedStepSimulationTickRatio, compressionModel);
+ writer.WritePackedUIntDelta((uint) HandshakeApprovalTimeoutMS, ClientServerTickRate.DefaultHandshakeApprovalTimeoutMS, compressionModel);
}
internal void Deserialize(ref DataStreamReader reader, in StreamCompressionModel compressionModel)
@@ -332,6 +418,7 @@ internal void Deserialize(ref DataStreamReader reader, in StreamCompressionModel
MaxSimulationStepBatchSize = (int) reader.ReadPackedUIntDelta(ClientServerTickRate.DefaultMaxSimulationStepBatchSize, compressionModel);
MaxSimulationStepsPerFrame = (int) reader.ReadPackedUIntDelta(ClientServerTickRate.DefaultMaxSimulationStepsPerFrame, compressionModel);
PredictedFixedStepSimulationTickRatio = (int) reader.ReadPackedUIntDelta(ClientServerTickRate.DefaultPredictedFixedStepSimulationTickRatio, compressionModel);
+ HandshakeApprovalTimeoutMS = reader.ReadPackedUIntDelta(ClientServerTickRate.DefaultHandshakeApprovalTimeoutMS, compressionModel);
}
public void ApplyTo(ref ClientServerTickRate tickRate)
@@ -341,6 +428,7 @@ public void ApplyTo(ref ClientServerTickRate tickRate)
tickRate.SimulationTickRate = SimulationTickRate;
tickRate.MaxSimulationStepBatchSize = MaxSimulationStepBatchSize;
tickRate.PredictedFixedStepSimulationTickRatio = PredictedFixedStepSimulationTickRatio;
+ tickRate.HandshakeApprovalTimeoutMS = HandshakeApprovalTimeoutMS;
}
public void ReadFrom(in ClientServerTickRate tickRate)
@@ -350,9 +438,25 @@ public void ReadFrom(in ClientServerTickRate tickRate)
MaxSimulationStepBatchSize = tickRate.MaxSimulationStepBatchSize;
SimulationTickRate = tickRate.SimulationTickRate;
PredictedFixedStepSimulationTickRatio = tickRate.PredictedFixedStepSimulationTickRatio;
+ HandshakeApprovalTimeoutMS = tickRate.HandshakeApprovalTimeoutMS;
}
}
+ ///
+ /// Configure when the prediction loop should run on the client.
+ ///
+ public enum PredictionLoopUpdateMode
+ {
+ ///
+ /// The prediction loop will run the prediction systems only if there is at least one predicted ghost spawned on the client.
+ ///
+ RequirePredictedGhost,
+ ///
+ /// The prediction loop will always run, regardless of whether or not any predicted ghosts are spawned on the client.
+ ///
+ AlwaysRun
+ }
+
///
/// Create a ClientTickRate singleton in the client world (either at runtime or by loading it from sub-scene)
/// to configure all the network time synchronization, interpolation delay, prediction batching and other setting for the client.
@@ -432,6 +536,19 @@ public struct ClientTickRate : IComponentData
[Range(0, 16)]
public int MaxPredictionStepBatchSizeFirstTimeTick;
///
+ /// Configure how the client should run the prediction loop systems. By default, the client runs the systems inside the (and consequently also the ones in )
+ /// only if there are predicted ghosts in the world. This is a good behaviour in general, as it saves some CPU cycles. However, it can be unintuitive, as there are situations where you would like to have these systems always run. For example:
+ /// >
+ /// - You would like to ray cast against the physics world, even in cases where there are only interpolated ghosts and/or static geometry present. I.e. In order to spawn a predicted ghost in first place, you need to raycast against the static geometry.
+ /// - You want some systems to act on both interpolated and predicted ghosts (and run in the same group, with certain caveats, of course). An example could be a "dead-reckoned" static, interpolated ghost that rarely updates (i.e. it has very low importance).
+ ///
+ /// It is important to understand the implications of selecting the alternative mode, , especially from a CPU cost perspective. In that case, because the systems will run all the time,
+ /// it is fundamental to prevent doing work when said work is un-necessary. Example: Scheduling jobs with empty queries. While it is, in general, already the case that most of the idiomatic foreach and jobs etc are going to be a no-op,
+ /// you may still incur some extra CPU overhead, just because of the systems update. Best practice is to use RequireForUpdate (or similar) checks, as preconditions for the system to run.
+ ///
+ [Tooltip("Denotes if the client should run the prediction loop systems, even if no predicted ghosts are present in the client world. By default, the client doesn't run the systems inside the PredictedSimulationSystemGroup (and consequently, nor the ones in PredictedFixedStepSimulationSystemGroup) if there are no predicted ghosts.\n\nThis is a good behaviour in general, that saves some CPU cycles. However, it may be unintuitive, as there are situations where you would like to have these systems always run. For example:\n\n - You would like to ray cast against the physics world, even in cases where there are only interpolated ghosts and/or static geometry present. I.e. In order to spawn a predicted ghost in first place, you need to raycast against the static geometry.\n\n - You want some systems to act on both interpolated and predicted ghosts (and run in the same group, with certain caveats, of course). An example could be a \"dead-reckoned\" static, interpolated ghost that rarely updates (i.e. it has very low importance).")]
+ public PredictionLoopUpdateMode PredictionLoopUpdateMode;
+ ///
/// Multiplier used to compensate received snapshot rate jitter when calculating the Interpolation Delay.
/// Default Value: 1.25.
///
@@ -517,5 +634,19 @@ public struct ClientTickRate : IComponentData
[Tooltip("PredictionTick time scale max value.\n\nDefaults to 1.1. Recommended range is (1.05 - 1.2).\n\nNote: It is not mandatory to have the min and max values symmetric.")]
[Range(1f, 2f)]
public float PredictionTimeScaleMax;
+
+ /// The size of the interpolation window.
+ /// The current struct value.
+ /// Value in Ticks.
+ public int CalculateInterpolationBufferTimeInTicks(in ClientServerTickRate tickRate)
+ {
+ if (InterpolationTimeMS != 0)
+ return (int)((InterpolationTimeMS * tickRate.NetworkTickRate + 999) / 1000);
+ return (int) InterpolationTimeNetTicks;
+ }
+ /// The size of the interpolation window.
+ /// The current struct value.
+ /// Value in milliseconds.
+ public float CalculateInterpolationBufferTimeInMs(in ClientServerTickRate tickRate) => CalculateInterpolationBufferTimeInTicks(in tickRate) * tickRate.SimulationFixedTimeStep * 1000;
}
}
diff --git a/Runtime/Command/CommandReceiveSystem.cs b/Runtime/Command/CommandReceiveSystem.cs
index 915e31f..eb618e3 100644
--- a/Runtime/Command/CommandReceiveSystem.cs
+++ b/Runtime/Command/CommandReceiveSystem.cs
@@ -303,8 +303,8 @@ private NetworkTick ReadTickDeltaCompressed(ref DataStreamReader reader, ref Net
/// enqueued by either using the target entity or via
/// if enabled.
///
- ///
- ///
+ /// Chunk containing commands to decode
+ /// Order index
public unsafe void Execute(ArchetypeChunk chunk, int orderIndex)
{
var snapshotAcks = chunk.GetNativeArray(ref snapshotAckType);
diff --git a/Runtime/Command/CommandSendSystem.cs b/Runtime/Command/CommandSendSystem.cs
index 564c80c..0898bf8 100644
--- a/Runtime/Command/CommandSendSystem.cs
+++ b/Runtime/Command/CommandSendSystem.cs
@@ -1,6 +1,7 @@
#if UNITY_EDITOR && !NETCODE_NDEBUG
#define NETCODE_DEBUG
#endif
+using System;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
@@ -143,10 +144,10 @@ protected override void OnUpdate()
var clientNetTime = SystemAPI.GetSingleton();
var targetTick = NetworkTimeHelper.LastFullServerTick(clientNetTime);
// Make sure we only send a single ack per tick - only triggers when using dynamic timestep
- if (targetTick == m_LastServerTick)
- return;
+ if (targetTick.IsValid && targetTick != m_LastServerTick)
+ base.OnUpdate();
m_LastServerTick = targetTick;
- base.OnUpdate();
+
}
}
@@ -247,6 +248,7 @@ public unsafe void Execute(DynamicBuffer rpcDat
netDebug.LogError($"CommandSendPacket EndSend failed with errorCode: {result} on {connection.Value.ToFixedString()}!");
}
}
+
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
diff --git a/Runtime/Command/CommandTarget.cs b/Runtime/Command/CommandTarget.cs
index c418d18..9bf6d27 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 support thin-clients (because does not work in that case)
- /// The use of and CommandTarget is complementary. I.e. They can both be used
+ ///
- You are not using the AutoCommandTarget.
+ /// - You want to support thin-clients (because AutoCommandTarget does not work in that case)
+ /// The use of AutoCommandTarget and CommandTarget is complementary. I.e. They can both be used
/// at the same time.
///
///
diff --git a/Runtime/Command/ICommandData.cs b/Runtime/Command/ICommandData.cs
index 9c469c3..67aed22 100644
--- a/Runtime/Command/ICommandData.cs
+++ b/Runtime/Command/ICommandData.cs
@@ -1,10 +1,12 @@
using System;
using System.Runtime.CompilerServices;
using System.Text;
+using Unity.Burst.CompilerServices;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.NetCode.LowLevel.Unsafe;
+using UnityEngine;
namespace Unity.NetCode
{
@@ -81,7 +83,7 @@ public interface ICommandData : IBufferElementData
/// If you enable manual serializaton, you must create a public struct that implement the ICommandDataSerializer for your type, as
/// well as the necessary send and received systems in order to have your RPC sent and received.
///
- ///
+ /// Your data type.
public interface ICommandDataSerializer where T: unmanaged, ICommandData
{
///
@@ -90,7 +92,7 @@ public interface ICommandDataSerializer where T: unmanaged, ICommandData
/// An instance of a
/// An instance of used to carry some additional data and accessor
/// for serializing the command field type. In particular, used to serialize entity
- ///
+ /// Command
void Serialize(ref DataStreamWriter writer, in RpcSerializerState state, in T data);
///
/// Deserialize a single command from the data stream.
@@ -98,7 +100,7 @@ public interface ICommandDataSerializer where T: unmanaged, ICommandData
/// An instance of a
/// An instance of used to carry some additional data and accessor
/// for serializing the command field type. In particular, used to serialize entity
- ///
+ /// Command
void Deserialize(ref DataStreamReader reader, in RpcDeserializerState state, ref T data);
///
@@ -107,9 +109,9 @@ public interface ICommandDataSerializer where T: unmanaged, ICommandData
/// An instance of a
/// An instance of used to carry some additional data and accessor
/// for serializing the command field type. In particular, used to serialize entity
- ///
- ///
- ///
+ /// Command
+ /// Baseline command
+ /// Delta compression model
void Serialize(ref DataStreamWriter writer, in RpcSerializerState state, in T data, in T baseline, StreamCompressionModel compressionModel);
///
@@ -118,9 +120,9 @@ public interface ICommandDataSerializer where T: unmanaged, ICommandData
/// An instance of a
/// An instance of used to carry some additional data and accessor
/// for serializing the command field type. In particular, used to serialize entity
- ///
- ///
- ///
+ /// Command
+ /// Baseline command
+ /// Delta compression model
void Deserialize(ref DataStreamReader reader, in RpcDeserializerState state, ref T data, in T baseline, StreamCompressionModel compressionModel);
///
@@ -205,9 +207,9 @@ public static bool GetDataAtTick(this DynamicBuffer commandArray, NetworkT
/// 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.
///
- ///
- ///
- ///
+ /// Buffer to index
+ /// index to get input
+ /// the command type
/// A readonly reference to the element
public static ref readonly T GetInputAtIndex(this DynamicBuffer buffer, int index) where T: unmanaged, ICommandData
{
@@ -227,6 +229,9 @@ public static ref readonly T GetInputAtIndex(this DynamicBuffer buffer, in
public static bool AddCommandData(this DynamicBuffer commandBuffer, T commandData)
where T : unmanaged, ICommandData
{
+ if (Hint.Unlikely(!commandData.Tick.IsValid))
+ return false;
+
var targetTick = commandData.Tick;
int oldestIdx = 0;
NetworkTick oldestTick = NetworkTick.Invalid;
diff --git a/Runtime/Command/IInputComponentData.cs b/Runtime/Command/IInputComponentData.cs
index b249f9c..76587b4 100644
--- a/Runtime/Command/IInputComponentData.cs
+++ b/Runtime/Command/IInputComponentData.cs
@@ -85,8 +85,8 @@ public interface IInputBufferData : ICommandData
/// For internal use only, helper struct that should be used to implement systems that copy the content of an
/// into the code-generated buffer.
///
- ///
- ///
+ /// input buffer data
+ /// Input component data
[Obsolete("CopyInputToCommandBuffer has been deprecated. There is no replacement, being the method meant to be used only by code-generated systems.", false)]
public partial struct CopyInputToCommandBuffer
where TInputBufferData : unmanaged, IInputBufferData
@@ -102,8 +102,8 @@ public struct CopyInputToBufferJob
/// Implements the component copy and input event management.
/// Should be called your job method.
///
- ///
- ///
+ /// chunk
+ /// order index
public void Execute(ArchetypeChunk chunk, int orderIndex)
{
}
@@ -113,7 +113,7 @@ public void Execute(ArchetypeChunk chunk, int orderIndex)
/// Initialize the CopyInputToCommandBuffer by updating all the component type handles and create a
/// a new instance.
///
- ///
+ ///
/// a new instance.
public CopyInputToBufferJob InitJobData(ref SystemState state)
{
@@ -124,12 +124,12 @@ public CopyInputToBufferJob InitJobData(ref SystemState state)
/// 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.
///
- ///
- ///
- ///
+ ///
+ /// Query for component type handles
public EntityQuery Create(ref SystemState state)
{
return default;
@@ -141,8 +141,8 @@ public EntityQuery Create(ref SystemState state)
/// commands from the buffer to the component
/// present on the entity.
///
- ///
- ///
+ /// Input buffer data
+ /// Input component data
[Obsolete("ApplyCurrentInputBufferElementToInputData has been deprecated. There is no replacement, being the method meant to be used only by code-generated systems.", false)]
public partial struct ApplyCurrentInputBufferElementToInputData
where TInputBufferData : unmanaged, IInputBufferData
@@ -159,8 +159,8 @@ public struct ApplyInputDataFromBufferJob
/// Copy the command for current server tick to the input component.
/// Should be called your job method.
///
- ///
- ///
+ /// Chunk
+ /// Order index
public void Execute(ArchetypeChunk chunk, int orderIndex)
{
}
@@ -170,7 +170,7 @@ public void Execute(ArchetypeChunk chunk, int orderIndex)
/// Update the component type handles and create a new
/// that can be passed to your job.
///
- ///
+ ///
/// a new instance.
public ApplyInputDataFromBufferJob InitJobData(ref SystemState state)
{
@@ -216,7 +216,7 @@ public struct InputBufferData : ICommandData where T: unmanaged, IInputCompon
/// Internal use only, interface implemented by code-generated helpers to increment and decrement
/// events when copy to/from the underlying
///
- ///
+ /// Input component type
public interface IInputEventHelper where T: unmanaged, IInputComponentData
{
///
diff --git a/Runtime/Command/InputCommandSystems.cs b/Runtime/Command/InputCommandSystems.cs
index bf322ff..e5b51c3 100644
--- a/Runtime/Command/InputCommandSystems.cs
+++ b/Runtime/Command/InputCommandSystems.cs
@@ -12,8 +12,8 @@ namespace Unity.NetCode
/// buffer. The job is also responsible to increment the counters, in case the input
/// component contains input events.
///
- ///
- ///
+ /// Input component data
+ /// Input helper
[BurstCompile]
public struct CopyInputToBufferJob : IJobChunk
where TInputComponentData : unmanaged, IInputComponentData
@@ -28,10 +28,10 @@ public struct CopyInputToBufferJob : IJobChun
///
/// Copy the input component for current server tick to the command buffer.
///
- ///
- ///
- ///
- ///
+ /// Chunk
+ /// Chunk index
+ /// Should use enabled
+ /// Chunk enabled mask
[BurstCompile]
public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
{
@@ -70,8 +70,8 @@ public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useE
/// For internal use only, system that that copy the content of an into
/// buffer present on the entity.
///
- ///
- ///
+ /// Input component data
+ /// Input helper
[BurstCompile]
[UpdateInGroup(typeof(CopyInputToCommandBufferSystemGroup))]
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
@@ -124,8 +124,8 @@ public void OnUpdate(ref SystemState state)
/// For internal use only, system that copies commands from the buffer
/// to the component present on the entity.
///
- ///
- ///
+ /// Input component data
+ /// Input helper
// 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]
@@ -182,8 +182,8 @@ public void OnUpdate(ref SystemState state)
/// since last tick (or batch, see also ) are correctly reported as
/// set (see
///
- ///
- ///
+ /// Input component data
+ /// Input helper
[BurstCompile]
public struct ApplyInputDataFromBufferJob : IJobChunk
where TInputComponentData : unmanaged, IInputComponentData
@@ -197,10 +197,10 @@ public struct ApplyInputDataFromBufferJob : I
///
/// Copy the command for current server tick to the input component.
///
- ///
- ///
- ///
- ///
+ /// Chunk
+ /// Chunk index
+ /// Should use enabled
+ /// Chunk enabled mask
[BurstCompile]
public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
{
diff --git a/Runtime/Connection/DefaultDriverConstructor.cs b/Runtime/Connection/DefaultDriverConstructor.cs
index 9c6755b..17427bb 100644
--- a/Runtime/Connection/DefaultDriverConstructor.cs
+++ b/Runtime/Connection/DefaultDriverConstructor.cs
@@ -2,15 +2,13 @@
using Unity.Assertions;
using Unity.Collections;
using Unity.Entities;
-#if UNITY_EDITOR
-using Unity.NetCode.Analytics;
-#endif
using Unity.Networking.Transport;
#if ENABLE_MANAGED_UNITYTLS
using Unity.Networking.Transport.TLS;
#endif
using Unity.Networking.Transport.Relay;
using Unity.Networking.Transport.Utilities;
+using UnityEngine;
namespace Unity.NetCode
{
@@ -29,17 +27,22 @@ public static class DefaultDriverBuilder
///
public static INetworkStreamDriverConstructor DefaultDriverConstructor => new IPCAndSocketDriverConstructor();
+ ///
+ //[Obsolete("Renamed `GetNetworkClientSettings` (RemovedAfter 2.0). (UnityUpgradable) -> GetNetworkClientSettings(*)", false)]
+ public static NetworkSettings GetNetworkSettings() => GetNetworkClientSettings();
+
///
- /// Return a set of internal default settings. This will use the NetworkSimulator parameters set by PlayMode Tools.
+ /// Return a set of default settings for the client world. This will use the NetworkSimulator parameters set by PlayMode Tools.
///
- /// A new
- public static NetworkSettings GetNetworkSettings()
+ /// A new instance.
+ public static NetworkSettings GetNetworkClientSettings()
{
var settings = new NetworkSettings();
settings.WithReliableStageParameters(windowSize: DefaultWindowSize)
.WithFragmentationStageParameters(payloadCapacity: DefaultPayloadCapacity);
+
+ AddNetcodePackageNetworkConfigParameters(ref settings, isServer:false);
#if UNITY_EDITOR || NETCODE_DEBUG
- settings.WithNetworkConfigParameters(maxFrameTimeMS: MaxFrameTimeMS);
if (NetworkSimulatorSettings.Enabled)
{
NetworkSimulatorSettings.SetSimulatorSettings(ref settings);
@@ -48,39 +51,87 @@ public static NetworkSettings GetNetworkSettings()
return settings;
}
+ ///
+ //[Obsolete("Removed playerCount (RemovedAfter 2.0). (UnityUpgradable) -> GetNetworkServerSettings(*)", false)]
+ public static NetworkSettings GetNetworkServerSettings(int playerCount = 0)
+ {
+ return GetNetworkServerSettings();
+ }
+
///
/// 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.
- public static NetworkSettings GetNetworkServerSettings(int playerCount = 0)
+ public static NetworkSettings GetNetworkServerSettings()
{
var settings = new NetworkSettings();
settings.WithReliableStageParameters(windowSize: DefaultWindowSize)
.WithFragmentationStageParameters(payloadCapacity: DefaultPayloadCapacity);
-#if UNITY_EDITOR || NETCODE_DEBUG
- settings.WithNetworkConfigParameters(maxFrameTimeMS: MaxFrameTimeMS,
- receiveQueueCapacity: QueueSizeFromPlayerCount(playerCount),
- sendQueueCapacity: QueueSizeFromPlayerCount(playerCount));
-#else
+ AddNetcodePackageNetworkConfigParameters(ref settings, isServer:true);
+ return settings;
+ }
+
+ ///
+ /// Helper: Adds all netcode-package specific settings
+ /// for the struct.
+ ///
+ /// The settings to inject into.
+ /// Settings differ for server worlds.
+ public static void AddNetcodePackageNetworkConfigParameters(ref NetworkSettings settings, bool isServer)
+ {
+ var config = NetCodeConfig.Global;
+ // TODO - Add support in Transport to fetch the default struct directly, so we don't miss any fields.
+ var ncp = new NetworkConfigParameter
+ {
+ connectTimeoutMS = NetworkParameterConstants.ConnectTimeoutMS,
+ maxConnectAttempts = NetworkParameterConstants.MaxConnectAttempts,
+ disconnectTimeoutMS = NetworkParameterConstants.DisconnectTimeoutMS,
+ heartbeatTimeoutMS = NetworkParameterConstants.HeartbeatTimeoutMS,
+ reconnectionTimeoutMS = NetworkParameterConstants.ReconnectionTimeoutMS,
+ maxMessageSize = NetworkParameterConstants.MaxMessageSize,
+ receiveQueueCapacity = NetworkParameterConstants.ReceiveQueueCapacity,
+ sendQueueCapacity = NetworkParameterConstants.SendQueueCapacity,
+ };
+ if (config)
+ {
+ ncp.connectTimeoutMS = config.ConnectTimeoutMS;
+ ncp.maxConnectAttempts = config.MaxConnectAttempts;
+ ncp.disconnectTimeoutMS = config.DisconnectTimeoutMS;
+ ncp.heartbeatTimeoutMS = config.HeartbeatTimeoutMS;
+ ncp.reconnectionTimeoutMS = config.ReconnectionTimeoutMS;
+ ncp.maxMessageSize = config.MaxMessageSize;
+ ncp.receiveQueueCapacity = isServer ? config.ServerReceiveQueueCapacity : config.ClientReceiveQueueCapacity;
+ ncp.sendQueueCapacity = isServer ? config.ServerSendQueueCapacity : config.ClientSendQueueCapacity;
+ }
+
+ // We use this method instead of the raw struct option because - if UTP add new fields,
+ // this constructor will pick it up, a raw struct won't.
settings.WithNetworkConfigParameters(
- receiveQueueCapacity: QueueSizeFromPlayerCount(playerCount),
- sendQueueCapacity: QueueSizeFromPlayerCount(playerCount));
+#if UNITY_EDITOR || NETCODE_DEBUG
+ maxFrameTimeMS: MaxFrameTimeMS,
#endif
- return settings;
+ connectTimeoutMS: ncp.connectTimeoutMS,
+ maxConnectAttempts: ncp.maxConnectAttempts,
+ disconnectTimeoutMS: ncp.disconnectTimeoutMS,
+ heartbeatTimeoutMS: ncp.heartbeatTimeoutMS,
+ reconnectionTimeoutMS: ncp.reconnectionTimeoutMS,
+ maxMessageSize: ncp.maxMessageSize,
+ receiveQueueCapacity: ncp.receiveQueueCapacity,
+ sendQueueCapacity: ncp.sendQueueCapacity
+ );
}
///
/// Helper method for creating NetworkDriver suitable for client.
/// The driver will use the the specified and is configured
- /// using the internal defaults. See: .
+ /// using the internal defaults. See: .
///
/// the type ot use
/// the instance of a to use to create the driver
/// A new
public static NetworkDriverStore.NetworkDriverInstance CreateClientNetworkDriver(T netIf) where T : unmanaged, INetworkInterface
{
- return CreateClientNetworkDriver(netIf, GetNetworkSettings());
+ return CreateClientNetworkDriver(netIf, GetNetworkClientSettings());
}
///
@@ -98,41 +149,37 @@ public static NetworkDriverStore.NetworkDriverInstance CreateClientNetworkDriver
#if UNITY_EDITOR || NETCODE_DEBUG
if (NetworkSimulatorSettings.Enabled)
{
- driverInstance.simulatorEnabled = true;
driverInstance.driver = NetworkDriver.Create(netIf, settings);
CreateClientSimulatorPipelines(ref driverInstance);
}
else
#endif
{
- driverInstance.simulatorEnabled = false;
driverInstance.driver = NetworkDriver.Create(netIf, settings);
CreateClientPipelines(ref driverInstance);
}
return driverInstance;
}
- private static int QueueSizeFromPlayerCount(int playerCount)
+#if !UNITY_WEBGL || UNITY_EDITOR
+
+ ///
+ //[Obsolete("Removed playerCount (RemovedAfter 2.0). (UnityUpgradable) -> CreateServerNetworkDriver(*)", false)]
+ public static NetworkDriverStore.NetworkDriverInstance CreateServerNetworkDriver(T netIf, int playerCount = 0) where T : unmanaged, INetworkInterface
{
- if (playerCount == 0)
- {
- playerCount = 16;
- }
- return playerCount * 4;
+ return CreateServerNetworkDriver(netIf);
}
-#if !UNITY_WEBGL || UNITY_EDITOR
///
/// Helper method for creating server NetworkDriver given the specified .
/// The driver is configured with the internal defaults. See: .
///
/// the type ot use
/// the instance of a to use to create the driver
- /// Amount of players the server should allocate receive and send queue for. The estimation is that each player will receive 4 packets.
/// A new
- public static NetworkDriverStore.NetworkDriverInstance CreateServerNetworkDriver(T netIf, int playerCount = 0) where T : unmanaged, INetworkInterface
+ public static NetworkDriverStore.NetworkDriverInstance CreateServerNetworkDriver(T netIf) where T : unmanaged, INetworkInterface
{
- return CreateServerNetworkDriver(netIf, GetNetworkServerSettings(playerCount));
+ return CreateServerNetworkDriver(netIf, GetNetworkServerSettings());
}
///
@@ -140,7 +187,7 @@ public static NetworkDriverStore.NetworkDriverInstance CreateServerNetworkDriver
/// The driver is configured using the
///
/// The type to use
- ///
+ /// the instance of a to use to create the driver
/// A list of the parameters that describe the network configuration.
/// A new
public static NetworkDriverStore.NetworkDriverInstance CreateServerNetworkDriver(T netIf, NetworkSettings settings) where T : unmanaged, INetworkInterface
@@ -161,7 +208,7 @@ public static NetworkDriverStore.NetworkDriverInstance CreateServerNetworkDriver
/// 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).
///
- ///
+ /// The singleton, for logging errors and debug information
/// 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)
@@ -180,10 +227,7 @@ public static bool ClientUseSocketDriver(NetDebug netDebug)
{
return true;
}
- //PlayMode is client server the simulator is disabled. We are in client-server mode
- Assert.IsTrue(ClientServerBootstrap.RequestedPlayType == ClientServerBootstrap.PlayType.ClientAndServer);
- netDebug.DebugLog("[DefaultDriverConstructor.ClientUseSocketDriver] RequestedPlayType is ClientAndServer, so looking for a server world instance in the same process.");
-
+ netDebug.DebugLog("[DefaultDriverConstructor.ClientUseSocketDriver] RequestedPlayType is ClientAndServer Or Server, so looking for a server world instance in the same process.");
if (ClientServerBootstrap.ServerWorld != null && ClientServerBootstrap.ServerWorld.IsCreated)
{
netDebug.DebugLog("[DefaultDriverConstructor.ClientUseSocketDriver] Found server world instance. Thus, preferring IPC network interface.");
@@ -193,36 +237,35 @@ public static bool ClientUseSocketDriver(NetDebug netDebug)
return true;
}
-
///
/// Register a NetworkDriver instance in the that uses either:
///
- /// - a single NetworkDriver if both the 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.
+ /// - a single IPCNetworkInterface NetworkDriver if both the client and server worlds are present in the same process.
+ /// - a single UDPNetworkInterface driver if you are targeting a standalone platform.
+ /// - a single WebSocketNetworkInterface if you are targeting WebGL.
///
- /// These are configured using internal defaults. See: .
+ /// 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.
+ /// The singleton, for logging errors and debug information
public static void RegisterClientDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug)
{
- RegisterClientDriver(world, ref driverStore, netDebug, GetNetworkSettings());
+ RegisterClientDriver(world, ref driverStore, netDebug, GetNetworkClientSettings());
}
///
/// Register a NetworkDriver instance in the that uses either:
///
- /// - a single NetworkDriver if both the 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.
+ /// - a single IPCNetworkInterface NetworkDriver if both the client and server worlds are present in the same process.
+ /// - a single UDPNetworkInterface driver if you are targeting a standalone platform.
+ /// - a single WebSocketNetworkInterface if you are targeting WebGL.
///
/// These are configured using the passed in.
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// A list of the parameters that describe the network configuration.
public static void RegisterClientDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings)
{
@@ -247,11 +290,10 @@ public static void RegisterClientDriver(World world, ref NetworkDriverStore driv
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// A list of the parameters that describe the network configuration.
public static void RegisterClientUdpDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings)
{
- Assert.IsTrue(ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.Server);
Assert.IsTrue(world.IsClient());
netDebug.DebugLog("[DefaultDriverConstructor.RegisterClientUdpDriver] Creating the client default UDP socket network interface driver.");
var driverInstance = DefaultDriverBuilder.CreateClientNetworkDriver(new UDPNetworkInterface(), settings);
@@ -266,18 +308,16 @@ public static void RegisterClientUdpDriver(World world, ref NetworkDriverStore d
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// 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.
@@ -288,7 +328,6 @@ public static void RegisterClientWebSocketDriver(World world, ref NetworkDriverS
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.
@@ -304,11 +343,10 @@ public static void RegisterClientWebSocketDriver(World world, ref NetworkDriverS
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// A list of the parameters that describe the network configuration.
public static void RegisterClientIpcDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, NetworkSettings settings)
{
- Assert.IsTrue(ClientServerBootstrap.RequestedPlayType != ClientServerBootstrap.PlayType.Server);
Assert.IsTrue(world.IsClient());
netDebug.DebugLog("[DefaultDriverConstructor.RegisterClientIpcDriver] Creating the client default IPC network interface driver.");
var driverInstance = DefaultDriverBuilder.CreateClientNetworkDriver(new IPCNetworkInterface(), settings);
@@ -316,36 +354,42 @@ public static void RegisterClientIpcDriver(World world, ref NetworkDriverStore d
}
#if !UNITY_WEBGL || UNITY_EDITOR
+ ///
+ //[Obsolete("Removed playerCount (RemovedAfter 2.0). (UnityUpgradable) -> RegisterServerDriver(*)", false)]
+ public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, int playerCount = 0)
+ {
+ RegisterServerDriver(world, ref driverStore, netDebug);
+ }
+
///
/// Register multiple NetworkDriver instances to the that uses different :
///
- /// - One driver that uses if the `ClientServerBootstrap.RequestedPlayType` 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.
+ /// - One driver that uses `IPCNetworkInterface` if the `ClientServerBootstrap.RequestedPlayType` is Client/Server.
+ /// - One driver that uses `UDPNetworkInterface` if the current build target is a standalone platorm (no WebGL) or dedicated server.
+ /// - One driver that uses `WebSocketNetworkInterface` if the current build target is WebGL.
///
- /// These are configured using internal defaults. See: .
+ /// 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.
- /// Amount of players the server should allocate receive and send queue for. The estimation is that each player will receive 4 packets.
- public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, int playerCount = 0)
+ /// The singleton, for logging errors and debug information
+ public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug)
{
- RegisterServerDriver(world, ref driverStore, netDebug, GetNetworkServerSettings(playerCount: playerCount));
+ RegisterServerDriver(world, ref driverStore, netDebug, GetNetworkServerSettings());
}
///
/// Register a multiple NetworkDriver instances to hte :
///
- /// - One driver that uses if the `ClientServerBootstrap.RequestedPlayType` 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.
+ /// - One driver that uses `IPCNetworkInterface` if the `ClientServerBootstrap.RequestedPlayType` is Client/Server.
+ /// - One driver that uses `UDPNetworkInterface` if the current build target is a standalone platorm (no WebGL) or dedicated server.
+ /// - One driver that uses `WebSocketNetworkInterface` 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.
+ /// The singleton, for logging errors and debug information
/// 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)
@@ -361,23 +405,15 @@ public static void RegisterServerDriver(World world, ref NetworkDriverStore driv
///
/// Register a NetworkDriver instance in .
/// This are configured using the passed in.
- ///
- /// If the requested is
- /// this will do nothing as no local clients will ever make use of the IPC mechanism.
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// 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)
{
Assert.IsTrue(world.IsServer());
- if (ClientServerBootstrap.RequestedPlayType == ClientServerBootstrap.PlayType.Server)
- {
- return;
- }
-
netDebug.DebugLog("[DefaultDriverConstructor.RegisterServerIpcDriver] Creating the server default IPC network interface driver.");
var ipcDriver = CreateServerNetworkDriver(new IPCNetworkInterface(), settings);
driverStore.RegisterDriver(TransportType.IPC, ipcDriver);
@@ -389,7 +425,7 @@ public static void RegisterServerIpcDriver(World world, ref NetworkDriverStore d
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// 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)
@@ -408,7 +444,7 @@ public static void RegisterServerUdpDriver(World world, ref NetworkDriverStore d
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// 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,
@@ -475,40 +511,47 @@ public static void CreateClientSimulatorPipelines(ref NetworkDriverStore.Network
/// 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.
- /// These are configured using the default settings. See .
+ /// These are configured using the default settings. See .
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// Signed server certificate.
/// Common name in the server certificate.
public static void RegisterClientDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref FixedString4096Bytes caCertificate, ref FixedString512Bytes serverName)
{
- var settings = GetNetworkSettings();
+ var settings = GetNetworkClientSettings();
settings = settings.WithSecureClientParameters(caCertificate: ref caCertificate, serverName: ref serverName);
RegisterClientDriver(world, ref driverStore, netDebug, settings);
}
#if !UNITY_WEBGL || UNITY_EDITOR
+ ///
+ //[Obsolete("Removed default parameter `GetNetworkClientSettings` (RemovedAfter 2.0). (UnityUpgradable) -> RegisterServerDriver(*)", false)]
+ public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug,
+ ref FixedString4096Bytes certificate, ref FixedString4096Bytes privateKey, int playerCount = 0)
+ {
+ RegisterServerDriver(world, ref driverStore, netDebug, ref certificate, ref privateKey);
+ }
+
///
/// 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
- /// .
+ /// - One driver that uses IPCNetworkInterface if the ClientServerBootstrap.RequestedPlayType is Client/Server.
+ /// - For all targets apart WebGL, one driver instance using a UDPNetworkInterface. For WebGL and in the Editor, one driver instance using the
+ /// WebSocketNetworkInterface.
///
/// These are configured using the default settings. See .
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
///
///
- /// 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)
+ public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref FixedString4096Bytes certificate, ref FixedString4096Bytes privateKey)
{
- var settings = GetNetworkServerSettings(playerCount: playerCount);
+ var settings = GetNetworkServerSettings();
settings = settings.WithSecureServerParameters(certificate: ref certificate, privateKey: ref privateKey);
RegisterServerDriver(world, ref driverStore, netDebug, settings);
}
@@ -518,15 +561,15 @@ public static void RegisterServerDriver(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.
- /// These are configured using the default settings. See .
+ /// These are configured using the default settings. See .
///
/// Used for determining whether we are running in a client or server world.
/// Store for NetworkDriver.
- /// For handling logging.
+ /// The singleton, for logging errors and debug information
/// Server information to make a connection using a relay server.
public static void RegisterClientDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref RelayServerData relayData)
{
- var settings = GetNetworkSettings();
+ var settings = GetNetworkClientSettings();
if (ClientUseSocketDriver(netDebug))
{
settings = settings.WithRelayParameters(ref relayData);
@@ -535,24 +578,30 @@ public static void RegisterClientDriver(World world, ref NetworkDriverStore driv
}
#if UNITY_EDITOR || !UNITY_WEBGL
+ ///
+ //[Obsolete("Removed playerCount (RemovedAfter 2.0). (UnityUpgradable) -> RegisterServerDriver(*)", false)]
+ public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref RelayServerData relayData, int playerCount = 0)
+ {
+ RegisterServerDriver(world, ref driverStore, netDebug, ref relayData);
+ }
+
///
/// 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.
+ /// - One driver that uses IPCNetworkInterface if the ClientServerBootstrap.RequestedPlayType is Client/Server.
+ /// - One driver that uses UDPNetworkInterface if the current build target is a standalone platorm (no WebGL) or dedicated server.
+ /// - One driver that uses WebSocketNetworkInterface if the current build target is WebGL.
///
- /// These are configured using internal defaults. See: .
+ /// 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.
+ /// The singleton, for logging errors and debug information
/// 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)
+ public static void RegisterServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug, ref RelayServerData relayData)
{
- var settings = GetNetworkServerSettings(playerCount: playerCount);
+ var settings = GetNetworkServerSettings();
RegisterServerIpcDriver(world, ref driverStore, netDebug, settings);
settings = settings.WithRelayParameters(ref relayData);
RegisterServerUdpDriver(world, ref driverStore, netDebug, settings);
diff --git a/Runtime/Connection/NetworkDriverStore.cs b/Runtime/Connection/NetworkDriverStore.cs
index da39780..35bfcd0 100644
--- a/Runtime/Connection/NetworkDriverStore.cs
+++ b/Runtime/Connection/NetworkDriverStore.cs
@@ -7,6 +7,7 @@
using Unity.Entities;
using Unity.Jobs;
using Unity.Networking.Transport;
+using Unity.Networking.Transport.Utilities;
namespace Unity.NetCode
{
@@ -16,16 +17,16 @@ namespace Unity.NetCode
public enum TransportType : int
{
///
- /// Not configured, or unsupported tramsport interface. The transport type for a registered driver instance
- /// is always valid, unless the driver creation fail.
+ /// Not configured, or unsupported transport interface. The transport type for a registered driver instance
+ /// is always valid (not this value, in other words), unless the driver creation failed.
///
Invalid = 0,
///
- /// An inter-process like communication channel with 0 latency and guaratee delivery.
+ /// An inter-process like communication channel with zero latency, and guaranteed delivery.
///
IPC,
///
- /// A socket based communication channel. WebSocket, UDP, TCP or any similar communication channel fit that category.
+ /// A socket based communication channel. WebSocket, UDP, TCP or any similar communication channels fit that category.
///
Socket,
}
@@ -64,12 +65,12 @@ public struct NetworkDriverInstance
///
public bool simulatorEnabled
{
- get { return m_SimulatorEnabled == 1; }
- set { m_SimulatorEnabled = value ? (byte)1 : (byte)0; }
+ get => driver.IsCreated && driver.CurrentSettings.TryGet(out _) || driver.CurrentSettings.TryGet(out _);
+ [Obsolete("This set has no effect on whether or not the simulator is actually enabled, and therefore should not be used.", false)]
+ // ReSharper disable once ValueParameterNotUsed
+ set { }
}
- private byte m_SimulatorEnabled;
-
internal void StopListening()
{
#pragma warning disable 0618
@@ -201,9 +202,9 @@ public bool HasListeningInterfaces
/// Add a new driver to the store. Throw exception if all drivers slot are already occupied or the driver is not created/valid
///
/// The assigned driver id
- ///
- ///
- ///
+ /// Driver type
+ /// Instance of driver
+ /// Thrown if cannot register or the NetworkDriverStore is finalized.
public int RegisterDriver(TransportType driverType, in NetworkDriverInstance driverInstance)
{
if (driverInstance.driver.IsCreated == false)
@@ -311,9 +312,6 @@ internal readonly unsafe ref NetworkDriverData GetDriverDataRO(int driverId)
///
/// Returns the instance, by ref.
///
- ///
- /// The instance, by ref.
- ///
///
internal unsafe ref NetworkDriverData GetDriverDataRW(int driverId)
{
@@ -330,17 +328,14 @@ internal unsafe ref NetworkDriverData GetDriverDataRW(int driverId)
}
}
- ///
+ ///
/// Return the instance with the given .
///
- /// the id of the driver. Should be always greater or equals than
///
/// The method return a copy of the driver instance not a reference. While this is suitable for almost all the use cases,
/// since the driver is trivially copyable, be aware that calling some of the Driver class methods, like ScheduleUpdate,
/// that update internal driver data (that aren't suited to be copied around) may not work as expected.
///
- ///
- ///
///
[Obsolete("Prefer GetDriverInstanceRW or GetDriverInstanceRO to avoid copying.", false)]
public readonly ref NetworkDriverInstance GetDriverInstance(int driverId) => ref GetDriverDataRO(driverId).instance;
@@ -348,9 +343,6 @@ internal unsafe ref NetworkDriverData GetDriverDataRW(int driverId)
///
/// Return the with the given .
///
- ///
- ///
- ///
///
[Obsolete("Prefer GetDriverRW or GetDriverRO to avoid copying.", false)]
public readonly NetworkDriver GetNetworkDriver(int driverId) => GetDriverDataRO(driverId).instance.driver;
@@ -358,45 +350,30 @@ internal unsafe ref NetworkDriverData GetDriverDataRW(int driverId)
///
/// Return a reference to the instance with the given .
///
- ///
- ///
- ///
///
public ref NetworkDriverStore.NetworkDriverInstance GetDriverInstanceRW(int driverId) => ref GetDriverDataRW(driverId).instance;
///
/// Return a reference to the instance with the given .
///
- ///
- ///
- ///
///
public ref readonly NetworkDriverStore.NetworkDriverInstance GetDriverInstanceRO(int driverId) => ref GetDriverDataRO(driverId).instance;
///
/// Retrieve a ReadWrite reference to the for the given .
///
- ///
- ///
- ///
///
public ref NetworkDriver GetDriverRW(int driverId) => ref GetDriverInstanceRW(driverId).driver;
///
/// Retrieve a Read-Only reference to the for the given .
///
- ///
- ///
- ///
///
public ref readonly NetworkDriver GetDriverRO(int driverId) => ref GetDriverInstanceRO(driverId).driver;
///
/// Return the transport type used by the registered driver.
///
- ///
- ///
- ///
///
public TransportType GetDriverType(int driverId) => GetDriverDataRO(driverId).transportType;
@@ -404,7 +381,7 @@ internal unsafe ref NetworkDriverData GetDriverDataRW(int driverId)
/// Return the state of the connection.
///
/// A client or server connection
- ///
+ /// The state of the connection
/// Throw an exception if the driver associated to the connection is not found
public NetworkConnection.State GetConnectionState(NetworkStreamConnection connection) => GetDriverRW(connection.DriverId).GetConnectionState(connection.Value);
@@ -412,13 +389,13 @@ internal unsafe ref NetworkDriverData GetDriverDataRW(int driverId)
/// Signature for all functions that can be used to visit the registered drivers in the store using the method.
///
/// a reference to a
- /// the id of the driver
+ /// the id of the driver. Must always greater or equals
public delegate void DriverVisitor(ref NetworkDriverInstance driver, int driverId);
///
/// Invoke the delegate on all registered drivers.
///
- ///
+ /// Visitor to invoke with the driver instance and ID
[Obsolete("The ForEachDriver has been deprecated. Please always iterate over the driver using a for loop, using the FirstDriver and LastDriver ids instead.")]
public void ForEachDriver(DriverVisitor visitor)
{
@@ -434,8 +411,7 @@ public void ForEachDriver(DriverVisitor visitor)
///
/// Utility method to disconnect the connection.
///
- ///
- ///
+ ///
public void Disconnect(NetworkStreamConnection connection) => GetDriverRW(connection.DriverId).Disconnect(connection.Value);
internal JobHandle ScheduleUpdateAllDrivers(JobHandle dependency)
diff --git a/Runtime/Connection/NetworkIdDebugColorUtility.cs b/Runtime/Connection/NetworkIdDebugColorUtility.cs
index d88760d..2fe80e7 100644
--- a/Runtime/Connection/NetworkIdDebugColorUtility.cs
+++ b/Runtime/Connection/NetworkIdDebugColorUtility.cs
@@ -13,7 +13,7 @@ public static class NetworkIdDebugColorUtility
///
/// Get the constant color assigned to the given network id.
///
- ///
+ /// Network id
/// A constant debug color for NetworkId's to aid in debugging
public static float4 Get(int networkId)
{
diff --git a/Runtime/Connection/NetworkSnapshotAck.cs b/Runtime/Connection/NetworkSnapshotAck.cs
index db0757e..63a3c2e 100644
--- a/Runtime/Connection/NetworkSnapshotAck.cs
+++ b/Runtime/Connection/NetworkSnapshotAck.cs
@@ -1,9 +1,12 @@
using System;
+using Unity.Burst.CompilerServices;
+using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Networking.Transport;
using Unity.Networking.Transport.Utilities;
+using UnityEngine;
namespace Unity.NetCode
{
@@ -19,96 +22,53 @@ public struct NetworkSnapshotAck : IComponentData
{
internal void UpdateReceivedByRemote(NetworkTick tick, uint mask)
{
- if (!tick.IsValid)
+ if (Hint.Unlikely(!tick.IsValid))
{
- ReceivedSnapshotByRemoteMask3 = 0;
- ReceivedSnapshotByRemoteMask2 = 0;
- ReceivedSnapshotByRemoteMask1 = 0;
- ReceivedSnapshotByRemoteMask0 = 0;
+ ReceivedSnapshotByRemoteMask.Clear();
LastReceivedSnapshotByRemote = NetworkTick.Invalid;
+ return;
}
- else if (!LastReceivedSnapshotByRemote.IsValid)
+ // For any ticks SINCE our last stored tick (or if we get the same tick again), we should shift the
+ // entire mask UP by that delta (shamt), then apply the new mask on top of the existing one,
+ // as the client may have more up-to-date ack info.
+ var shamt = Hint.Likely(LastReceivedSnapshotByRemote.IsValid) ? tick.TicksSince(LastReceivedSnapshotByRemote) : 0;
+ if (shamt >= 0)
{
- ReceivedSnapshotByRemoteMask3 = 0;
- ReceivedSnapshotByRemoteMask2 = 0;
- ReceivedSnapshotByRemoteMask1 = 0;
- ReceivedSnapshotByRemoteMask0 = mask;
- LastReceivedSnapshotByRemote = tick;
- }
- else if (tick.IsNewerThan(LastReceivedSnapshotByRemote))
- {
- int shamt = tick.TicksSince(LastReceivedSnapshotByRemote);
- if (shamt >= 256)
- {
- ReceivedSnapshotByRemoteMask3 = 0;
- ReceivedSnapshotByRemoteMask2 = 0;
- ReceivedSnapshotByRemoteMask1 = 0;
- ReceivedSnapshotByRemoteMask0 = mask;
- }
- else
- {
- while (shamt >= 64)
- {
- ReceivedSnapshotByRemoteMask3 = ReceivedSnapshotByRemoteMask2;
- ReceivedSnapshotByRemoteMask2 = ReceivedSnapshotByRemoteMask1;
- ReceivedSnapshotByRemoteMask1 = ReceivedSnapshotByRemoteMask0;
- ReceivedSnapshotByRemoteMask0 = 0;
- shamt -= 64;
- }
-
- if (shamt == 0)
- ReceivedSnapshotByRemoteMask0 |= mask;
- else
- {
- ReceivedSnapshotByRemoteMask3 = (ReceivedSnapshotByRemoteMask3 << shamt) |
- (ReceivedSnapshotByRemoteMask2 >> (64 - shamt));
- ReceivedSnapshotByRemoteMask2 = (ReceivedSnapshotByRemoteMask2 << shamt) |
- (ReceivedSnapshotByRemoteMask1 >> (64 - shamt));
- ReceivedSnapshotByRemoteMask1 = (ReceivedSnapshotByRemoteMask1 << shamt) |
- (ReceivedSnapshotByRemoteMask0 >> (64 - shamt));
- ReceivedSnapshotByRemoteMask0 = (ReceivedSnapshotByRemoteMask0 << shamt) |
- mask;
- }
- }
+ ReceivedSnapshotByRemoteMask.ShiftLeftExt(shamt);
+ // Note: Clobbering the mask is valid, because the client should never send us a false value
+ // after sending us a true value for a given tick. But perform the OR operation anyway,
+ // to safeguard against malicious or erring clients.
+ const int writeOffset = 0;
+ const int numBitsToWrite = 32;
+ var previousMask = ReceivedSnapshotByRemoteMask.GetBits(writeOffset, numBitsToWrite);
+ mask |= (uint) previousMask;
+ ReceivedSnapshotByRemoteMask.SetBits(writeOffset, mask, numBitsToWrite);
LastReceivedSnapshotByRemote = tick;
+ SnapshotPacketLoss.NumPacketsAcked += (ulong) (math.countbits(mask) - math.countbits(previousMask));
}
+ // Else, for older ticks (because YES - the client can send negative ticks relative to the last acked),
+ // we don't do anything, as they cannot correctly contain new ack information (due to the sequential
+ // requirement implicit to snapshots).
}
///
/// Return true if the snapshot for tick has been received (from a client perspective)
- /// or acknowledged (from the servers POV)
+ /// or acknowledged (from the servers POV).
///
- ///
- ///
+ /// Tick to query.
+ /// Whether the snapshot for tick has been received (from a client perspective)
public bool IsReceivedByRemote(NetworkTick tick)
{
if (!tick.IsValid || !LastReceivedSnapshotByRemote.IsValid)
return false;
- if (tick.IsNewerThan(LastReceivedSnapshotByRemote))
- return false;
int bit = LastReceivedSnapshotByRemote.TicksSince(tick);
- if (bit >= 256)
+ if (bit < 0)
return false;
- if (bit >= 192)
- {
- bit -= 192;
- return (ReceivedSnapshotByRemoteMask3 & (1ul << bit)) != 0;
- }
-
- if (bit >= 128)
- {
- bit -= 128;
- return (ReceivedSnapshotByRemoteMask2 & (1ul << bit)) != 0;
- }
-
- if (bit >= 64)
- {
- bit -= 64;
- return (ReceivedSnapshotByRemoteMask1 & (1ul << bit)) != 0;
- }
-
- return (ReceivedSnapshotByRemoteMask0 & (1ul << bit)) != 0;
+ if (bit >= ReceivedSnapshotByRemoteMask.Length)
+ return false;
+ var set = ReceivedSnapshotByRemoteMask.GetBits(bit) != 0;
+ return set;
}
///
@@ -117,10 +77,8 @@ public bool IsReceivedByRemote(NetworkTick tick)
/// For the server, it is the last acknowledge packet that has been received by client.
///
public NetworkTick LastReceivedSnapshotByRemote;
- private ulong ReceivedSnapshotByRemoteMask0;
- private ulong ReceivedSnapshotByRemoteMask1;
- private ulong ReceivedSnapshotByRemoteMask2;
- private ulong ReceivedSnapshotByRemoteMask3;
+ internal UnsafeBitArray ReceivedSnapshotByRemoteMask;
+
///
/// The field has a different meaning on the client vs on the server:
/// Client: it is the last received ghost snapshot from the server.
diff --git a/Runtime/Connection/NetworkStreamConnectionComponent.cs b/Runtime/Connection/NetworkStreamConnectionComponent.cs
index 8fa4e25..930114d 100644
--- a/Runtime/Connection/NetworkStreamConnectionComponent.cs
+++ b/Runtime/Connection/NetworkStreamConnectionComponent.cs
@@ -223,7 +223,7 @@ public enum State
/// - The is the same.
///
/// The component to compare
- ///
+ /// Whether the two connection state are equal.
public bool Equals(ConnectionState other) => CurrentState == other.CurrentState && NetworkId == other.NetworkId && DisconnectReason == other.DisconnectReason;
}
diff --git a/Runtime/Connection/NetworkStreamDriver.cs b/Runtime/Connection/NetworkStreamDriver.cs
index 9158ba6..c21529f 100644
--- a/Runtime/Connection/NetworkStreamDriver.cs
+++ b/Runtime/Connection/NetworkStreamDriver.cs
@@ -208,7 +208,7 @@ private NetworkEndpoint SanitizeConnectAddress(in NetworkEndpoint endpoint, int
/// Tell all the registered drivers to start listening for incoming connections.
///
/// The local address to use. This is the address that will be used to bind the underlying socket.
- ///
+ /// Whether the drivers starts listening
public bool Listen(NetworkEndpoint endpoint)
{
//Check that at least the first driver have been created. This is a sufficient condition.
@@ -321,7 +321,7 @@ public Entity Connect(EntityManager entityManager, NetworkEndpoint endpoint, Ent
///
/// The remote connection address. This is the seen public ip address of the connection.
///
- ///
+ /// Connection
///
/// When relay is used, the current relay host address. Otherwise the remote endpoint address.
///
@@ -344,7 +344,7 @@ public NetworkEndpoint GetRemoteEndPoint(NetworkStreamConnection connection)
///
/// Check if the given connection is using relay to connect to the remote endpoint
///
- ///
+ /// Connection
///
/// Either if the connection is using the relay or not.
///
@@ -383,8 +383,8 @@ public NetworkEndpoint GetLocalEndPoint(int driverId)
///
/// The current state of the internal transport connection.
///
- ///
- ///
+ /// Connection
+ /// The current state of the internal transport connection
///
/// Is different from the and it is less granular.
///
diff --git a/Runtime/Connection/NetworkStreamReceiveSystem.cs b/Runtime/Connection/NetworkStreamReceiveSystem.cs
index 338465c..3fd6af8 100644
--- a/Runtime/Connection/NetworkStreamReceiveSystem.cs
+++ b/Runtime/Connection/NetworkStreamReceiveSystem.cs
@@ -9,6 +9,7 @@
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Jobs;
+using Unity.Mathematics;
using Unity.NetCode.LowLevel.Unsafe;
using Unity.Networking.Transport;
using Unity.Profiling;
@@ -19,8 +20,7 @@ namespace Unity.NetCode
///
/// Parent group of all systems that; receive data from the server, deal with connections, and
/// that need to perform operations before the ghost simulation group.
- /// In particular, ,
- /// , and the
+ /// In particular, , and the
/// update in this group.
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ThinClientSimulation,
@@ -40,16 +40,16 @@ public interface INetworkStreamDriverConstructor
///
/// Register to the driver store a new instance of suitable to be used by clients.
///
- ///
- ///
- ///
+ /// Client world
+ /// Driver store
+ /// The singleton, for logging errors and debug information
void CreateClientDriver(World world, ref NetworkDriverStore driver, NetDebug netDebug);
///
/// Register to the driver store a new instance of suitable to be used by servers.
///
- ///
- ///
- ///
+ /// Server world
+ /// Driver store
+ /// The singleton, for logging errors and debug information
void CreateServerDriver(World world, ref NetworkDriverStore driver, NetDebug netDebug);
}
@@ -374,6 +374,13 @@ public void OnDestroy(ref SystemState state)
DriverStore.Dispose();
}
UnsafeUtility.Free((void*)m_DriverPointers, Allocator.Persistent);
+
+ // Force clean-up of ReceivedSnapshotByRemoteMask:
+ foreach (var snapshotAck in SystemAPI.Query>())
+ {
+ if (snapshotAck.ValueRO.ReceivedSnapshotByRemoteMask.IsCreated)
+ snapshotAck.ValueRW.ReceivedSnapshotByRemoteMask.Dispose();
+ }
}
[BurstCompile]
@@ -584,7 +591,10 @@ public void Execute()
};
var ent = commandBuffer.CreateEntity();
commandBuffer.AddComponent(ent, connection);
- commandBuffer.AddComponent(ent, new NetworkSnapshotAck());
+ commandBuffer.AddComponent(ent, new NetworkSnapshotAck
+ {
+ ReceivedSnapshotByRemoteMask = new UnsafeBitArray((int)math.max(1024, tickRate.SnapshotAckMaskCapacity), Allocator.Persistent),
+ });
commandBuffer.AddBuffer(ent);
commandBuffer.AddComponent(ent, new CommandTarget());
commandBuffer.AddBuffer(ent);
@@ -667,12 +677,14 @@ public void Execute(Entity entity, ref NetworkStreamConnection connection, ref N
}
else if (!inGameFromEntity.HasComponent(entity))
{
+ // Reset almost all NetworkSnapshotAck fields:
snapshotAck = new NetworkSnapshotAck
{
LastReceivedRemoteTime = snapshotAck.LastReceivedRemoteTime,
LastReceiveTimestamp = snapshotAck.LastReceiveTimestamp,
EstimatedRTT = snapshotAck.EstimatedRTT,
DeviationRTT = snapshotAck.DeviationRTT,
+ ReceivedSnapshotByRemoteMask = snapshotAck.ReceivedSnapshotByRemoteMask,
};
}
@@ -698,7 +710,10 @@ public void Execute(Entity entity, ref NetworkStreamConnection connection, ref N
case NetworkEvent.Type.Connect:
{
// This event is only invoked on the client. The server bypasses, as part of the Accept() call.
+#if ENABLE_UNITY_COLLECTIONS_CHECKS
Debug.Assert(!isServer);
+ Debug.Assert(!snapshotAck.ReceivedSnapshotByRemoteMask.IsCreated);
+#endif
netDebug.DebugLog($"{debugPrefix} Client connected to driver, sending {protocolVersion.ToFixedString()} to server to begin handshake...");
snapshotAck.SnapshotPacketLoss = default;
var buf = outgoingRpcBuffer[entity];
@@ -862,7 +877,7 @@ public void Execute(Entity entity, ref NetworkStreamConnection connection, ref N
// CurrentStateDirty is a bit of a hack: It only exists for:
// - The `Connecting` state on the client.
// - The `Approval` state on the client.
- // We intentionally this in most places (see various event evocations scattered around).
+ // Note that we intentionally bypass this in most places (see various event evocations scattered around).
if(Hint.Unlikely(connection.CurrentStateDirty))
{
connection.CurrentStateDirty = false;
@@ -902,6 +917,8 @@ public void Execute(Entity entity, ref NetworkStreamConnection connection, ref N
ConnectionEntity = entity,
});
+ if (snapshotAck.ReceivedSnapshotByRemoteMask.IsCreated)
+ snapshotAck.ReceivedSnapshotByRemoteMask.Dispose();
connection.Value = default;
connection.CurrentState = ConnectionState.State.Disconnected;
connection.CurrentStateDirty = false;
@@ -985,9 +1002,12 @@ private void HandleApproval(Entity entity, ref NetworkStreamConnection connectio
}
}
- // Handle timeout: Note: Client can time itself out, too.
+ // Handle timeout: Note that the client can time itself out, too, but only if not in handshake,
+ // as it doesn't know the configured timeout duration.
if (Hint.Unlikely(connection.ConnectionApprovalTimeoutStart != 0))
{
+ var isClientHandshaking = !isServer && connection.CurrentState == ConnectionState.State.Handshake;
+ if (isClientHandshaking) return;
var elapsedSinceApprovalStartMS = localTime - connection.ConnectionApprovalTimeoutStart;
if (Hint.Unlikely(elapsedSinceApprovalStartMS >= tickRate.HandshakeApprovalTimeoutMS))
{
diff --git a/Runtime/Connection/SnapshotPacketLossStatistics.cs b/Runtime/Connection/SnapshotPacketLossStatistics.cs
index 2a33795..9909eff 100644
--- a/Runtime/Connection/SnapshotPacketLossStatistics.cs
+++ b/Runtime/Connection/SnapshotPacketLossStatistics.cs
@@ -4,14 +4,19 @@
namespace Unity.NetCode
{
///
- /// Stores packet loss causes and statistics for all received snapshots. Thus, client-only.
+ /// Stores packet loss causes and statistics for all received snapshots. Thus, client-only (with one exception).
/// Access via .
///
/// Very similar approach to Statistics.
public struct SnapshotPacketLossStatistics
{
- /// Count of snapshot packets received - on the client - from the server.
+ ///
+ /// On the client, it counts the number of snapshot packets received by said client from the server.
+ /// On the server, it stores the number of snapshots sent.
+ ///
public ulong NumPacketsReceived;
+ /// Server-only. Stores the number of snapshots the client has successfully replied that they have acked.
+ public ulong NumPacketsAcked;
/// Counts the number of snapshot packets dropped (i.e. "culled") due to invalid SequenceId. I.e. Implies the packet arrived, but out of order.
public ulong NumPacketsCulledOutOfOrder;
///
@@ -24,6 +29,8 @@ public struct SnapshotPacketLossStatistics
/// Detects gaps in to determine real packet loss.
public ulong NumPacketsDroppedNeverArrived;
+ /// Server-only. Percentage of all snapshot packets sent that the client has acked.
+ public double AckPercent => NumPacketsReceived != 0 ? NumPacketsAcked / (double) (NumPacketsReceived) : 0;
/// Percentage of all snapshot packets - that we assume must have been sent to us (based on SequenceId) - which are lost due to network-caused packet loss.
public double NetworkPacketLossPercent => NumPacketsReceived != 0 ? NumPacketsDroppedNeverArrived / (double) (NumPacketsReceived + NumPacketsDroppedNeverArrived) : 0;
/// Percentage of all snapshot packets - that we assume must have been sent to us (based on SequenceId) - which are lost due to arriving out of order (and thus being culled).
@@ -44,6 +51,7 @@ public struct SnapshotPacketLossStatistics
public static SnapshotPacketLossStatistics operator +(SnapshotPacketLossStatistics a, SnapshotPacketLossStatistics b)
{
a.NumPacketsReceived += b.NumPacketsReceived;
+ a.NumPacketsAcked += b.NumPacketsAcked;
a.NumPacketsCulledOutOfOrder += b.NumPacketsCulledOutOfOrder;
a.NumPacketsCulledAsArrivedOnSameFrame += b.NumPacketsCulledAsArrivedOnSameFrame;
a.NumPacketsDroppedNeverArrived += b.NumPacketsDroppedNeverArrived;
@@ -60,6 +68,7 @@ public struct SnapshotPacketLossStatistics
{
// Guard subtraction as it can get negative when we're polling 3s intervals.
a.NumPacketsReceived -= math.min(a.NumPacketsReceived, b.NumPacketsReceived);
+ a.NumPacketsAcked -= math.min(a.NumPacketsAcked, b.NumPacketsAcked);
a.NumPacketsCulledOutOfOrder -= math.min(a.NumPacketsCulledOutOfOrder, b.NumPacketsCulledOutOfOrder);
a.NumPacketsCulledAsArrivedOnSameFrame -= math.min(a.NumPacketsCulledAsArrivedOnSameFrame, b.NumPacketsCulledAsArrivedOnSameFrame);
a.NumPacketsDroppedNeverArrived -= math.min(a.NumPacketsDroppedNeverArrived, b.NumPacketsDroppedNeverArrived);
@@ -67,10 +76,15 @@ public struct SnapshotPacketLossStatistics
}
///
- /// Dumps all the statistic info.
+ /// Formatted dump of statistics for this world-type.
///
- /// Dumps all the statistic info.
- public FixedString512Bytes ToFixedString() => $"SPLS[received:{NumPacketsReceived}, combinedPL:{CombinedPacketLossCount} {(int) (CombinedPacketLossPercent*100)}%, networkPL:{NumPacketsDroppedNeverArrived} {(int) (NetworkPacketLossPercent*100)}%, outOfOrderPL:{NumPacketsCulledOutOfOrder} {(int) (OutOfOrderPacketLossPercent*100)}%, clobberedPL:{NumPacketsCulledAsArrivedOnSameFrame} {(int) (ArrivedOnTheSameFrameClobberedPacketLossPercent*100)}%]";
+ /// Formatted dump of statistics for this world-type.
+ public FixedString512Bytes ToFixedString()
+ {
+ if (NumPacketsReceived == 0) return "SPLS[default]";
+ if (NumPacketsAcked > 0) return $"SPLS[sent:{NumPacketsReceived}, receivedAck:{NumPacketsAcked} {(int) (AckPercent * 100)}%]";
+ return $"SPLS[received:{NumPacketsReceived}, combinedPL:{CombinedPacketLossCount} {(int) (CombinedPacketLossPercent * 100)}%, networkPL:{NumPacketsDroppedNeverArrived} {(int) (NetworkPacketLossPercent * 100)}%, outOfOrderPL:{NumPacketsCulledOutOfOrder} {(int) (OutOfOrderPacketLossPercent * 100)}%, clobberedPL:{NumPacketsCulledAsArrivedOnSameFrame} {(int) (ArrivedOnTheSameFrameClobberedPacketLossPercent * 100)}%]";
+ }
///
public override string ToString() => ToFixedString().ToString();
diff --git a/Runtime/Connection/WarnAboutBatchedTicksSystem.cs b/Runtime/Connection/WarnAboutBatchedTicksSystem.cs
new file mode 100644
index 0000000..ee7fa30
--- /dev/null
+++ b/Runtime/Connection/WarnAboutBatchedTicksSystem.cs
@@ -0,0 +1,63 @@
+#if UNITY_EDITOR && !NETCODE_NDEBUG
+#define NETCODE_DEBUG
+#endif
+#if NETCODE_DEBUG
+using System;
+using Unity.Collections;
+using Unity.Entities;
+using Unity.Burst;
+
+namespace Unity.NetCode
+{
+ [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
+ [UpdateInGroup(typeof(SimulationSystemGroup), OrderLast = true)]
+ [BurstCompile]
+ public partial struct WarnAboutBatchedTicksSystem : ISystem
+ {
+ private float m_RollingAverage;
+ private bool m_ShowDetailedWarning;
+
+ [BurstCompile]
+ public void OnCreate(ref SystemState state)
+ {
+ m_RollingAverage = 1.0f;
+ m_ShowDetailedWarning = true;
+ }
+
+ [BurstCompile]
+ public void OnUpdate(ref SystemState state)
+ {
+ var netDebug = SystemAPI.GetSingletonRW();
+ var networkTime = SystemAPI.GetSingleton();
+ SystemAPI.TryGetSingleton(out var tickRate);
+ tickRate.ResolveDefaults();
+
+ if (!netDebug.ValueRO.WarnBatchedTicks)
+ return;
+
+ if (networkTime.SimulationStepBatchSize > 1)
+ {
+ netDebug.ValueRW.DebugLog( $"Server tick batching has occured. {networkTime.SimulationStepBatchSize} ticks have been batched into 1." );
+ }
+
+ float k_RollingWindow = (float)netDebug.ValueRO.WarnBatchedTicksRollingWindowSize;
+ m_RollingAverage = (m_RollingAverage - (m_RollingAverage / k_RollingWindow)) + ((float)networkTime.SimulationStepBatchSize / k_RollingWindow);
+
+ if ( m_RollingAverage >= netDebug.ValueRO.WarnAboveAverageBatchedTicksPerFrame )
+ {
+ FixedString64Bytes detailsString = (m_ShowDetailedWarning ? "" : " (see first warning for more details)");
+
+ netDebug.ValueRW.LogWarning($"Server Tick Batching has occurred due to the server falling behind its desired `SimulationTickRate`. An average of {m_RollingAverage:G3} ticks per frame has been detected for the last ~{netDebug.ValueRO.WarnBatchedTicksRollingWindowSize} frames.{detailsString}" );
+
+ if ( m_ShowDetailedWarning )
+ {
+ m_ShowDetailedWarning = false;
+ netDebug.ValueRW.LogWarning($"Expect client input loss, and a reduction in gameplay, physics, prediction and interpolation quality. Server Tick Batching should only occur in exceptional situations, as a defensive mechanism to prevent a death spiral. i.e. If encountered frequently - with optimizations (like Burst) enabled - this indicates unacceptably poor server performance, as frequent batching makes most games effectively unplayable.");
+ }
+
+ m_RollingAverage = 1.0f;
+ }
+ }
+ }
+}
+#endif
diff --git a/Runtime/Connection/WarnAboutBatchedTicksSystem.cs.meta b/Runtime/Connection/WarnAboutBatchedTicksSystem.cs.meta
new file mode 100644
index 0000000..f53cb6c
--- /dev/null
+++ b/Runtime/Connection/WarnAboutBatchedTicksSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f466900956e7f1545af18535c8c70fe4
\ No newline at end of file
diff --git a/Runtime/Debug/BlobStringText.cs b/Runtime/Debug/BlobStringText.cs
index 29d2a3e..3073f1b 100644
--- a/Runtime/Debug/BlobStringText.cs
+++ b/Runtime/Debug/BlobStringText.cs
@@ -20,7 +20,7 @@ public struct BlobStringText: INativeList, IUTF8Bytes
/// is cached internally by this wrapper and if the original blob is detroyed, the memory content
/// may point to something that it is not a string.
///
- ///
+ /// reference.
public BlobStringText(ref BlobString blob)
{
unsafe
@@ -29,7 +29,7 @@ public BlobStringText(ref BlobString blob)
}
m_Length = blob.Length;
}
-
+
///
public bool IsEmpty => m_Length == 0;
@@ -41,7 +41,7 @@ public BlobStringText(ref BlobString blob)
///
/// Always throw NotImplementedException
- ///
+ /// Always throw NotImplementedException
public bool TryResize(int newLength, NativeArrayOptions clearOptions = NativeArrayOptions.ClearMemory)
{
throw new NotImplementedException();
@@ -49,7 +49,7 @@ public bool TryResize(int newLength, NativeArrayOptions clearOptions = NativeArr
///
/// Always throw NotImplementedException
- ///
+ /// Always throw NotImplementedException
public int Length
{
get => m_Length;
@@ -57,20 +57,21 @@ public int Length
}
///
/// Always throw NotImplementedException
+ /// Always throw NotImplementedException
public ref byte ElementAt(int index)
{
throw new NotImplementedException();
}
///
/// Always throw NotImplementedException
- ///
+ /// Always throw NotImplementedException
public int Capacity {
get => m_Length;
set => throw new NotImplementedException();
}
///
/// Always throw NotImplementedException
- ///
+ /// Always throw NotImplementedException
public byte this[int index]
{
get
@@ -81,7 +82,7 @@ public byte this[int index]
}
///
/// Always throw NotImplementedException
- ///
+ /// Always throw NotImplementedException
public void Clear()
{
throw new NotImplementedException();
diff --git a/Runtime/Debug/GhostDebugMeshBounds.cs b/Runtime/Debug/GhostDebugMeshBounds.cs
index 26713b8..801a313 100644
--- a/Runtime/Debug/GhostDebugMeshBounds.cs
+++ b/Runtime/Debug/GhostDebugMeshBounds.cs
@@ -23,10 +23,10 @@ public struct GhostDebugMeshBounds : IComponentData
///
/// Convenience method to initialize the debug mesh bounds for GameObjects.
///
- ///
- ///
- ///
- ///
+ /// GameObject with debug mesh
+ /// Entity represenation of gameobject
+ /// World containing entity
+ /// Returns a mesh's bounds for debug drawing.
public GhostDebugMeshBounds Initialize(GameObject gameObject, Entity entity, World world)
{
gameObject.GetComponentsInChildren(includeInactive: true, results: s_AllRenderers);
diff --git a/Runtime/Debug/NetDebug.cs b/Runtime/Debug/NetDebug.cs
index 16ab587..d9db59a 100644
--- a/Runtime/Debug/NetDebug.cs
+++ b/Runtime/Debug/NetDebug.cs
@@ -105,7 +105,7 @@ public static class NetCodeUtils
///
/// Returns the Fixed String enum value name.
///
- ///
+ /// The source enum.
/// Returns the Fixed String enum value name.
public static FixedString32Bytes ToFixedString(this NetworkStreamDisconnectReason reason)
{
@@ -128,11 +128,11 @@ public static FixedString32Bytes ToFixedString(this NetworkStreamDisconnectReaso
///
/// Converts from the Transport state to ours.
///
- ///
- ///
+ /// The source enum.
+ /// True if the handshake process has been completed.
/// True if (we have been approved AND the approval flow is enabled) OR if we don't need approval.
- ///
- ///
+ /// Netcode connection state
+ /// If transport state is unknown.
public static ConnectionState.State ToNetcodeState(this NetworkConnection.State transportState, bool hasHandshaked, bool hasApproval = true)
{
switch (transportState)
@@ -153,7 +153,7 @@ public static ConnectionState.State ToNetcodeState(this NetworkConnection.State
///
/// Returns the Fixed String enum value name.
///
- ///
+ /// The source enum.
/// Returns the Fixed String enum value name.
public static FixedString32Bytes ToFixedString(this ConnectionState.State state)
{
@@ -168,6 +168,23 @@ public static FixedString32Bytes ToFixedString(this ConnectionState.State state)
default: return $"ConnectionState_{(int) state}";
}
}
+
+ ///
+ /// Returns the Fixed String enum value name.
+ ///
+ /// The source enum.
+ /// Returns the Fixed String enum value name.
+ public static FixedString32Bytes ToFixedString(this NetworkConnection.State state)
+ {
+ switch (state)
+ {
+ case NetworkConnection.State.Disconnected: return nameof(NetworkConnection.State.Disconnected);
+ case NetworkConnection.State.Disconnecting: return nameof(NetworkConnection.State.Disconnecting);
+ case NetworkConnection.State.Connecting: return nameof(NetworkConnection.State.Connecting);
+ case NetworkConnection.State.Connected: return nameof(NetworkConnection.State.Connected);
+ default: return $"NetworkConnection.State_{(int) state}";
+ }
+ }
}
/// Singleton handling NetCode logging and log management.
@@ -263,6 +280,10 @@ internal void Initialize()
LogLevel = DefaultLogLevel;
// Suppressing by default because it leads to many test false positives.
SuppressApprovalRpcSentWhenApprovalFlowDisabledWarning = true;
+
+ WarnBatchedTicks = true;
+ WarnBatchedTicksRollingWindowSize = 4;
+ WarnAboveAverageBatchedTicksPerFrame = 1.2f;
}
///
@@ -313,6 +334,31 @@ public void Dispose()
///
public ushort MaxRpcAgeFrames { get; set; }
+ // Frame time has exceeded the ability for fixed updates to 'catch up' to the simulation time, ticks will now be batched so instead of n ticks of fixedTimer per frame, we will have m ticks of (n/m)*fixedTime per frame
+ // While this will allow the simulation to catch-up it will degrade interpolation performacne and can introduce predition errors since the server will simulate fewer frames than a client will predict and they may need to be adjusted. This can be common in the editor and situations of poor performance. With good interpolation and infrequent ocurrances this should have minimal visual impact.
+ // If its happening every frame you will observe severly degraded performance
+
+ ///
+ /// Display a warning if ticks have been bacthed
+ ///
+ ///
+ /// Warning will be displayed when frame time has exceeded the ability for fixed updates to 'catch up' to the simulation time, ticks will be batched so instead of n ticks of fixedTimer per frame, we will have m ticks of (n/m)*fixedTime per frame
+ /// While this allows the simulation to catch-up it degrades interpolation performance and can introduce predition errors since the server will simulate fewer frames than a client will predict and they may need to be adjusted. This can be common in the editor and situations of poor performance. With good interpolation and infrequent ocurrances this should have minimal visual impact.
+ /// If its happening every frame you will observe severly degraded performance
+ ///
+ [field: MarshalAs(UnmanagedType.U1)]
+ public bool WarnBatchedTicks;
+
+ ///
+ /// Size of the rolling window used to calculate the avergage for the number of frames which contained tick batching.
+ ///
+ public int WarnBatchedTicksRollingWindowSize;
+
+ ///
+ /// Display a warning if the average number if ticks per frame is above this number
+ ///
+ public float WarnAboveAverageBatchedTicksPerFrame;
+
///
/// The current debug logging level. Default value is .
///
@@ -471,7 +517,7 @@ internal static FixedString32Bytes PrintHex(ulong value, int bitSize)
/// Print an unsigned integer in hexadecimal format
///
/// The unsigned value to convert
- ///
+ /// An unsigned integer in hexadecimal format
public static FixedString32Bytes PrintHex(uint value)
{
return PrintHex(value, 32);
@@ -480,7 +526,7 @@ public static FixedString32Bytes PrintHex(uint value)
/// Print a unsigned long integer in hexadecimal format
///
/// The unsigned value to convert
- ///
+ /// a unsigned long integer in hexadecimal format
public static FixedString32Bytes PrintHex(ulong value)
{
return PrintHex(value, 64);
diff --git a/Runtime/Debug/NetDebugSystem.cs b/Runtime/Debug/NetDebugSystem.cs
index bffcbba..743c326 100644
--- a/Runtime/Debug/NetDebugSystem.cs
+++ b/Runtime/Debug/NetDebugSystem.cs
@@ -31,6 +31,10 @@ private void CreateNetDebugSingleton(ref SystemState state)
#if UNITY_EDITOR
if (MultiplayerPlayModePreferences.ApplyLoggerSettings)
netDebug.LogLevel = MultiplayerPlayModePreferences.TargetLogLevel;
+
+ netDebug.WarnBatchedTicks = MultiplayerPlayModePreferences.WarnBatchedTicks;
+ netDebug.WarnBatchedTicksRollingWindowSize = MultiplayerPlayModePreferences.WarnBatchedTicksRollingWindow;
+ netDebug.WarnAboveAverageBatchedTicksPerFrame = MultiplayerPlayModePreferences.WarnAboveAverageBatchedTicksPerFrame;
#endif
#if NETCODE_DEBUG
m_ComponentTypeNameLookupData = new NativeHashMap(1024, Allocator.Persistent);
diff --git a/Runtime/Hybrid/GhostPresentationGameObjectEntityOwner.cs b/Runtime/Hybrid/GhostPresentationGameObjectEntityOwner.cs
index 460c15a..394556a 100644
--- a/Runtime/Hybrid/GhostPresentationGameObjectEntityOwner.cs
+++ b/Runtime/Hybrid/GhostPresentationGameObjectEntityOwner.cs
@@ -24,8 +24,8 @@ public class GhostPresentationGameObjectEntityOwner : MonoBehaviour
///
/// Convenience method to initialize the debug mesh bounds.
///
- ///
- ///
+ /// The entity owning this GameObject.
+ /// The world in which the entity owning this GameObject exists.
public void Initialize(Entity entity, World world)
{
Entity = entity;
diff --git a/Runtime/NetCodeConfig.cs b/Runtime/NetCodeConfig.cs
index 783be06..8bcaeeb 100644
--- a/Runtime/NetCodeConfig.cs
+++ b/Runtime/NetCodeConfig.cs
@@ -1,5 +1,8 @@
using System;
using System.Text;
+using Unity.Networking.Transport;
+using Unity.Networking.Transport.Relay;
+using Unity.Networking.Transport.Utilities;
using UnityEngine;
namespace Unity.NetCode
@@ -40,7 +43,6 @@ public enum AutomaticBootstrapSetting
[Tooltip("Denotes if the ClientServerBootstrap (or any derived version of it) should be triggered on game boot. Project-wide setting (when this config is applied in the Netcode tab), overridable via the OverrideAutomaticNetCodeBootstrap MonoBehaviour.")] [SerializeField]
public AutomaticBootstrapSetting EnableClientServerBootstrap = AutomaticBootstrapSetting.EnableAutomaticBootstrap;
- // TODO - Range + Tooltips attributes for these structs.
// TODO - Add a helper link to open the NetDbg when viewing the NetConfig asset.
///
public ClientServerTickRate ClientServerTickRate;
@@ -53,12 +55,99 @@ public enum AutomaticBootstrapSetting
// TODO - Importance.
// TODO - Relevancy.
- //[Header("Unity Transport Package (UTP)")]
- // TODO - Make these structs public and [Serializable] so that we can actually modify them.
- // public NetworkConfigParameter NetworkConfigParameter;
- // public FragmentationUtility.Parameters FragmentationUtilityParameters;
- // public ReliableUtility.Parameters ReliableUtilityParameters;
- // public RelayNetworkParameter RelayNetworkParameter;
+ // Transport:
+ ///
+ [Tooltip("Time between connection attempts, in milliseconds.")]
+ [Min(1)]
+ public int ConnectTimeoutMS;
+
+ ///
+ [Tooltip("Maximum number of connection attempts to try. If no answer is received from the server after this number of attempts, a Disconnect event is generated for the connection.")]
+ [Min(1)]
+ public int MaxConnectAttempts;
+
+ ///
+ [Tooltip("Inactivity timeout for a connection, in milliseconds. If nothing is received on a connection for this amount of time, it is disconnected (a Disconnect event will be generated).\n\nTo prevent this from happening when the game session is simply quiet, set heartbeatTimeoutMS to a positive non-zero value.")]
+ [Min(1)]
+ public int DisconnectTimeoutMS;
+
+ ///
+ [Tooltip("Time after which if nothing from a peer is received, a heartbeat message will be sent to keep the connection alive. Prevents the disconnectTimeoutMS mechanism from kicking when nothing happens on a connection. A value of 0 will disable heartbeats.")]
+ [Min(1)]
+ public int HeartbeatTimeoutMS;
+
+ ///
+ [Tooltip("Time after which to attempt to re-establish a connection if nothing is received from the peer. This is used to re-establish connections for example when a peer's IP address changes (e. g. mobile roaming scenarios).\n\nTo be effective, should be less than disconnectTimeoutMS but greater than heartbeatTimeoutMS.\n\nA value of 0 will disable this functionality.")]
+ [Min(1)]
+ public int ReconnectionTimeoutMS;
+
+ ///
+ /// Capacity of the send queue (per pipeline-stage) on the client.
+ /// This should be the maximum number of packets expected to be sent by the client in a single update (i.e. each render frame).
+ /// Broad recommendation: 8 If not memory constrained, else use minimum, as it can affect Reliable and Fragmentation pipeline throughput.
+ ///
+ ///
+ [Tooltip(@"Capacity of the send queue (per pipeline-stage) on the client.
+This should be the maximum number of packets expected to be sent by the client, per pipeline-stage, in a single update (i.e. each render frame).
+
+Recommended value: 8 if not memory constrained, else minimum, as it can affect Reliable and Fragmentation pipeline throughput.
+Default value: 512 i.e. NetworkParameterConstants.SendQueueCapacity")]
+ [Min(4)]
+ public int ClientSendQueueCapacity;
+
+ ///
+ /// Capacity of the receive queue (per pipeline-stage) on the client.
+ /// This should be the maximum number of in-flight packets expected to be received by the client - from the
+ /// server - during a worst-case frame (like if the client executable stalls).
+ /// Broad recommendation: 64.
+ ///
+ ///
+ [Tooltip(@"Capacity of the receive queue (per pipeline-stage) on the client.
+This should be the maximum number of in-flight packets expected to be received by the client - from the
+server - during a worst-case frame (like if the client executable stalls).
+
+Broad recommendation: 64.
+Default value: 512 i.e. NetworkParameterConstants.ReceiveQueueCapacity")]
+ [Min(8)]
+ public int ClientReceiveQueueCapacity;
+
+ ///
+ /// Capacity of the send queue (per pipeline-stage) on the server.
+ /// This should be a multiple (likely 1) of the maximum number of packets expected to be sent by the server, across all
+ /// connections, on a per pipeline-stage basis, in a single update (i.e. each render frame).
+ /// Broad recommendations: For 2 players, ~64. For 100 players, ~100. For 1k players, ~1k.
+ ///
+ /// 1 packet per pipeline-stage, per connection, for a game supporting, at most, 512 players per server.
+ ///
+ [Tooltip(@"Capacity of the send queue (per pipeline-stage) on the server.
+This should be a multiple of the maximum number of packets expected to be sent by the server, across all connections, on a per pipeline-stage basis, in a single update (i.e. each render frame).
+
+For 2 players, ~128. For 100 players, ~512. For 1k players, ~1k.
+If memory constrained, use minimum, but note it can affect Reliable and Fragmentation pipeline throughput.
+Default value: 512 i.e. NetworkParameterConstants.SendQueueCapacity")]
+ [Min(16)]
+ public int ServerSendQueueCapacity;
+
+ ///
+ /// Capacity of the receive queue (per pipeline-stage) on the server.
+ /// This should be the maximum number of in-flight packets - expected to be sent across by the maximum supported
+ /// number of connected clients - to the server - arriving within a worst-case server game loop update.
+ /// Broad recommendations: For 2 players, ~64. For 100 players, ~512. For 1k players, ~1.2k.
+ ///
+ ///
+ [Tooltip(@"Capacity of the receive queue (per pipeline-stage) on the server.
+This should be the maximum number of in-flight packets - expected to be sent across by the maximum supported
+number of connected clients - to the server - arriving within a worst-case server game loop update.
+
+Broad recommendations: For 2 players, ~64. For 100 players, ~512. For 1k players, ~1.2k.
+Default value: 512 i.e. NetworkParameterConstants.ReceiveQueueCapacity")]
+ [Min(64)]
+ public int ServerReceiveQueueCapacity;
+
+ ///
+ [Tooltip("Maximum size of a packet that can be sent by the transport.\n\nNote that this size includes any headers that could be added by the transport (e. g. headers for DTLS or pipelines), which means the actual maximum message size that can be sent by a user is slightly less than this value.\n\nTo find out what the size of these headers is, use MaxHeaderSize(NetworkPipeline).\n\nIt is possible to send messages larger than that by sending them through a pipeline with a FragmentationPipelineStage. These headers do not include those added by the OS network stack (like UDP or IP).")]
+ [Range(64, NetworkParameterConstants.AbsoluteMaxMessageSize)]
+ public int MaxMessageSize;
internal NetCodeConfig()
{
@@ -76,6 +165,24 @@ public void Reset()
ClientTickRate = NetworkTimeSystem.DefaultClientTickRate;
GhostSendSystemData = default;
GhostSendSystemData.Initialize();
+
+ ResetIfDefault(ref ConnectTimeoutMS, NetworkParameterConstants.ConnectTimeoutMS);
+ ResetIfDefault(ref MaxConnectAttempts, NetworkParameterConstants.MaxConnectAttempts);
+ ResetIfDefault(ref DisconnectTimeoutMS, NetworkParameterConstants.DisconnectTimeoutMS);
+ ResetIfDefault(ref HeartbeatTimeoutMS, NetworkParameterConstants.HeartbeatTimeoutMS);
+ ResetIfDefault(ref ReconnectionTimeoutMS, NetworkParameterConstants.ReconnectionTimeoutMS);
+ ResetIfDefault(ref ClientReceiveQueueCapacity, 64);
+ ResetIfDefault(ref ClientSendQueueCapacity, 64);
+ ResetIfDefault(ref ServerReceiveQueueCapacity, NetworkParameterConstants.ReceiveQueueCapacity);
+ ResetIfDefault(ref ServerSendQueueCapacity, NetworkParameterConstants.SendQueueCapacity);
+ ResetIfDefault(ref MaxMessageSize, NetworkParameterConstants.MaxMessageSize);
+
+ static void ResetIfDefault(ref T value, T defaultValue)
+ where T : IEquatable
+ {
+ if (value.Equals(default))
+ value = defaultValue;
+ }
}
///
@@ -129,8 +236,8 @@ void OnQuit()
///
/// Makes Find deterministic.
///
- ///
- ///
+ /// Instance of
+ /// Whether the config and names match.
public int CompareTo(NetCodeConfig other)
{
if (IsGlobalConfig != other.IsGlobalConfig)
diff --git a/Runtime/Physics/Hybrid/NetCodePhysicsConfig.cs b/Runtime/Physics/Hybrid/NetCodePhysicsConfig.cs
index ee65e93..fd6493c 100644
--- a/Runtime/Physics/Hybrid/NetCodePhysicsConfig.cs
+++ b/Runtime/Physics/Hybrid/NetCodePhysicsConfig.cs
@@ -15,6 +15,18 @@ namespace Unity.NetCode
[HelpURL(Authoring.HelpURLs.NetCodePhysicsConfig)]
public sealed class NetCodePhysicsConfig : MonoBehaviour
{
+ ///
+ /// Configure how the PhysicsSystemGroup should update inside the .
+ /// By default, this option is set to (preserve the original behavior).
+ /// However, in general, a more correct settings would be to either use , or .
+ ///
+ ///
+ /// For the client, in particular, because physics can update only if the prediction loop runs,
+ /// in order to have this settings be used, it is necessary to configure the PredictedSimulationSystemGroup to always update
+ /// (by using the property and set that to ).
+ ///
+ [Tooltip("Configure how the PhysicsSystemGroup should update inside the PredictedFixedStepSimulationSystemGroup.\nBy default, this option is set to PhysicGroupRunMode.LagCompensationEnabledOrKinematicGhosts (preserve the original behavior).\nHowever, in general, a more correct settings would be to either use PhysicGroupRunMode.LagCompensationEnabledOrAnyPhysicsEntities, or PhysicGroupRunMode.AlwaysRun.\n\nFor the client, in particular, because physics can update only if the prediction loop runs, in order to have this settings be used, it is necessary to configure the PredictedSimulationSystemGroup to always update (by using the ClientTickRate.PredictionLoopUpdateMode property and set that to PredictionLoopUpdateMode.AlwaysRun).")]
+ public PhysicGroupRunMode PhysicGroupRunMode;
///
/// Set to true to enable the use of the LagCompensation system. Server and Client will start recording the physics world state in the PhysicsWorldHistory buffer,
/// which size can be further configured for by changing the ServerHistorySize and ClientHistorySize properites;
@@ -29,8 +41,8 @@ public sealed class NetCodePhysicsConfig : MonoBehaviour
///
/// The number of physics world states that are backed up on the client. This cannot be more than the maximum capacity. Leaving it at zero will give you the default (of one).
///
- [Tooltip("The number of physics world states that are backed up on the client. This cannot be more than the maximum capacity, and must be 0 (OFF/DISABLED) or a power of two.\n\nLeaving it at zero will disable it.")]
- public int ClientHistorySize;
+ [Tooltip("The number of physics world states that are backed up on the client. This cannot be more than the maximum capacity, leaving it at zero will give oyu the default which is one.")]
+ public int ClientHistorySize = 1;
///
/// When using predicted physics all dynamic physics objects in the main physics world on the client
@@ -63,6 +75,10 @@ public override void Bake(NetCodePhysicsConfig authoring)
DeepCopyDynamicColliders = authoring.DeepCopyDynamicColliders,
});
}
+ AddComponent(entity, new PhysicsGroupConfig()
+ {
+ PhysicsRunMode = authoring.PhysicGroupRunMode
+ });
if (authoring.ClientNonGhostWorldIndex != 0)
AddComponent(entity, new PredictedPhysicsNonGhostWorld{Value = authoring.ClientNonGhostWorldIndex});
}
diff --git a/Runtime/Physics/Hybrid/NetCodePhysicsInspector.cs b/Runtime/Physics/Hybrid/NetCodePhysicsInspector.cs
index 6562b00..d4f0d61 100644
--- a/Runtime/Physics/Hybrid/NetCodePhysicsInspector.cs
+++ b/Runtime/Physics/Hybrid/NetCodePhysicsInspector.cs
@@ -13,7 +13,9 @@ public sealed class NetCodePhysicsInspector : UnityEditor.Editor
private SerializedProperty ClientNonGhostWorldIndex;
private SerializedProperty DeepCopyDynamicColliders;
private SerializedProperty DeepCopyStaticColliders;
+ private SerializedProperty PhysicGroupRunMode;
private static readonly GUIContent s_LagCompensationTitle = new GUIContent("Lag Compensation", "Configure how the Lag Compensation ring buffers function.");
+ private static readonly GUIContent s_PhysicsRunMode = new GUIContent("PhysicsGroup Run Mode");
private void OnEnable()
{
@@ -23,6 +25,7 @@ private void OnEnable()
ClientNonGhostWorldIndex = serializedObject.FindProperty(nameof(NetCodePhysicsConfig.ClientNonGhostWorldIndex));
DeepCopyDynamicColliders = serializedObject.FindProperty(nameof(NetCodePhysicsConfig.DeepCopyDynamicColliders));
DeepCopyStaticColliders = serializedObject.FindProperty(nameof(NetCodePhysicsConfig.DeepCopyStaticColliders));
+ PhysicGroupRunMode = serializedObject.FindProperty(nameof(NetCodePhysicsConfig.PhysicGroupRunMode));
}
public override void OnInspectorGUI()
@@ -31,7 +34,7 @@ public override void OnInspectorGUI()
using (new EditorGUI.DisabledScope(true))
EditorGUILayout.PropertyField(serializedObject.FindProperty("m_Script"), true);
EditorGUILayout.PropertyField(EnableLagCompensation, s_LagCompensationTitle);
-
+ EditorGUILayout.PropertyField(PhysicGroupRunMode, s_PhysicsRunMode);
if (EnableLagCompensation.boolValue)
{
EditorGUI.indentLevel += 1;
diff --git a/Runtime/Physics/LagCompensationConfig.cs b/Runtime/Physics/LagCompensationConfig.cs
index 12bf27e..3d2ceaa 100644
--- a/Runtime/Physics/LagCompensationConfig.cs
+++ b/Runtime/Physics/LagCompensationConfig.cs
@@ -24,9 +24,8 @@ public struct LagCompensationConfig : IComponentData
///
public int ServerHistorySize;
///
- /// The number of physics world states that are backed up on the client.
- /// This cannot be more than the maximum capacity, leaving it at zero will
- /// give you the default which is one.
+ /// The number of physics world states that are backed up on the client. This cannot be more than the maximum capacity. Settingthe value to 0 will disable recording the physics hystory on the client.
+ /// By default, the history size on client is 1.
///
///
/// Must be 0 (OFF/DISABLED), or a power of 2, for the ring-buffer to return correct values when
diff --git a/Runtime/Physics/PhysicGroupConfig.cs b/Runtime/Physics/PhysicGroupConfig.cs
new file mode 100644
index 0000000..43f5048
--- /dev/null
+++ b/Runtime/Physics/PhysicGroupConfig.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Runtime.CompilerServices;
+using Unity.Entities;
+using UnityEngine.Serialization;
+
+[assembly: InternalsVisibleTo("Unity.NetCode.Physics.Hybrid")]
+
+namespace Unity.NetCode
+{
+ ///
+ /// Instrument how and when the inside
+ /// the should run.
+ ///
+ public enum PhysicGroupRunMode
+ {
+ ///
+ /// The default option for both the server and client. The requires
+ /// entities with and components to run.
+ /// On the server, If no entities match this query, and the lag compensation is active, the physics group will still run.
+ ///
+ ///
+ /// Be aware of the fact that, when using this setting (which is the default) on the client, when no predicted ghosts are present, the prediction loop does not run, and therefore neither does any part of the physics simulation.
+ /// In order to change that, set the to .
+ ///
+ /// If no matching entities, and lag compensation is enabled, the physics loop will only run when the is true.
+ ///
+ /// If all predicted ghost entities have been destroyed, and lag compensation is not enabled, the collision world information will become stale, meaning that while it contains the latest
+ /// computed broadphase tree, we are no longer computing any new broadphase trees. And therefore, entity references stored inside these old broadphase trees may have become invalidated, as well as any references to the associated collider blobs.
+ ///
+ LagCompensationEnabledOrKinematicGhosts,
+ ///
+ /// A more relaxed option for both server and client. The requires
+ /// entities with or components to run.
+ /// In case no entities match this query, but the lag compensation is active, the physics group will update.
+ ///
+ ///
+ /// Be aware of the fact that, when using this setting on the client, when no physics ghosts are present, the prediction loop does not run, and therefore neither does any part of the physics simulation.
+ /// In order to change that, set the to .
+ ///
+ /// If no matching entities, and lag compensation is enabled, the physics loop will only run when the is true.
+ ///
+ /// If all physics entities have been destroyed, and lag compensation is not enabled, the collision world information will become stale, meaning that while it contains the latest
+ /// computed broadphase tree, we are no longer computing any new broadphase trees. And therefore, entity references stored inside these old broadphase trees may have become invalidated, as well as any references to the associated collider blobs.
+ ///
+ LagCompensationEnabledOrAnyPhysicsEntities,
+ ///
+ /// Allow the physics group to run, even if there aren't physics entities, predicted ghost entities, or lag compensation enabled.
+ /// If no physics entities exists, the physics loop run only when the is true.
+ ///
+ AlwaysRun,
+ }
+ ///
+ /// Singleton component that allows users to configure whether or not the runs inside the prediction loop.
+ ///
+ internal struct PhysicsGroupConfig : IComponentData
+ {
+ ///
+ /// Denotes whether or not the physics group should run, even if predicted ghosts are not present in the world.
+ /// By default, this settings is .
+ ///
+ public PhysicGroupRunMode PhysicsRunMode;
+ }
+}
diff --git a/Runtime/Physics/PhysicGroupConfig.cs.meta b/Runtime/Physics/PhysicGroupConfig.cs.meta
new file mode 100644
index 0000000..ee7ea95
--- /dev/null
+++ b/Runtime/Physics/PhysicGroupConfig.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 35f667fe041b4f149a925594a42ae810
+timeCreated: 1718222416
\ No newline at end of file
diff --git a/Runtime/Physics/PhysicsWorldHistory.cs b/Runtime/Physics/PhysicsWorldHistory.cs
index ba19b18..0408c4e 100644
--- a/Runtime/Physics/PhysicsWorldHistory.cs
+++ b/Runtime/Physics/PhysicsWorldHistory.cs
@@ -71,8 +71,8 @@ public void GetCollisionWorldFromTick(NetworkTick tick, uint interpolationDelay,
///
/// Helper to retrieve debug data from the history buffer.
///
- ///
- ///
+ /// Physics world containing history buffer
+ /// History buffer
public unsafe string GetHistoryBufferData(ref PhysicsWorld physicsWorld)
{
string info = $"[PhysicsWorldHistorySingleton] Size:{m_History.m_Size} History.LastStoredTick:{LatestStoredTick.ToFixedString()}";
@@ -499,7 +499,7 @@ public void OnUpdate(ref SystemState state)
{
int historySize;
if (state.WorldUnmanaged.IsServer())
- historySize = config.ServerHistorySize!=0 ? config.ServerHistorySize : RawHistoryBuffer.Capacity;
+ historySize = config.ServerHistorySize != 0 ? config.ServerHistorySize : RawHistoryBuffer.Capacity;
else
historySize = config.ClientHistorySize;
if (historySize == 0)
diff --git a/Runtime/Physics/PredictedPhysicsSystemGroup.cs b/Runtime/Physics/PredictedPhysicsSystemGroup.cs
index 77892d0..3ef4713 100644
--- a/Runtime/Physics/PredictedPhysicsSystemGroup.cs
+++ b/Runtime/Physics/PredictedPhysicsSystemGroup.cs
@@ -16,16 +16,62 @@
namespace Unity.NetCode
{
+ ///
+ /// Rate manager that control when the physics simulation will run.
+ /// The use cases we have:
+ ///
+ /// On the server
+ ///
+ /// Does require physics objects exist? No, physics should run all the time to rebuild the world (empty) if all the physics stuff are gone.
+ /// Static physics: yes, may need to raycast
+ /// Dynamic physics: yes, even if not replicated.
+ /// Triggers (static or dynamic): yes
+ /// Kinematics, non ghost with physics: yes
+ /// Predicted ghost with physics: yes
+ /// Interpolated ghost with physics: yes (kinematics)
+ /// Lag Compensation On: yes, we require the collision history to be rebuilt.
+ ///
+ ///
+ ///
+ /// On the client:
+ ///
+ /// Does require physics objects exist? Ideally yse, in practice no: physics should run all the time to rebuild the world (empty) if all the physics stuff are gone.
+ /// Static physics: yes, may need to raycast. Ideally, this should use client-only physics if there are no ghost. It is up to the users
+ /// Dynamic physics: yes, even if not replicated. Not ideal keep them in world 0 in that case, but necessary. It is up to the users
+ /// Kinematics, non ghost with physics: yes. Not ideal keep them in world 0 in that case, but necessary. It is up to the users
+ /// Predicted ghost with physics: yes
+ /// Interpolated ghost with physics: yes (kinematics). In this case prediction should run only once. Should be up to the user though (not an hidden, opinionated default)
+ /// Lag Compensation On: yes
+ ///
+ /// Overall, the group should always run all the time by default. However, because this would be a breaking change, we allow to opt-in for this behaviour via
+ /// enum.
+ ///
class NetcodePhysicsRateManager : IRateManager
{
private bool m_DidUpdate;
- private EntityQuery m_PredictedGhostPhysicsQuery;
private EntityQuery m_LagCompensationQuery;
+ private EntityQuery m_predictedPhysicsQuery;
+ private EntityQuery m_relaxedPhysicsQuery;
+ private EntityQuery m_PhysicsGroupConfigQuery;
private EntityQuery m_NetworkTimeQuery;
public NetcodePhysicsRateManager(ComponentSystemGroup group)
{
- m_PredictedGhostPhysicsQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly());
+ var queryBuilder = new EntityQueryBuilder(Allocator.Temp);
+ //The default current behaviour: allow physics to run as long as entities with physics velocity exists, either kinematic or dynamic.
+ //This is by far a very restrictive scenario, on client especially. For the server, this can be also
+ //be not what you want. You may need to raycast against some geometry for example.
+ queryBuilder.WithAll().WithAny();
+ m_predictedPhysicsQuery = queryBuilder.Build(group.EntityManager);
+ //this is a more relaxed condition, that allow physics to run as long there are some ghost physics entities. This is more
+ //correct in my opinion, but break some "assumptions" and behavior in respect to the original default, so I left that
+ //only as an options.
+ //It is again not working correctly in case all physics entities get destroyed. The physics collision world is stale in that case.
+ //However, if lag compensation is turned on, everything work fine.
+ queryBuilder.Reset();
+ queryBuilder.WithAny();
+ m_relaxedPhysicsQuery = queryBuilder.Build(group.EntityManager);
m_LagCompensationQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly());
+ m_PhysicsGroupConfigQuery = group.World.EntityManager.CreateEntityQuery(typeof(PhysicsGroupConfig));
m_NetworkTimeQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly());
}
public bool ShouldGroupUpdate(ComponentSystemGroup group)
@@ -35,14 +81,30 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
m_DidUpdate = false;
return false;
}
- // Check if physics needs to update, this is only really needed on the client where predicted physics is expensive
- if (m_PredictedGhostPhysicsQuery.IsEmptyIgnoreFilter)
+ m_PhysicsGroupConfigQuery.TryGetSingleton(out PhysicsGroupConfig groupConfig);
+ if (groupConfig.PhysicsRunMode != PhysicGroupRunMode.AlwaysRun)
{
- if (m_LagCompensationQuery.IsEmptyIgnoreFilter)
- return false;
- var netTime = m_NetworkTimeQuery.GetSingleton();
- if (!netTime.IsFirstTimeFullyPredictingTick)
- return false;
+ bool noEntitiesMatchingQuery;
+ if (groupConfig.PhysicsRunMode == PhysicGroupRunMode.LagCompensationEnabledOrKinematicGhosts)
+ noEntitiesMatchingQuery = m_predictedPhysicsQuery.IsEmptyIgnoreFilter;
+ else
+ noEntitiesMatchingQuery = m_relaxedPhysicsQuery.IsEmptyIgnoreFilter;
+
+ //if query is emtpy and no lag compesation, there is nothing to run
+ if (noEntitiesMatchingQuery)
+ {
+ //On the client, if users set this to 0 is the same as disabling the hystory backup.
+ if (m_LagCompensationQuery.IsEmptyIgnoreFilter ||
+ (group.World.IsClient() &&
+ m_LagCompensationQuery.GetSingleton().ClientHistorySize == 0))
+ {
+ return false;
+ }
+ //if lag compensation is enabled, run only for new full ticks,
+ var netTime = m_NetworkTimeQuery.GetSingleton();
+ if (!netTime.IsFirstTimeFullyPredictingTick)
+ return false;
+ }
}
m_DidUpdate = true;
return true;
diff --git a/Runtime/PortableFunctionPointer.cs b/Runtime/PortableFunctionPointer.cs
index 23f3628..dc45eb5 100644
--- a/Runtime/PortableFunctionPointer.cs
+++ b/Runtime/PortableFunctionPointer.cs
@@ -12,7 +12,7 @@ public struct PortableFunctionPointer where T : Delegate
///
/// Convert the delegate to a burst-compatible function pointer.
///
- ///
+ /// the function delegate
public PortableFunctionPointer(T executeDelegate)
{
Ptr = BurstCompiler.CompileFunctionPointer(executeDelegate);
diff --git a/Runtime/PredictionTicking/GhostPredictionSystemGroup.cs b/Runtime/PredictionTicking/GhostPredictionSystemGroup.cs
index e77a9a9..2061e88 100644
--- a/Runtime/PredictionTicking/GhostPredictionSystemGroup.cs
+++ b/Runtime/PredictionTicking/GhostPredictionSystemGroup.cs
@@ -232,7 +232,7 @@ internal void ConfigureTimeStep(in ClientServerTickRate tickRate)
}
}
#endif
- rateManager.SetTimeStep(tickRate.PredictedFixedStepSimulationTimeStep);
+ rateManager.SetTimeStep(tickRate.PredictedFixedStepSimulationTimeStep, tickRate.PredictedFixedStepSimulationTickRatio);
}
///
@@ -242,7 +242,7 @@ internal void ConfigureTimeStep(in ClientServerTickRate tickRate)
public PredictedFixedStepSimulationSystemGroup()
{
//we are passing 0 as time step so the group does not run until a proper setting is setup.
- SetRateManagerCreateAllocator(new NetcodePredictionFixedRateManager(0f));
+ SetRateManagerCreateAllocator(new NetcodePredictionFixedRateManager(0f, 0));
}
protected override void OnCreate()
{
diff --git a/Runtime/PredictionTicking/NetworkTick.cs b/Runtime/PredictionTicking/NetworkTick.cs
index eb4978c..4cc8884 100644
--- a/Runtime/PredictionTicking/NetworkTick.cs
+++ b/Runtime/PredictionTicking/NetworkTick.cs
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
+using Unity.Burst.CompilerServices;
using Unity.Collections;
using Unity.Properties;
@@ -15,7 +16,7 @@ public struct NetworkTick : IEquatable
[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
private void CheckValid()
{
- if(!IsValid)
+ if(Hint.Unlikely(!IsValid))
throw new InvalidOperationException("Cannot perform calculations with invalid ticks");
}
///
@@ -25,9 +26,9 @@ private void CheckValid()
///
/// Compare two ticks, also works for invalid ticks.
///
- ///
- ///
- ///
+ /// Tick a
+ /// Tick b
+ /// Whether the tick values are equal.
public static bool operator ==(in NetworkTick a, in NetworkTick b)
{
return a.m_Value == b.m_Value;
@@ -35,9 +36,9 @@ private void CheckValid()
///
/// Compare two ticks, also works for invalid ticks.
///
- ///
- ///
- ///
+ /// Tick a
+ /// Tick b
+ /// Whether the tick values are different.
public static bool operator !=(in NetworkTick a, in NetworkTick b)
{
return a.m_Value != b.m_Value;
@@ -45,14 +46,13 @@ private void CheckValid()
///
/// Compare two ticks, also works for invalid ticks.
///
- ///
- ///
+ ///
public override bool Equals(object obj) => obj is NetworkTick && Equals((NetworkTick) obj);
///
/// Compare two ticks, also works for invalid ticks.
///
- ///
- ///
+ /// Network tick to compare with
+ /// Whether has the same tick
public bool Equals(NetworkTick compare)
{
return m_Value == compare.m_Value;
@@ -60,7 +60,7 @@ public bool Equals(NetworkTick compare)
///
/// Get a hash for the tick.
///
- ///
+ /// Internal tick value
public override int GetHashCode()
{
return (int)m_Value;
@@ -143,7 +143,7 @@ public void Decrement()
/// If the passed in tick is newer this will return a negative value.
///
/// The tick to compute passed ticks from
- ///
+ /// The number of ticks which passed since
public int TicksSince(NetworkTick older)
{
CheckValid();
@@ -160,7 +160,7 @@ public int TicksSince(NetworkTick older)
/// the result might not be correct.
///
/// The tick to compare with
- ///
+ /// Whether this tick is newer than another tick.
public bool IsNewerThan(NetworkTick old)
{
CheckValid();
diff --git a/Runtime/PredictionTicking/NetworkTimeSystem.cs b/Runtime/PredictionTicking/NetworkTimeSystem.cs
index f4adb04..95e632b 100644
--- a/Runtime/PredictionTicking/NetworkTimeSystem.cs
+++ b/Runtime/PredictionTicking/NetworkTimeSystem.cs
@@ -401,13 +401,11 @@ public void OnUpdate(ref SystemState state)
}
var estimatedRTT = math.min(ack.EstimatedRTT, clientTickRate.MaxPredictAheadTimeMS);
- var netTickRate = tickRate.CalculateNetworkSendRateInterval();
+ var netTickRateInterval = tickRate.CalculateNetworkSendRateInterval();
// The desired number of interpolation frames depend on the ratio in between the simulation and the network tick rate
// ex: if the server run the sim at 60hz but send at 20hz we need to stay back at least 3 ticks, or
// any integer multiple of that
- var interpolationTimeTicks = (int)clientTickRate.InterpolationTimeNetTicks;
- if (clientTickRate.InterpolationTimeMS != 0)
- interpolationTimeTicks = (int)((clientTickRate.InterpolationTimeMS * tickRate.NetworkTickRate + 999) / 1000);
+ var interpolationTimeTicks = clientTickRate.CalculateInterpolationBufferTimeInTicks(tickRate);
// Reset the latestSnapshotEstimate if not in game
ref var netTimeData = ref SystemAPI.GetSingletonRW().ValueRW;
#if UNITY_EDITOR || NETCODE_DEBUG
@@ -423,7 +421,7 @@ public void OnUpdate(ref SystemState state)
return;
}
netTimeData.InitWithFirstSnapshot(ack.LastReceivedSnapshotByLocal, TimestampMS, clientTickRate.TargetCommandSlack,
- ack.EstimatedRTT, ack.DeviationRTT, interpolationTimeTicks, tickRate.SimulationTickRate, netTickRate);
+ ack.EstimatedRTT, ack.DeviationRTT, interpolationTimeTicks, tickRate.SimulationTickRate, netTickRateInterval);
commandAgeAdjustment.Length = CommandAgeAdjustmentLength;
for (int i = 0; i < CommandAgeAdjustmentLength; ++i)
@@ -520,12 +518,12 @@ public void OnUpdate(ref SystemState state)
//The perceived snapshot inter-arrival in simulation ticks.
var avgNetRate = (netTimeData.avgPacketInterArrival*tickRate.SimulationTickRate + 999)/1000;
//The number of interpolation frames is expressed as number of simulation ticks. This is why it is necessary to use the netTickRate/
- float desiredInterpolationDelayTicks = interpolationTimeTicks*netTickRate;
+ float desiredInterpolationDelayTicks = interpolationTimeTicks*netTickRateInterval;
//Select the largest in between the average snapshot rate (in ticks) and the average snapshot tick delta.
var clampedDelayTick = math.max(avgNetRate, deltaInBetweenSnapshotTicks);
//still clamp this as 6 times the desired netTickRate. It is reasonable assumption the server will try to go
//back to normal
- clampedDelayTick = math.min(clampedDelayTick, 6*netTickRate);
+ clampedDelayTick = math.min(clampedDelayTick, 6*netTickRateInterval);
//If you then have a desiredInterpolationDelayTicks larger that that, we will use your anyway.
var interpolationFrames = math.max(desiredInterpolationDelayTicks, clampedDelayTick);
diff --git a/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientPredictionRateManager.cs b/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientPredictionRateManager.cs
index 5e771ae..aab44fa 100644
--- a/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientPredictionRateManager.cs
+++ b/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientPredictionRateManager.cs
@@ -74,6 +74,7 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
{
networkTime.PredictedTickIndex = 0;
m_CurrentTime = networkTime;
+ m_ClientTickRateQuery.TryGetSingleton(out var clientTickRate);
m_AppliedPredictedTicksQuery.CompleteDependency();
m_UniqueInputTicksQuery.CompleteDependency();
@@ -81,12 +82,20 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
var appliedPredictedTicks = m_AppliedPredictedTicksQuery.GetSingletonRW().ValueRW.AppliedPredictedTicks;
var uniqueInputTicks = m_UniqueInputTicksQuery.GetSingletonRW().ValueRW.TickMap;
- // Nothing to predict
- if (!m_CurrentTime.ServerTick.IsValid || appliedPredictedTicks.IsEmpty)
+
+ // Nothing to predict yet, because the connection is not in game yet and no snapshot has
+ // being received so far (still waiting for the first snapshot)
+ if (!m_CurrentTime.ServerTick.IsValid)
+ return false;
+
+ // If there is not predicted ghost (so no continuation or rollback to do)
+ if(appliedPredictedTicks.IsEmpty)
{
uniqueInputTicks.Clear();
appliedPredictedTicks.Clear();
- return false;
+ //early exit if the prediction mode require ghosts are present, thus the appliedPredictedTicks should be non empty.
+ if(clientTickRate.PredictionLoopUpdateMode == PredictionLoopUpdateMode.RequirePredictedGhost)
+ return false;
}
m_TargetTick = m_CurrentTime.ServerTick;
@@ -104,6 +113,10 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
// We must simulate at the tick we used as last full tick last time since smoothing and error reporting is happening there
if (m_LastFullPredictionTick.IsValid && m_TargetTick.IsNewerThan(m_LastFullPredictionTick))
appliedPredictedTicks.TryAdd(m_LastFullPredictionTick, m_LastFullPredictionTick);
+ else if (!m_LastFullPredictionTick.IsValid)
+ m_LastFullPredictionTick = m_TargetTick;
+
+
m_AppliedPredictedTickArray = appliedPredictedTicks.GetKeyArray(Allocator.Temp);
@@ -114,6 +127,8 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
if (!oldestTick.IsValid || oldestTick.IsNewerThan(appliedTick))
oldestTick = appliedTick;
}
+ //If this condition trigger (that is, removed pretty much where we should start predicting from)
+ //it is ok and correct to exit.
if (!oldestTick.IsValid)
{
uniqueInputTicks.Clear();
@@ -157,7 +172,6 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
group.World.EntityManager.SetComponentEnabled(m_GhostQuery, false);
- m_ClientTickRateQuery.TryGetSingleton(out var clientTickRate);
if (clientTickRate.MaxPredictionStepBatchSizeRepeatedTick < 1)
clientTickRate.MaxPredictionStepBatchSizeRepeatedTick = 1;
if (clientTickRate.MaxPredictionStepBatchSizeFirstTimeTick < 1)
diff --git a/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientRateManager.cs b/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientRateManager.cs
index f13358b..419409b 100644
--- a/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientRateManager.cs
+++ b/Runtime/PredictionTicking/UpdateRateManagement/NetcodeClientRateManager.cs
@@ -12,7 +12,7 @@ internal struct PreviousServerTick : IComponentData
public NetworkTick Value;
public float Fraction;
}
-
+
class NetcodeClientRateManager : IRateManager
{
private EntityQuery m_NetworkTimeQuery;
@@ -21,6 +21,7 @@ class NetcodeClientRateManager : IRateManager
private EntityQuery m_ClientSeverTickRateQuery;
private EntityQuery m_NetworkStreamInGameQuery;
private EntityQuery m_NetworkTimeSystemDataQuery;
+ private EntityQuery m_NetDebugQuery;
private readonly PredictedFixedStepSimulationSystemGroup m_PredictedFixedStepSimulationSystemGroup;
private bool m_DidPushTime;
@@ -33,6 +34,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_NetDebugQuery = group.World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly());
m_PredictedFixedStepSimulationSystemGroup = group.World.GetExistingSystemManaged();
var netTimeEntity = group.World.EntityManager.CreateEntity(
@@ -106,7 +108,7 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
if (networkDeltaTime <= 0)
{
- Debug.Log("Delta time was negative. To avoid undefined behaviour the frame is skipped.");
+ m_NetDebugQuery.GetSingleton().DebugLog($"[{group.World.Name}] Netcode's network delta time is negative: {networkDeltaTime}. To avoid undefined behaviour, the frame will be skipped.");
return false;
}
diff --git a/Runtime/PredictionTicking/UpdateRateManagement/NetcodePredictionFixedRateManager.cs b/Runtime/PredictionTicking/UpdateRateManagement/NetcodePredictionFixedRateManager.cs
index 5b2f24c..af98292 100644
--- a/Runtime/PredictionTicking/UpdateRateManagement/NetcodePredictionFixedRateManager.cs
+++ b/Runtime/PredictionTicking/UpdateRateManagement/NetcodePredictionFixedRateManager.cs
@@ -1,6 +1,7 @@
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
+using Unity.Mathematics;
namespace Unity.NetCode
{
@@ -21,6 +22,7 @@ public float Timestep
int m_RemainingUpdates;
float m_TimeStep;
+ int m_StepRatio;
double m_ElapsedTime;
private EntityQuery networkTimeQuery;
//used to track invalid usage of the TimeStep setter.
@@ -35,9 +37,11 @@ public float DeprecatedTimeStep
#endif
DoubleRewindableAllocators* m_OldGroupAllocators = null;
- public NetcodePredictionFixedRateManager(float defaultTimeStep)
+ public int RemainingUpdates => m_RemainingUpdates;
+
+ public NetcodePredictionFixedRateManager(float defaultTimeStep, int ratio)
{
- SetTimeStep(defaultTimeStep);
+ SetTimeStep(defaultTimeStep, ratio);
}
public void OnCreate(ComponentSystemGroup group)
@@ -45,9 +49,10 @@ public void OnCreate(ComponentSystemGroup group)
networkTimeQuery = group.EntityManager.CreateEntityQuery(typeof(NetworkTime));
}
- public void SetTimeStep(float timeStep)
+ public void SetTimeStep(float timeStep, int ratio)
{
m_TimeStep = timeStep;
+ m_StepRatio = ratio;
#if UNITY_EDITOR || NETCODE_DEBUG
m_DeprecatedTimeStep = 0f;
#endif
@@ -64,16 +69,26 @@ public bool ShouldGroupUpdate(ComponentSystemGroup group)
}
else if(m_TimeStep > 0f)
{
- // Add epsilon to account for floating point inaccuracy
- m_RemainingUpdates = (int)((group.World.Time.DeltaTime + 0.001f) / m_TimeStep);
- if (m_RemainingUpdates > 0)
+ var networkTime = networkTimeQuery.GetSingleton();
+ //While not running for partial ticks in case the of stepRatio 1:1 ? Because the ClientSimulationSystemGroup
+ //already ensure we are in withing the 5% of the tick rate, so rounding in this case does not make much of a sense.
+ //But for stepRatio > 1, the current physic loop run faster, meaning that partial ticks actually cause physics to do
+ //potentially 1 or more steps.
+ if (!networkTime.IsPartialTick || m_StepRatio > 1)
{
- var networkTime = networkTimeQuery.GetSingleton();
+ //We still allow physics to step if we are in withing 1% of the step time.
+ m_RemainingUpdates = (int) (group.World.Time.DeltaTime / m_TimeStep);
m_ElapsedTime = group.World.Time.ElapsedTime;
+ //on the client we allow the physics to run for partial ticks. This is a valid situation in case the fixed loop run at higher tick rate than the
+ //simulation. This though add some extra burden client side in term of physics stepping. For example:
+ //if the step ratio is 2 (run at 120hz), client will run 3 physics simulation per tick instead of 2 on average
+ // 1 tick, for partial, dt > physics dt
+ // 2 ticks full tick
+ //
+ // While it is correct that physics run this way (is running at 120 hz), it may be not what you want (to keep the cost lower).
+ // Should an option seems like necessary to configure this behavior and let the user be in control?
if (networkTime.IsPartialTick)
{
- //dt = m_FixedTimeStep * networkTime.ServerTickFraction;
- //elapsed since last full tick = m_ElapsedTime - dt;
m_ElapsedTime -= group.World.Time.DeltaTime;
m_ElapsedTime += m_RemainingUpdates * m_TimeStep;
}
diff --git a/Runtime/PredictionTicking/UpdateRateManagement/NetcodeTimeTracker.cs b/Runtime/PredictionTicking/UpdateRateManagement/NetcodeTimeTracker.cs
index 49c33f0..ad78702 100644
--- a/Runtime/PredictionTicking/UpdateRateManagement/NetcodeTimeTracker.cs
+++ b/Runtime/PredictionTicking/UpdateRateManagement/NetcodeTimeTracker.cs
@@ -2,6 +2,7 @@
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
+using Unity.Mathematics;
using Unity.Profiling;
using static Unity.NetCode.ClientServerTickRate.FrameRateMode;
@@ -26,6 +27,8 @@ internal struct Count
internal int RemainingTicksToRun;
private float m_AccumulatedTime;
+ private bool m_IsFirstTimeExecuting = true;
+ private double m_ElapsedTime;
private Count m_UpdateCount;
private ProfilerMarker m_fixedUpdateMarker;
private readonly PredictedFixedStepSimulationSystemGroup m_PredictedFixedStepSimulationSystemGroup;
@@ -61,22 +64,30 @@ private static Count UpdateAccumulatorForDeltaTime(float deltaTime, float fixedT
{
accumulatedTime += deltaTime;
int updateCount = (int)(accumulatedTime / fixedTimeStep);
- accumulatedTime = accumulatedTime % fixedTimeStep;
+ // ex:
+ // accumulatedTime = 0.16666666
+ // fixedTimeStep = 0.0.016666666
+ // updateCount = 10, maxTimeSteps = 4, maxTimeStepLength = 4
int shortSteps = 0;
int length = 1;
- if (updateCount > maxTimeSteps)
+ if (updateCount > maxTimeSteps) // 10 > 4
{
// Required length
- length = (updateCount + maxTimeSteps - 1) / maxTimeSteps;
- if (length > maxTimeStepLength)
+ // +maxTimeSteps-1 to get the implicit int cast to "round up"
+ length = (updateCount + maxTimeSteps - 1) / maxTimeSteps; // (10 + 4 - 1) / 4 = 13/4 = (int)3.25 = 3
+ if (length > maxTimeStepLength) // 3 ! > 4
length = maxTimeStepLength;
else
{
// Check how many will need to be long vs short
- shortSteps = length * maxTimeSteps - updateCount;
+ shortSteps = length * maxTimeSteps - updateCount; // 3 * 4 - 10 = 2
}
- updateCount = maxTimeSteps;
+ updateCount = maxTimeSteps; // 4
}
+
+ var longStepCount = updateCount - shortSteps; // 4 - 2 = 2
+ var timeConsumedThisFrame = length * fixedTimeStep * longStepCount + (length - 1) * fixedTimeStep * shortSteps; // 3 * 0.016666666 * 2 + (3 - 1) * 0.016666666 * 2 = 0.1666666666 == accumulatedTime
+ accumulatedTime -= timeConsumedThisFrame;
return new Count
{
TotalSteps = updateCount,
@@ -125,6 +136,13 @@ internal void PushTime(ComponentSystemGroup group, float dt, NetworkTime network
internal void UpdateNetworkTime(ComponentSystemGroup group, ClientServerTickRate tickRate, ref NetworkTime networkTime)
{
+ if (m_IsFirstTimeExecuting)
+ {
+ m_IsFirstTimeExecuting = false;
+ // we want to keep the same behaviour as UpdateWorldTimeSystem which starts at 0 for the first frame
+ // here this will be negative and then clamped to 0 later
+ m_ElapsedTime = group.World.Time.ElapsedTime - group.World.Time.DeltaTime;
+ }
if (RemainingTicksToRun == (m_UpdateCount.ShortStepCount))
--m_UpdateCount.LengthLongSteps;
var dt = GetDeltaTimeForCurrentTick(tickRate);
@@ -140,7 +158,9 @@ internal void UpdateNetworkTime(ComponentSystemGroup group, ClientServerTickRate
networkTime.Flags &= ~NetworkTimeFlags.IsCatchUpTick;
else
networkTime.Flags |= NetworkTimeFlags.IsCatchUpTick;
- networkTime.ElapsedNetworkTime += dt;
+ m_ElapsedTime += dt;
+ // At the beginning of the world, we'll be a few prediction ticks with a negative elapsedTime value if the first frame has a high deltaTime. This is needed so that during that first frame, if we execute multiple batched ticks that we're still following the world's elapsed time.
+ networkTime.ElapsedNetworkTime = math.max(m_ElapsedTime, 0);
}
private void AdjustTargetFrameRate(int tickRate, float fixedTimeStep)
diff --git a/Runtime/Rpc/IRpcCommand.cs b/Runtime/Rpc/IRpcCommand.cs
index 6edb2bc..7c7acee 100644
--- a/Runtime/Rpc/IRpcCommand.cs
+++ b/Runtime/Rpc/IRpcCommand.cs
@@ -146,7 +146,7 @@ public struct RpcDeserializerState
/// and all necessary boilerplate code.
///
///
- ///
+ /// Component type to serialize
public interface IRpcCommandSerializer where T: struct, IComponentData
{
///
@@ -156,9 +156,9 @@ public interface IRpcCommandSerializer where T: struct, IComponentData
/// interface.
/// You must implement this method yourself when you opt-in for manual serialization.
///
- ///
- ///
- ///
+ /// Data writer
+ /// Serializer state
+ /// data to serialize
void Serialize(ref DataStreamWriter writer, in RpcSerializerState state, in T data);
///
/// Method called by the when an rpc is dequeued from the
@@ -168,19 +168,18 @@ public interface IRpcCommandSerializer where T: struct, IComponentData
/// interface.
/// You must implement this method yourself when you opt-in for manual serialization.
///
- ///
- ///
- ///
+ /// Data reader
+ /// Serializer state
+ /// data to read into
void Deserialize(ref DataStreamReader reader, in RpcDeserializerState state, ref T data);
///
/// Invoked when the rpc is registered to the at runtime.
- /// Should return a valid burst-compatible function pointer of a static method
- /// that will be called after the rpc has been deserialized to actually "execute" the command.
/// By declaring rpcs using , this method is automatically generated.
/// See for further information on how to use it to implement your
/// custom execute method.
///
- ///
+ /// A valid burst-compatible function pointer of a static method that will be called
+ /// after the rpc has been deserialized to actually "execute" the command.
PortableFunctionPointer CompileExecute();
}
}
diff --git a/Runtime/Rpc/RpcCollection.cs b/Runtime/Rpc/RpcCollection.cs
index 7b35f73..0902428 100644
--- a/Runtime/Rpc/RpcCollection.cs
+++ b/Runtime/Rpc/RpcCollection.cs
@@ -41,11 +41,23 @@ public FixedString512Bytes ToFixedString()
}
}
///
- /// Treat the set of assemblies loaded on the client / server as dynamic or different. This is only required if
- /// assemblies containing ghost component serializers or RPC serializers are removed when building standalone.
- /// This property is read in OnUpdate, so it must be set before then. Defaults to false, which saves 6 bytes per header,
- /// and allows RPC version errors to trigger immediately upon connecting to the server (rather than needing to wait for
- /// an invalid RPC to be received).
+ ///
+ /// Allows the set assemblies loaded on the client and server to differ. This is useful during development when
+ /// assemblies containing ghost component serializers or RPCs are removed when building standalone.
+ /// This usually happens during development when you are connecting a standalone player to the Editor.
+ /// For example, tests are usually not included in a standalone build, but they are still compiled and
+ /// registered in the Editor, which causes a mismatch in the set of assemblies.
+ ///
+ ///
+ /// If set to false (default), the RPC system triggers an RPC version error when connecting to a server with
+ /// a different set of assemblies. This is more strict and acts as a validation step during handshake.
+ ///
+ ///
+ /// If set to true, six bytes is added to the header of each RPC.
+ /// The RPC system doesn't trigger an RPC version error when connecting to
+ /// a server with a different set of assemblies. Instead, an error will be triggered if an invalid RPC or serialized component is
+ /// received.
+ ///
///
public bool DynamicAssemblyList
{
diff --git a/Runtime/Rpc/RpcCommandRequest.cs b/Runtime/Rpc/RpcCommandRequest.cs
index 3e700ac..67a9382 100644
--- a/Runtime/Rpc/RpcCommandRequest.cs
+++ b/Runtime/Rpc/RpcCommandRequest.cs
@@ -2,6 +2,9 @@
#define NETCODE_DEBUG
#endif
using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
using System.Runtime.CompilerServices;
using Unity.Collections;
using Unity.Entities;
@@ -61,7 +64,7 @@ public bool IsConsumed
}
///
- /// has a which will log a warning if this value exceeds .
+ /// has a which will log a warning if this value exceeds .
/// Counts simulation frames.
/// 0 is the simulation frame it is received on.
///
@@ -142,7 +145,7 @@ public struct SendRpcData
internal byte isApprovalRpc;
internal byte isServer;
internal FixedString128Bytes worldName;
-
+ internal NativeArray.ReadOnly connectionEventsForTick;
// Process all send requests
void LambdaMethod(Entity entity, int orderIndex, in SendRpcCommandRequest dest, in TActionRequest action)
@@ -150,84 +153,132 @@ void LambdaMethod(Entity entity, int orderIndex, in SendRpcCommandRequest dest,
commandBuffer.DestroyEntity(orderIndex, entity);
if (dest.TargetConnection != Entity.Null)
{
- ValidateAndQueueRpc(dest.TargetConnection, action);
+ ValidateIncorrectApprovalUsage(dest.TargetConnection, false);
+ ValidateAndQueueRpc(dest.TargetConnection, false, action);
}
else
{
if (connections.Length == 0)
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
- if (isServer != 0)
- netDebug.LogWarning($"[{worldName}] Cannot broadcast RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' as no remote connections. I.e. No `NetworkStreamConnection` entities found, as no clients connected to this server.");
- else
- netDebug.LogWarning($"[{worldName}] Cannot send RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' with no remote connection. I.e. No `NetworkStreamConnection` entity, as this client world is not connected (nor connecting) to any server.");
+ var msg = isServer != 0
+ ? $"[{worldName}] Cannot broadcast RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' as no remote connections. I.e. No `NetworkStreamConnection` entities found, as no clients connected to this server."
+ : $"[{worldName}] Cannot send RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to the server as not connected to one! I.e. No `NetworkStreamConnection` entity, as this client world is not connected (nor connecting) to any server.";
+ if (!AnyDisconnectEvents(connectionEventsForTick))
+ netDebug.LogWarning(msg);
+ else netDebug.DebugLog(msg);
+ static bool AnyDisconnectEvents(NativeArray.ReadOnly eventsForTickLocal)
+ {
+ foreach (var evt in eventsForTickLocal)
+ if (evt.State == ConnectionState.State.Disconnected)
+ return true;
+ return false;
+ }
#endif
return;
}
+
+ ValidateIncorrectApprovalUsage(connections[0], isServer != 0);
for (var i = 0; i < connections.Length; ++i)
{
- ValidateAndQueueRpc(connections[i], action);
+ ValidateAndQueueRpc(connections[i], isServer != 0, action);
}
}
}
- private void ValidateAndQueueRpc(Entity connectionEntity, in TActionRequest action)
+ private void ValidateAndQueueRpc(Entity connectionEntity, bool isBroadcast, in TActionRequest action)
{
- // TODO - Distinguish between entity deleted and Entity never was a NetworkStreamConnection "NetworkConnection" entity.
- // One is an error, the other is a warning.
- if (!networkStreamConnectionLookup.TryGetComponent(connectionEntity, out var networkStreamConnection))
+ // TODO - If cleanup components are removed (and/or structural changes disallowed),
+ // add error if you assign an incorrect Entity to the TargetConnection by checking entityExists.
+ if (!networkStreamConnectionLookup.TryGetComponent(connectionEntity, out var networkStreamConnection)
+ || !rpcFromEntity.TryGetBuffer(connectionEntity, out var buffer))
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
- netDebug.LogWarning($"[{worldName}] Cannot send RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {connectionEntity.ToFixedString()} as it does not have a `NetworkStreamConnection` entity. It's either recently deleted, or you assigned an invalid entity.");
+ if (isBroadcast || FindDidJustDisconnect(connectionEntity))
+ netDebug.DebugLog($"{Prefix(true, connectionEntity)} as they just disconnected.");
+ else
+ netDebug.LogWarning($"{Prefix(false, connectionEntity)} as its connection entity ({connectionEntity.ToFixedString()}) does not have a `NetworkStreamConnection` or `OutgoingRpcDataStreamBuffer` component (anymore?). Did you assign the correct entity?");
#endif
return;
}
var isHandshakeOrApproval = networkStreamConnection.IsHandshakeOrApproval;
- if (!isHandshakeOrApproval)
+ if (isHandshakeOrApproval)
{
- var isConnected = networkStreamConnection.CurrentState == ConnectionState.State.Connected && networkIdLookup.HasComponent(connectionEntity);
- if (!isConnected)
+ if (isApprovalRpc == 0)
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
- netDebug.LogError($"[{worldName}] Cannot send RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {connectionEntity.ToFixedString()} as {networkStreamConnection.Value.ToFixedString()} is in state `{networkStreamConnection.CurrentState.ToFixedString()}`!");
+ FixedString512Bytes msg = $"{Prefix(isBroadcast, connectionEntity)} as it is not an Approval RPC, and its {networkStreamConnection.Value.ToFixedString()} - on {connectionEntity.ToFixedString()} - is in state `{networkStreamConnection.CurrentState.ToFixedString()}`!";
+ if (isBroadcast)
+ netDebug.DebugLog(msg);
+ else
+ {
+ msg.Append((FixedString128Bytes)" You MUST wait for Handshake and Approval to complete, OR convert this RPC to an `IApprovalRpcCommand`!");
+ netDebug.LogError(msg);
+ }
#endif
return;
}
}
-
- if (!rpcFromEntity.TryGetBuffer(connectionEntity, out var buffer))
- {
-#if ENABLE_UNITY_COLLECTIONS_CHECKS
- netDebug.LogError($"Cannot send RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {connectionEntity.ToFixedString()} as {networkStreamConnection.Value.ToFixedString()} has no `OutgoingRpcDataStreamBuffer` RPC buffer!");
-#endif
- return;
- }
-
- if (isHandshakeOrApproval)
+ else
{
- if (isApprovalRpc == 0)
+ var isConnected = networkStreamConnection.CurrentState == ConnectionState.State.Connected && networkIdLookup.HasComponent(connectionEntity);
+ if (!isConnected)
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
- netDebug.LogError($"[{worldName}] Cannot send non-approval RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {connectionEntity.ToFixedString()} as {networkStreamConnection.Value.ToFixedString()} is in state `{networkStreamConnection.CurrentState.ToFixedString()}`! Wait for handshake and approval to complete!");
+ FixedString512Bytes msg = $"{Prefix(isBroadcast, connectionEntity)} as its {networkStreamConnection.Value.ToFixedString()} - on {connectionEntity.ToFixedString()} - is in state `{networkStreamConnection.CurrentState.ToFixedString()}`!";
+ if (isBroadcast)
+ netDebug.DebugLog(msg);
+ else netDebug.LogError(msg);
#endif
return;
}
}
+ rpcQueue.Schedule(buffer, ghostFromEntity, action);
+ }
+
+ private bool FindDidJustDisconnect(Entity entity)
+ {
+ foreach (var evt in connectionEventsForTick)
+ {
+ if (evt.State == ConnectionState.State.Disconnected && evt.ConnectionEntity == entity)
+ return true;
+ }
+ return false;
+ }
+
+ [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
+ private void ValidateIncorrectApprovalUsage(Entity connectionEntity, bool isBroadcast)
+ {
#if ENABLE_UNITY_COLLECTIONS_CHECKS
- if(requireConnectionApproval == 0 && isApprovalRpc == 1 && !netDebug.SuppressApprovalRpcSentWhenApprovalFlowDisabledWarning)
- netDebug.LogWarning($"[{worldName}] Sending approval RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {connectionEntity.ToFixedString()} ('{networkStreamConnection.Value.ToFixedString()}') but connection approval is disabled. RPC will still be sent. If intentional, suppress via `NetDebug.SuppressApprovalRpcSentWhenApprovalFlowDisabledWarning`.");
+ if (requireConnectionApproval == 0 && isApprovalRpc == 1 && !netDebug.SuppressApprovalRpcSentWhenApprovalFlowDisabledWarning)
+ {
+ FixedString512Bytes msg = isBroadcast
+ ? $"[{worldName}] Broadcasting approval RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' but connection approval is disabled. We will still attempt to broadcast the RPC."
+ : $"[{worldName}] Sending approval RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {Target(connectionEntity)} but connection approval is disabled. We will still attempt to send the RPC.";
+ msg.Append((FixedString128Bytes)" If intentional, suppress via `NetDebug.SuppressApprovalRpcSentWhenApprovalFlowDisabledWarning`.");
+ netDebug.LogWarning(msg);
+ }
#endif
+ }
- rpcQueue.Schedule(buffer, ghostFromEntity, action);
+#if ENABLE_UNITY_COLLECTIONS_CHECKS
+ FixedString512Bytes Prefix(bool isBroadcast, Entity connectionEntity)
+ {
+ return isBroadcast
+ ? $"[{worldName}] Broadcast of RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' will skip client connection {connectionEntity.ToFixedString()}"
+ : $"[{worldName}] Cannot send RPC '{ComponentType.ReadOnly().GetDebugTypeName()}' to {Target(connectionEntity)}";
}
+ private FixedString128Bytes Target(Entity connectionEntity) => isServer == 0 ? $"the server" : $"TargetConnection:{connectionEntity.ToFixedString()}";
+#endif
+
///
- /// Call this from an method to handle the rpc requests.
+ /// Call this from a method to handle the rpc requests.
///
- ///
- ///
+ /// Chunk
+ /// Order index
public void Execute(ArchetypeChunk chunk, int orderIndex)
{
var entities = chunk.GetNativeArray(entitiesType);
@@ -319,6 +370,8 @@ public void OnCreate(ref SystemState state)
/// initialized using
public SendRpcData InitJobData(ref SystemState state)
{
+ var connections = m_ConnectionsQuery.ToEntityListAsync(state.WorldUpdateAllocator,
+ out var connectionsHandle);
m_EntityTypeHandle.Update(ref state);
m_SendRpcCommandRequestComponentHandle.Update(ref state);
m_TActionRequestHandle.Update(ref state);
@@ -326,8 +379,7 @@ public SendRpcData InitJobData(ref SystemState state)
m_NetworkIdLookup.Update(ref state);
m_NetworkStreamConnectionLookup.Update(ref state);
m_OutgoingRpcDataStreamBufferComponentFromEntity.Update(ref state);
- var connections = m_ConnectionsQuery.ToEntityListAsync(state.WorldUpdateAllocator,
- out var connectionsHandle);
+ var nsd = m_NetworkStreamDriver.GetSingleton();
var sendJob = new SendRpcData
{
commandBuffer = m_CommandBufferQuery.GetSingleton().CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(),
@@ -340,8 +392,9 @@ public SendRpcData InitJobData(ref SystemState state)
networkStreamConnectionLookup = m_NetworkStreamConnectionLookup,
rpcQueue = m_RpcQueue,
connections = connections,
+ connectionEventsForTick = nsd.ConnectionEventsForTick,
netDebug = m_NetDebugQuery.GetSingleton(),
- requireConnectionApproval = m_NetworkStreamDriver.GetSingleton().RequireConnectionApproval ? (byte)1 : (byte)0,
+ requireConnectionApproval = nsd.RequireConnectionApproval ? (byte)1 : (byte)0,
isApprovalRpc = m_IsApprovalRpc ? (byte)1 : (byte)0,
isServer = state.WorldUnmanaged.IsServer() ? (byte)1 : (byte)0,
worldName = state.WorldUnmanaged.Name,
diff --git a/Runtime/Rpc/RpcQueue.cs b/Runtime/Rpc/RpcQueue.cs
index 46aa026..1539a1b 100644
--- a/Runtime/Rpc/RpcQueue.cs
+++ b/Runtime/Rpc/RpcQueue.cs
@@ -44,10 +44,10 @@ public struct RpcQueue
/// - MsgLen: short, the length of the serialized data.
/// - RpcData: the binary data generated by invoking the serialize method.
///
- ///
- ///
- ///
- ///
+ /// Stream buffer for the rpc packetsk
+ /// Lookup for ghost instance
+ /// data
+ /// If the RPC index cannot be found for the rpc type.
public unsafe void Schedule(DynamicBuffer buffer,
ComponentLookup ghostFromEntity, TActionRequest data)
{
diff --git a/Runtime/Rpc/RpcSystem.cs b/Runtime/Rpc/RpcSystem.cs
index c0a8932..799dbc2 100644
--- a/Runtime/Rpc/RpcSystem.cs
+++ b/Runtime/Rpc/RpcSystem.cs
@@ -95,7 +95,7 @@ public RpcDeserializerState DeserializerState
/// The DisableDirectCall = true was necessary to workaround an issue with burst and function delegate.
/// If you are implementing your custom rpc serializer, please remember to disable the direct call.
///
- ///
+ /// Parameters for custom rpc serializer
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ExecuteDelegate(ref Parameters parameters);
@@ -612,8 +612,30 @@ public void Execute(Entity entity, in RpcSystem.ProtocolVersionError rpcError)
errorHeader.Append(localProtocol.ToFixedString());
errorHeader.Append((FixedString32Bytes)"\nRemote protocol: ");
errorHeader.Append(rpcError.remoteProtocol.ToFixedString());
+ errorHeader.Append((FixedString512Bytes)"\nSee the following errors for more information.");
netDebug.LogError(errorHeader);
+ if (localProtocol.NetCodeVersion != rpcError.remoteProtocol.NetCodeVersion)
+ {
+ netDebug.LogError((FixedString512Bytes)"The NetCode version mismatched between remote and local. Ensure that you are using the same version of Netcode for Entities on both client and server.");
+ }
+
+ if (localProtocol.GameVersion != rpcError.remoteProtocol.GameVersion)
+ {
+ netDebug.LogError((FixedString512Bytes)"The Game version mismatched between remote and local. Ensure that you are using the same version of the game on both client and server.");
+ }
+
+ if (localProtocol.RpcCollectionVersion != rpcError.remoteProtocol.RpcCollectionVersion)
+ {
+ netDebug.LogError((FixedString512Bytes)"The RPC Collection mismatched between remote and local. Compare the following list of RPCs against the set produced by the remote, to find which RPCs are misaligned. You can also enable `RpcCollection.DynamicAssemblyList` to relax this requirement (which is recommended during development, see documentation for more details).");
+ }
+
+ if (localProtocol.ComponentCollectionVersion != rpcError.remoteProtocol.ComponentCollectionVersion)
+ {
+ netDebug.LogError((FixedString512Bytes)"The Component Collection mismatched between remote and local. Compare the following list of Components against the set produced by the remote, to find which components are misaligned. You can also enable `RpcCollection.DynamicAssemblyList` to relax this requirement (which is recommended during development, see documentation for more details).");
+ }
+
+
var s = (FixedString512Bytes)"RPC List (for above 'bad protocol version' error): ";
s.Append(rpcs.Length);
netDebug.LogError(s);
diff --git a/Runtime/SerializationHelpers/IGhostSerializer.cs b/Runtime/SerializationHelpers/IGhostSerializer.cs
index a1f6ba9..fda11c9 100644
--- a/Runtime/SerializationHelpers/IGhostSerializer.cs
+++ b/Runtime/SerializationHelpers/IGhostSerializer.cs
@@ -33,20 +33,20 @@ public interface IGhostSerializer
///
/// Copy/Convert the component data to the snapshot.
///
- ///
- ///
- ///
+ /// Serializer state
+ /// Snapshot pointer
+ /// Component
void CopyToSnapshot(in GhostSerializerState serializerState, [NoAlias]IntPtr snapshot, [ReadOnly][NoAlias]IntPtr component);
///
/// Copy/Convert the snapshot to component. Perform interpolation if necessary.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Serializer state
+ /// Component
+ /// Interpolation factor
+ /// Interpolation factor
+ /// Snapshot before
+ /// Snapshot after
public void CopyFromSnapshot(in GhostDeserializerState serializerState, [NoAlias] IntPtr component,
float snapshotInterpolationFactor, float snapshotInterpolationFactorRaw,
[NoAlias] [ReadOnly] IntPtr snapshotBefore, [NoAlias] [ReadOnly] IntPtr snapshotAfter);
@@ -54,21 +54,21 @@ public void CopyFromSnapshot(in GhostDeserializerState serializerState, [NoAlias
///
/// Compute the change mask for the snapshot in respect to the given baseline
///
- ///
- ///
- ///
- ///
+ /// Snapshot pointer
+ /// Snapshot baseline
+ /// Change mask data
+ /// Start offset
void CalculateChangeMask([NoAlias][ReadOnly]IntPtr snapshot, [NoAlias][ReadOnly]IntPtr baseline, [NoAlias]IntPtr changeMaskData, int startOffset);
///
/// Serialise the snapshot data to the and calculate the current changemask.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot pointer
+ /// Snapshot baseline
+ /// Change mask data
+ /// Start offset
+ /// Datastream writer
+ /// Compression model
void SerializeCombined([ReadOnly][NoAlias] IntPtr snapshot, [ReadOnly][NoAlias] IntPtr baseline,
[NoAlias][ReadOnly]IntPtr changeMaskData, int startOffset,
ref DataStreamWriter writer, in StreamCompressionModel compressionModel);
@@ -76,15 +76,15 @@ void SerializeCombined([ReadOnly][NoAlias] IntPtr snapshot, [ReadOnly][NoAlias]
///
/// Serialise the snapshot dato to the and calculate the current changemask.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot pointer
+ /// Snapshot baseline
+ /// Snapshot baseline
+ /// Snapshot baseline
+ /// Delta predicot
+ /// Change mask data
+ /// Start offset
+ /// Datastream writer
+ /// Compression model
void SerializeWithPredictedBaseline([ReadOnly] [NoAlias] IntPtr snapshot,
[ReadOnly] [NoAlias] IntPtr baseline0,
[ReadOnly] [NoAlias] IntPtr baseline1,
@@ -97,12 +97,12 @@ void SerializeWithPredictedBaseline([ReadOnly] [NoAlias] IntPtr snapshot,
/// Serialise the snapshot dato to the based on the calculated changemask.
/// Expecte the changemask bits be all already set.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot pointer
+ /// Snapshot baseline
+ /// Change mask data
+ /// start offset
+ /// data stream writer
+ /// comrpession model
void Serialize([ReadOnly][NoAlias] IntPtr snapshot, [ReadOnly][NoAlias] IntPtr baseline,
[NoAlias][ReadOnly]IntPtr changeMaskData, int startOffset,
ref DataStreamWriter writer, in StreamCompressionModel compressionModel);
@@ -110,21 +110,21 @@ void Serialize([ReadOnly][NoAlias] IntPtr snapshot, [ReadOnly][NoAlias] IntPtr b
///
/// Calculate the predicted snapshot from the two baseline
///
- ///
- ///
- ///
- ///
+ /// Predicted snapshot data
+ /// Snapshot baseline
+ /// Snapshot baseline
+ /// Delta predictor
void PredictDelta([NoAlias] IntPtr snapshotData, [NoAlias] IntPtr baseline1Data, [NoAlias] IntPtr baseline2Data, ref GhostDeltaPredictor predictor);
///
/// Read the data from the stream into the snapshot data.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Data stream reader
+ /// compression model
+ /// change mask
+ /// start offset
+ /// Snapshot pointer
+ /// Snapshot baseline
void Deserialize(ref DataStreamReader reader, in StreamCompressionModel compressionModel,
IntPtr changeMask,
int startOffset, [NoAlias]IntPtr snapshot, [NoAlias][ReadOnly]IntPtr baseline);
@@ -132,18 +132,18 @@ void Deserialize(ref DataStreamReader reader, in StreamCompressionModel compress
///
/// Restore the component data from the prediction backup buffer. Only serialised fields are restored.
///
- ///
- ///
+ /// Component
+ /// Backup buffer
void RestoreFromBackup([NoAlias]IntPtr component, [NoAlias][ReadOnly]IntPtr backup);
#if UNITY_EDITOR || NETCODE_DEBUG
///
/// Calculate the prediction error for this component.
///
- ///
- ///
- ///
- ///
+ /// Component
+ /// Backup buffer
+ /// Error list pointer
+ /// Number of errors
void ReportPredictionErrors([NoAlias][ReadOnly]IntPtr component, [NoAlias][ReadOnly]IntPtr backup, IntPtr errorsList,
int errorsCount);
#endif
@@ -163,30 +163,30 @@ public interface IGhostSerializer
///
/// Calculate the predicted baseline.
///
- ///
- ///
- ///
- ///
+ /// Snapshot reference
+ /// Snapshot baseline
+ /// Snapshot baseline
+ /// Delta predictor
void PredictDeltaGenerated(ref TSnapshot snapshot, in TSnapshot baseline1, in TSnapshot baseline2, ref GhostDeltaPredictor predictor);
///
/// Compute the change mask for the snapshot in respect to the given baseline
///
- ///
- ///
- ///
- ///
+ /// Snapshot reference
+ /// Snapshot baseline
+ /// Change mask data
+ /// Start offset
void CalculateChangeMaskGenerated(in TSnapshot snapshot, in TSnapshot baseline, IntPtr changeMaskData, int startOffset){}
///
/// Copy/Convert the data form the snapshot to the component. Support interpolation and extrapolation.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Serializer state
+ /// Component
+ /// Interpolation factor
+ /// Snapshot interpolation factor
+ /// Snapshot before
+ /// Snapshot after
void CopyFromSnapshotGenerated(in GhostDeserializerState serializerState, ref TComponent component,
float interpolationFactor, float snapshotInterpolationFactorRaw, in TSnapshot snapshotBefore,
in TSnapshot snapshotAfter);
@@ -194,21 +194,21 @@ void CopyFromSnapshotGenerated(in GhostDeserializerState serializerState, ref TC
///
/// Copy/Convert the component data to the snapshot.
///
- ///
- ///
- ///
+ /// Serializer state
+ /// Snapshot reference
+ /// Component
void CopyToSnapshotGenerated(in GhostSerializerState serializerState, ref TSnapshot snapshot,
in TComponent component);
///
/// Serialise the snapshot dato to the based on the calculated changemask.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot reference
+ /// Snapshot baseline
+ /// Change mask data
+ /// Start offset
+ /// Datastream writer
+ /// Compression model
void SerializeGenerated(in TSnapshot snapshot, in TSnapshot baseline,
[ReadOnly][NoAlias]IntPtr changeMaskData, int startOffset, ref DataStreamWriter writer,
in StreamCompressionModel compressionModel);
@@ -216,12 +216,12 @@ void SerializeGenerated(in TSnapshot snapshot, in TSnapshot baseline,
///
/// Serialise the snapshot dato to the based on the calculated changemask.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot reference
+ /// Snapshot baseline
+ /// Change mask data
+ /// Start offset
+ /// Datastream writer
+ /// Compression model
void SerializeCombinedGenerated(in TSnapshot snapshot, in TSnapshot baseline,
[NoAlias][ReadOnly]IntPtr changeMaskData, int startOffset,
ref DataStreamWriter writer, in StreamCompressionModel compressionModel);
@@ -229,12 +229,12 @@ void SerializeCombinedGenerated(in TSnapshot snapshot, in TSnapshot baseline,
///
/// Read the data from the stream into the snapshot data.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Data stream reader
+ /// Compression model
+ /// Change mask
+ /// Starting offset
+ /// Snapshot reference
+ /// Snapshot baseline
void DeserializeGenerated(ref DataStreamReader reader, in StreamCompressionModel compressionModel,
IntPtr changeMask,
int startOffset, ref TSnapshot snapshot, in TSnapshot baseline);
@@ -242,18 +242,18 @@ void DeserializeGenerated(ref DataStreamReader reader, in StreamCompressionModel
///
/// Restore the component data from the prediction backup buffer. Only serialised fields are restored.
///
- ///
- ///
+ /// Component
+ /// Backup buffer
void RestoreFromBackupGenerated(ref TComponent component, in TComponent backup);
#if UNITY_EDITOR || NETCODE_DEBUG
///
/// Calculate the prediction error for this component.
///
- ///
- ///
- ///
- ///
+ /// Component
+ /// Backup buffer
+ /// Data for errors
+ /// Error count
void ReportPredictionErrorsGenerated(in TComponent component, in TComponent backup, IntPtr errorsList,
int errorsCount);
#endif
diff --git a/Runtime/Simulator/MultiplayerPlayModePreferences.cs b/Runtime/Simulator/MultiplayerPlayModePreferences.cs
index 6cd5141..9a37457 100644
--- a/Runtime/Simulator/MultiplayerPlayModePreferences.cs
+++ b/Runtime/Simulator/MultiplayerPlayModePreferences.cs
@@ -1,8 +1,12 @@
#if UNITY_EDITOR
using System;
+using System.Collections.Generic;
+using Unity.Collections;
+using Unity.Entities;
using Unity.Mathematics;
using Unity.Networking.Transport;
using Unity.Networking.Transport.Utilities;
+using Unity.Scenes;
using UnityEditor;
using UnityEngine;
@@ -46,6 +50,9 @@ public static class MultiplayerPlayModePreferences
static string s_LoggerLevelType = s_PrefsKeyPrefix + "NetDebugLogger_LogLevelType";
static string s_TargetShouldDumpPackets = s_PrefsKeyPrefix + "NetDebugLogger_ShouldDumpPackets";
static string s_ShowAllSimulatorPresets = s_PrefsKeyPrefix + "ShowAllSimulatorPresets";
+ static string s_WarnBatchedTicks = s_PrefsKeyPrefix + "NetDebugLogger_WarnBacthedTicks";
+ static string s_WarnBatchedTicksRollingWindow = s_PrefsKeyPrefix + "NetDebugLogger_WarnBatchedTicksRollingWindow";
+ static string s_WarnAboveAverageTicksPerFrame = s_PrefsKeyPrefix + "NetDebugLogger_WarnAboveAverageTicksPerFrame";
/// Stores whether or not the user wishes to use the client simulator UTP module.
///
@@ -173,14 +180,20 @@ public static int PacketFuzzPercentage
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).
+ ///
+ /// Denotes how many thin client worlds are created in the editor (via the utility),
+ /// assuming that feature is enabled.
+ ///
public static int RequestedNumThinClients
{
get => math.clamp(EditorPrefs.GetInt(s_RequestedNumThinClientsKey, 0), 0, ClientServerBootstrap.k_MaxNumThinClients);
set => EditorPrefs.SetInt(s_RequestedNumThinClientsKey, math.clamp(value, 0, ClientServerBootstrap.k_MaxNumThinClients));
}
- /// How many thin client worlds to spawn per second. 0 implies spawn all at once.
+ ///
+ /// Denotes how many thin client worlds to spawn per second when in the editor (via the utility),
+ /// assuming that feature is enabled.
+ ///
public static float ThinClientCreationFrequency
{
get => math.clamp(EditorPrefs.GetFloat(s_StaggerThinClientCreationKey, 2), 0f, 1_000);
@@ -223,6 +236,56 @@ public static bool ApplyLoggerSettings
set => EditorPrefs.SetBool(s_ApplyLoggerSettings, value);
}
+ /// If true, will force to display a warning when prediction ticks are batched.
+ public static bool WarnBatchedTicks
+ {
+ get => EditorPrefs.GetBool(s_WarnBatchedTicks, true);
+ set
+ {
+ EditorPrefs.SetBool(s_WarnBatchedTicks, value);
+
+ foreach (var serverWorld in ClientServerBootstrap.ServerWorlds)
+ {
+ using var netDebugQuery = serverWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite());
+ netDebugQuery.GetSingletonRW().ValueRW.WarnBatchedTicks = value;
+ }
+ }
+ }
+
+ /// Specifies the number of frames the rolling average is calcualted over.
+ public static int WarnBatchedTicksRollingWindow
+ {
+ get => EditorPrefs.GetInt(s_WarnBatchedTicksRollingWindow, 4);
+ set
+ {
+ EditorPrefs.SetInt(s_WarnBatchedTicksRollingWindow, value);
+
+ foreach (var serverWorld in ClientServerBootstrap.ServerWorlds)
+ {
+ using var netDebugQuery = serverWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite());
+ netDebugQuery.GetSingletonRW().ValueRW.WarnBatchedTicksRollingWindowSize = value;
+ }
+ }
+ }
+
+ /// If the average is above this percent a warning will be displayed. Set to 0 to always warn when ticks are batched.
+ public static float WarnAboveAverageBatchedTicksPerFrame
+ {
+ get => EditorPrefs.GetFloat(s_WarnAboveAverageTicksPerFrame, 1.2f);
+ set
+ {
+ EditorPrefs.SetFloat(s_WarnAboveAverageTicksPerFrame, value);
+
+ foreach (var serverWorld in ClientServerBootstrap.ServerWorlds)
+ {
+ using var netDebugQuery = serverWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite());
+ netDebugQuery.GetSingletonRW().ValueRW.WarnAboveAverageBatchedTicksPerFrame = value;
+ }
+ }
+ }
+
+
+
/// If , forces all loggers to this log level.
public static NetDebug.LogLevelType TargetLogLevel
{
diff --git a/Runtime/Simulator/SimulatorPreset.cs b/Runtime/Simulator/SimulatorPreset.cs
index 98ae843..363b8dc 100644
--- a/Runtime/Simulator/SimulatorPreset.cs
+++ b/Runtime/Simulator/SimulatorPreset.cs
@@ -191,12 +191,12 @@ internal static bool TryGetPresetFromName(string name, List all
///
/// Construct a new preset.
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Simulator name
+ /// Packet delay in miliseconds
+ /// Packet jitter in miliseconds
+ /// Packet loss in percentage
+ /// Packet fuzz in percentage
+ /// Tooltip string
public SimulatorPreset(string name, int packetDelayMs, int packetJitterMs, int packetLossPercent, int packetFuzzPercent, string tooltip)
{
Name = name;
@@ -210,12 +210,12 @@ public SimulatorPreset(string name, int packetDelayMs, int packetJitterMs, int p
///
/// Construct a new preset.
///
- ///
- ///
- ///
- ///
- ///
- [Obsolete("Use other constructor. (RemovedAfter Entities 1.1)")]
+ /// Simulator name
+ /// Packet delay in miliseconds
+ /// Packet jitter in miliseconds
+ /// Packet loss in percentage
+ /// Tooltip string
+ [Obsolete("Use other constructor. (RemovedAfter 2.0)")]
public SimulatorPreset(string name, int packetDelayMs, int packetJitterMs, int packetLossPercent, string tooltip)
: this(name, packetDelayMs, packetJitterMs, packetLossPercent, 0, tooltip)
{
diff --git a/Runtime/Snapshot/GhostChunkSerializationState.cs b/Runtime/Snapshot/GhostChunkSerializationState.cs
index b399740..3b0ec60 100644
--- a/Runtime/Snapshot/GhostChunkSerializationState.cs
+++ b/Runtime/Snapshot/GhostChunkSerializationState.cs
@@ -15,7 +15,9 @@ unsafe struct GhostChunkSerializationState
{
public ulong sequenceNumber;
public int ghostType;
- public int baseImportance;
+ // Cached here for perf.
+ public ushort baseImportance;
+ public ushort maxSendRateAsSimTickInterval;
// the entity and data arrays are 2d arrays (chunk capacity * max snapshots)
// Find baseline by finding the largest tick not at writeIndex which has been acked by the other end
diff --git a/Runtime/Snapshot/GhostChunkSerializer.cs b/Runtime/Snapshot/GhostChunkSerializer.cs
index 5a75fcc..d4380a5 100644
--- a/Runtime/Snapshot/GhostChunkSerializer.cs
+++ b/Runtime/Snapshot/GhostChunkSerializer.cs
@@ -27,7 +27,6 @@ internal unsafe struct GhostChunkSerializer
public DynamicBuffer GhostTypeCollection;
public DynamicBuffer GhostComponentIndex;
public ComponentTypeHandle PrespawnIndexType;
- public Unity.Profiling.ProfilerMarker ghostGroupMarker;
public EntityStorageInfoLookup childEntityLookup;
public BufferTypeHandle linkedEntityGroupType;
public BufferTypeHandle prespawnBaselineTypeHandle;
@@ -57,16 +56,12 @@ internal unsafe struct GhostChunkSerializer
#if NETCODE_DEBUG
public PacketDumpLogger netDebugPacket;
public byte enablePacketLogging;
- public byte enablePerComponentProfiling;
public FixedString64Bytes ghostTypeName;
FixedString512Bytes debugLog;
#endif
[ReadOnly] public NativeParallelHashMap SnapshotPreSerializeData;
- public byte forceSingleBaseline;
- public byte keepSnapshotHistoryOnStructuralChange;
- public byte snaphostHasCompressedGhostSize;
- public byte useCustomSerializer;
+ public GhostSendSystemData systemData;
private NativeArray tempRelevancyPerEntity;
private NativeList tempAvailableBaselines;
@@ -388,7 +383,7 @@ private static void ValidateGhostType(int entityGhostType, int ghostType)
private void ComponentScopeBegin(int serializerIdx)
{
#if NETCODE_DEBUG
- if (enablePerComponentProfiling == 1)
+ if (systemData.EnablePerComponentProfiling)
GhostComponentCollection[serializerIdx].ProfilerMarker.Begin();
#endif
}
@@ -396,7 +391,7 @@ private void ComponentScopeBegin(int serializerIdx)
private void ComponentScopeEnd(int serializerIdx)
{
#if NETCODE_DEBUG
- if (enablePerComponentProfiling == 1)
+ if (systemData.EnablePerComponentProfiling)
GhostComponentCollection[serializerIdx].ProfilerMarker.End();
#endif
}
@@ -593,7 +588,7 @@ private int SerializeEntities(ref DataStreamWriter dataStream, out int skippedEn
SnapshotPreSerializeData preSerializedSnapshot = default;
var hasPreserializeData = chunk.Has(ref preSerializedGhostType) && SnapshotPreSerializeData.TryGetValue(chunk, out preSerializedSnapshot);
- var hasCustomSerializer = useCustomSerializer != 0 && typeData.CustomSerializer.Ptr.IsCreated;
+ var hasCustomSerializer = systemData.UseCustomSerializer != 0 && typeData.CustomSerializer.Ptr.IsCreated;
var lastSerializedEntity = endIndex;
if (hasCustomSerializer)
@@ -1057,7 +1052,7 @@ private int SerializeEntities(ref DataStreamWriter dataStream, out int skippedEn
uint anyChangeMaskThisEntity = 0;
uint anyEnableableMaskChangedThisEntity = 0;
- if (snaphostHasCompressedGhostSize == 1)
+ if (GhostSystemConstants.SnaphostHasCompressedGhostSize)
{
var headerLen = 0;
//Calculate the compressed size of the header part and add that to the final ghost size
@@ -1161,16 +1156,16 @@ private int SerializeEntities(ref DataStreamWriter dataStream, out int skippedEn
PacketDumpFlush();
if (typeData.IsGhostGroup != 0)
{
- ghostGroupMarker.Begin();
+ GhostSendSystem.k_GhostGroupMarker.Begin();
var ghostGroup = chunk.GetBufferAccessor(ref ghostGroupType)[ent];
// Serialize all other ghosts in the group, this also needs to be handled correctly in the receive system
dataStream.WritePackedUInt((uint)ghostGroup.Length, compressionModel);
PacketDumpBeginGroup(ghostGroup.Length);
PacketDumpFlush();
- bool success = SerializeGroup(ref dataStream, ref compressionModel, ghostGroup, useSingleBaseline);
+ bool success = SerializeGroup(ref dataStream, ghostGroup, useSingleBaseline);
- ghostGroupMarker.End();
+ GhostSendSystem.k_GhostGroupMarker.End();
if (!success)
{
// Abort before setting the entity since the snapshot is not going to be sent
@@ -1270,7 +1265,7 @@ private bool CanSerializeGroup(in DynamicBuffer ghostGroup)
}
return true;
}
- private bool SerializeGroup(ref DataStreamWriter dataStream, ref StreamCompressionModel compressionModel, in DynamicBuffer ghostGroup, bool useSingleBaseline)
+ private bool SerializeGroup(ref DataStreamWriter dataStream, in DynamicBuffer ghostGroup, bool useSingleBaseline)
{
var grpAvailableBaselines = new NativeList(GhostSystemConstants.SnapshotHistorySize, Allocator.Temp);
for (int i = 0; i < ghostGroup.Length; ++i)
@@ -1386,7 +1381,7 @@ int UpdateGhostRelevancy(ArchetypeChunk chunk, int startIndex, byte* relevancyDa
{
hasSpawns = false;
var ghost = chunk.GetNativeArray(ref ghostComponentType);
- var ghostEntities = chunk.GetNativeArray(entityType);
+ //var ghostEntities = chunk.GetNativeArray(entityType);
var ghostSystemState = chunk.GetNativeArray(ref ghostSystemStateType);
// First figure out the baselines to use per entity so they can be sent as baseline + maxCount instead of one per entity
int irrelevantCount = 0;
@@ -1457,9 +1452,11 @@ int UpdateValidGhostGroupRelevancy(ArchetypeChunk chunk, int startIndex, byte* r
}
return irrelevantCount;
}
- bool CanUseStaticOptimization(ArchetypeChunk chunk, int ghostType, int writeIndex, uint* snapshotIndex, in GhostChunkSerializationState chunkState,
+ bool CanUseStaticOptimization(ArchetypeChunk chunk, int ghostType, int writeIndex, uint* snapshotIndex, ref GhostChunkSerializationState chunkState,
bool hasRelevancyChanges, ref bool canSkipZeroChange)
{
+ using var _ = GhostSendSystem.k_CanUseStaticOptimization.Auto();
+
// If nothing in the chunk changed we don't even have to try sending it
int baseOffset = GhostTypeCollection[ghostType].FirstComponent;
int numChildComponents = GhostTypeCollection[ghostType].NumChildComponents;
@@ -1538,7 +1535,7 @@ private void UpdateChunkHistory(int ghostType, ArchetypeChunk currentChunk, Ghos
// but we cannot read the entity array because we do not know the capacity
// GetLastValidTick is required to know that the memory used by LastChunk is currently used as a chunk storing ghosts, without that check the chunk memory could be reused for
// something else before or during this loop causing it to access invalid memory (which could also change during the loop)
- if ((keepSnapshotHistoryOnStructuralChange == 1) && ghostState.LastChunk != default && GhostTypeCollection[ghostType].NumBuffers == 0 &&
+ if (systemData.KeepSnapshotHistoryOnStructuralChange && ghostState.LastChunk != default && GhostTypeCollection[ghostType].NumBuffers == 0 &&
chunkSerializationData.TryGetValue(ghostState.LastChunk, out var prevChunkState) && prevChunkState.GetLastValidTick() == currentTick &&
prevChunkState.IsSameSizeAndCapacity(snapshotSize, ghostState.LastChunk.Capacity))
{
@@ -1600,8 +1597,9 @@ private void UpdateChunkHistory(int ghostType, ArchetypeChunk currentChunk, Ghos
}
}
public SerializeEnitiesResult SerializeChunk(in PrioChunk serialChunk, ref DataStreamWriter dataStream,
- ref uint updateLen, ref bool didFillPacket)
+ out uint currentChunkUpdateLen, ref uint totalSentEntities, ref uint totalSentChunks, ref bool didFillPacket)
{
+ currentChunkUpdateLen = 0;
int entitySize = UnsafeUtility.SizeOf();
bool relevancyEnabled = (relevancyMode != GhostRelevancyMode.Disabled);
bool hasRelevancySpawns = false;
@@ -1682,7 +1680,7 @@ public SerializeEnitiesResult SerializeChunk(in PrioChunk serialChunk, ref DataS
{
// If a chunk was modified it will be cleared after we serialize the content
// If the snapshot is still zero change we only want to update the version, not the tick, since we still did not send anything
- if (CanUseStaticOptimization(chunk, ghostType, writeIndex, snapshotIndex, chunkState, hasRelevancySpawns, ref canSkipZeroChange))
+ if (CanUseStaticOptimization(chunk, ghostType, writeIndex, snapshotIndex, ref chunkState, hasRelevancySpawns, ref canSkipZeroChange))
{
// There were not changes we required to send, treat is as if we did send the chunk to make sure we do not collect all static chunks as the top priority ones
chunkState.SetLastUpdate(currentTick);
@@ -1710,10 +1708,8 @@ public SerializeEnitiesResult SerializeChunk(in PrioChunk serialChunk, ref DataS
return SerializeEnitiesResult.Ok;
int ent;
-
uint anyChangeMask = 0;
int skippedEntityCount = 0;
- uint currentChunkUpdateLen = 0;
var oldStream = dataStream;
dataStream.WritePackedUInt((uint) ghostType, compressionModel);
@@ -1732,9 +1728,9 @@ public SerializeEnitiesResult SerializeChunk(in PrioChunk serialChunk, ref DataS
GhostTypeCollection[ghostType].profilerMarker.Begin();
// Write the chunk for current ghostType to the data stream
tempWriter.Clear(); // Clearing the temp writer here instead of inside the method to make it easier to deal with ghost groups which recursively adds more data to the temp writer
- ent = SerializeEntities(ref dataStream, out skippedEntityCount, out anyChangeMask, ghostType, chunk, startIndex, endIndex, useStaticOptimization || (forceSingleBaseline == 1), currentSnapshot);
+ ent = SerializeEntities(ref dataStream, out skippedEntityCount, out anyChangeMask, ghostType, chunk, startIndex, endIndex, useStaticOptimization || systemData.ForceSingleBaseline, currentSnapshot);
GhostTypeCollection[ghostType].profilerMarker.End();
- if (useStaticOptimization && anyChangeMask == 0 && startIndex == 0 && ent < endIndex && updateLen > 0)
+ if (useStaticOptimization && anyChangeMask == 0 && startIndex == 0 && ent < endIndex && totalSentEntities > 0)
{
PacketDumpStaticOptimizeChunk();
// Do not send partial chunks for zero changes unless we have to since the zero change optimizations only kick in if the full chunk was sent
@@ -1742,7 +1738,6 @@ public SerializeEnitiesResult SerializeChunk(in PrioChunk serialChunk, ref DataS
didFillPacket = true;
return SerializeEnitiesResult.Failed;
}
- currentChunkUpdateLen = (uint) (ent - serialChunk.startIndex - skippedEntityCount);
bool isZeroChange = ent >= chunk.Count && serialChunk.startIndex == 0 && anyChangeMask == 0;
if (isZeroChange && canSkipZeroChange)
@@ -1753,52 +1748,56 @@ public SerializeEnitiesResult SerializeChunk(in PrioChunk serialChunk, ref DataS
}
else
{
- updateLen += currentChunkUpdateLen;
+ currentChunkUpdateLen = (uint) (ent - serialChunk.startIndex - skippedEntityCount);
+ totalSentEntities += currentChunkUpdateLen;
+ // TODO - Simplify to didWriteToSnapshot, which can be inferred in the calling code.
+ totalSentChunks++;
}
- // Spawn chunks are temporary and should not be added to the state data cache
- if (chunk.Has(ref ghostSystemStateType))
+#if ENABLE_UNITY_COLLECTIONS_CHECKS
+ Assert.IsTrue(chunk.Has(ref ghostSystemStateType), "Spawn chunks should already be filtered out! (RemovedAfter 1.x)");
+#endif
+
+ // Only append chunks which contain data, and only update the write index if we actually sent it
+ if (currentChunkUpdateLen > 0)
{
- // Only append chunks which contain data, and only update the write index if we actually sent it
- if (currentChunkUpdateLen > 0 && !(isZeroChange && canSkipZeroChange))
- {
- if (serialChunk.startIndex > 0)
- UnsafeUtility.MemClear(currentSnapshot.SnapshotEntity, entitySize * serialChunk.startIndex);
- if (ent < chunk.Capacity)
- UnsafeUtility.MemClear(currentSnapshot.SnapshotEntity + ent,
- entitySize * (chunk.Capacity - ent));
- var nextWriteIndex = (chunkState.GetSnapshotWriteIndex() + 1) % GhostSystemConstants.SnapshotHistorySize;
- chunkState.SetSnapshotWriteIndex(nextWriteIndex);
- }
+ if (serialChunk.startIndex > 0)
+ UnsafeUtility.MemClear(currentSnapshot.SnapshotEntity, entitySize * serialChunk.startIndex);
+ if (ent < chunk.Capacity)
+ UnsafeUtility.MemClear(currentSnapshot.SnapshotEntity + ent,
+ entitySize * (chunk.Capacity - ent));
+ var nextWriteIndex = (chunkState.GetSnapshotWriteIndex() + 1) % GhostSystemConstants.SnapshotHistorySize;
+ chunkState.SetSnapshotWriteIndex(nextWriteIndex);
+ }
- if (ent >= chunk.Count)
- {
- chunkState.SetLastUpdate(currentTick);
- }
- else
- {
- // TODO: should this always be run or should partial chunks only be allowed for the highest priority chunk?
- //if (pc == 0)
- chunkState.SetStartIndex(ent);
- }
+ if (ent >= chunk.Count)
+ {
+ chunkState.SetLastUpdate(currentTick);
+ }
+ else
+ {
+ // TODO: should this always be run or should partial chunks only be allowed for the highest priority chunk?
+ //if (pc == 0)
+ chunkState.SetStartIndex(ent);
+ }
- if (isZeroChange)
- {
- var zeroChangeTick = chunkState.GetFirstZeroChangeTick();
- if (!zeroChangeTick.IsValid)
- zeroChangeTick = currentTick;
- chunkState.SetFirstZeroChange(zeroChangeTick, CurrentSystemVersion);
- }
- else
- {
- chunkState.SetFirstZeroChange(NetworkTick.Invalid, 0);
- }
+ if (isZeroChange)
+ {
+ var zeroChangeTick = chunkState.GetFirstZeroChangeTick();
+ if (!zeroChangeTick.IsValid)
+ zeroChangeTick = currentTick;
+ chunkState.SetFirstZeroChange(zeroChangeTick, CurrentSystemVersion);
+ }
+ else
+ {
+ chunkState.SetFirstZeroChange(NetworkTick.Invalid, 0);
}
+
// Could not send all ghosts, so packet must be full
if (ent < chunk.Count)
{
didFillPacket = true;
- return SerializeEnitiesResult.Failed;
+ return SerializeEnitiesResult.Failed; // TODO - Remove this confusing concept of a result. It didn't "Fail", it succeeded but simply filled the packet.
}
return SerializeEnitiesResult.Ok;
}
diff --git a/Runtime/Snapshot/GhostCollectionComponent.cs b/Runtime/Snapshot/GhostCollectionComponent.cs
index b59d45e..e71bdeb 100644
--- a/Runtime/Snapshot/GhostCollectionComponent.cs
+++ b/Runtime/Snapshot/GhostCollectionComponent.cs
@@ -47,6 +47,7 @@ public ComponentReference(int index, ulong hash)
}
public int Importance;
+ public byte MaxSendRate;
public GhostMode SupportedModes;
public GhostMode DefaultMode;
public bool StaticOptimization;
@@ -286,11 +287,12 @@ public struct GhostCollectionPrefabSerializer : IBufferElementData
/// Client CPU optimization. Force predicted ghost to always try to continue from the last prediction in case of structural changes. True by default (because may introduce some issue when replicated component are removed).
///
public byte RollbackPredictionOnStructuralChanges;
- ///
- /// Reflect the importance value set in the . Is used as the base value for the
- /// scaled importance calculated at runtime.
- ///
+ ///
public int BaseImportance;
+ /// expressed as a interval
+ /// (i.e. the number of ticks until we can send again).
+ ///
+ public byte MaxSendRateAsSimTickInterval;
///
/// Used by the to assign the type of to use for this ghost,
/// if no other user-defined system has classified how the new ghost should be spawned.
@@ -529,20 +531,20 @@ public struct Context
///
/// Delegate to specify a custom order for the serialised components.
///
- ///
- ///
+ /// Serialized component types
+ /// Number of components
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void CollectComponentDelegate(IntPtr componentTypes, IntPtr componentCount);
///
/// Delegate for the custom chunk serializer.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Chunk
+ /// Type data
+ /// Component indices
+ /// Context
+ /// Datastream writer
+ /// Compression model
+ /// Last serialized entity
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ChunkSerializerDelegate(
ref ArchetypeChunk chunk,
@@ -555,10 +557,10 @@ public delegate void ChunkSerializerDelegate(
///
/// Delegate for the custom chunk pre-serialization function.
///
- ///
- ///
- ///
- ///
+ /// Chunk
+ /// Type data
+ /// Component indices
+ /// Context
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ChunkPreserializeDelegate(
in ArchetypeChunk chunk,
diff --git a/Runtime/Snapshot/GhostCollectionSystem.cs b/Runtime/Snapshot/GhostCollectionSystem.cs
index d037690..907c6d0 100644
--- a/Runtime/Snapshot/GhostCollectionSystem.cs
+++ b/Runtime/Snapshot/GhostCollectionSystem.cs
@@ -277,16 +277,6 @@ struct AddComponentCtx
public GhostType ghostType;
public FixedString64Bytes ghostName;
public NetDebug netDebug;
-
- public void Update(ref SystemState state, Entity collectionSingleton)
- {
- ghostPrefabCollection = state.EntityManager.GetBuffer(collectionSingleton);
- ghostSerializerCollection = state.EntityManager.GetBuffer(collectionSingleton);
- ghostPrefabSerializerCollection = state.EntityManager.GetBuffer(collectionSingleton);
- ghostComponentCollection = state.EntityManager.GetBuffer(collectionSingleton);
- ghostComponentIndex = state.EntityManager.GetBuffer(collectionSingleton);
- customSerializers = state.EntityManager.GetComponentData(collectionSingleton);
- }
}
[BurstCompile]
@@ -513,6 +503,8 @@ public void OnUpdate(ref SystemState state)
}
}
+ SystemAPI.TryGetSingleton(out ClientServerTickRate tickRate);
+ tickRate.ResolveDefaults();
var ctx = new AddComponentCtx
{
ghostPrefabCollection = state.EntityManager.GetBuffer(collectionSingleton),
@@ -521,7 +513,7 @@ public void OnUpdate(ref SystemState state)
ghostComponentCollection = state.EntityManager.GetBuffer(collectionSingleton),
ghostComponentIndex = state.EntityManager.GetBuffer(collectionSingleton),
customSerializers = state.EntityManager.GetComponentData(collectionSingleton),
- netDebug = netDebug
+ netDebug = netDebug,
};
var data = SystemAPI.GetSingletonRW().ValueRW;
var ghostPrefabSerializerErrors = 0;
@@ -546,7 +538,7 @@ public void OnUpdate(ref SystemState state)
if (ghost.GhostPrefab != Entity.Null)
{
// This can be setup - do so
- ProcessGhostPrefab(ref state, ref data, ref ctx, ghost.GhostPrefab);
+ ProcessGhostPrefab(ref state, ref data, ref ctx, ref tickRate, ghost.GhostPrefab);
// Ensure it was added (can fail due to collection checks):
if (ctx.ghostPrefabSerializerCollection.Length > i)
hash = HashGhostType(ctx.ghostPrefabSerializerCollection[i], in netDebug, in ctx.ghostName, in entityPrefabName, in ghost.GhostPrefab);
@@ -714,7 +706,7 @@ private static void ValidatePrefabGUID(Entity ent, in GhostType ghostType, Dynam
#endif
}
- private void ProcessGhostPrefab(ref SystemState state, ref GhostComponentSerializerCollectionData data, ref AddComponentCtx ctx, Entity prefabEntity)
+ private void ProcessGhostPrefab(ref SystemState state, ref GhostComponentSerializerCollectionData data, ref AddComponentCtx ctx, ref ClientServerTickRate tickRate, Entity prefabEntity)
{
var ghostPrefabMetadata = state.EntityManager.GetComponentData(prefabEntity);
ref var ghostMetaData = ref ghostPrefabMetadata.Value.Value;
@@ -764,6 +756,7 @@ private void ProcessGhostPrefab(ref SystemState state, ref GhostComponentSeriali
OwnerPredicted = (ghostMetaData.DefaultMode == GhostPrefabBlobMetaData.GhostMode.Both) ? 1 : 0,
PartialComponents = 0,
BaseImportance = ghostMetaData.Importance,
+ MaxSendRateAsSimTickInterval = tickRate.CalculateNetworkSendIntervalOfGhostInTicks(ghostMetaData.MaxSendRate),
FallbackPredictionMode = fallbackPredictionMode,
IsGhostGroup = state.EntityManager.HasComponent(prefabEntity) ? 1 : 0,
StaticOptimization = (byte)(ghostMetaData.StaticOptimization ? 1 :0),
diff --git a/Runtime/Snapshot/GhostComponent.cs b/Runtime/Snapshot/GhostComponent.cs
index b80f56a..72125fe 100644
--- a/Runtime/Snapshot/GhostComponent.cs
+++ b/Runtime/Snapshot/GhostComponent.cs
@@ -88,8 +88,8 @@ public struct GhostInstance : IComponentData
///
/// Implicitly convert a GhostComponent to a instance.
///
- ///
- ///
+ /// Ghost component to convert
+ /// Converted ghost component to .
public static implicit operator SpawnedGhost(in GhostInstance comp)
{
return new SpawnedGhost(comp.ghostId, comp.spawnTick);
@@ -162,8 +162,8 @@ internal static GhostType FromHash128String(string guid)
///
/// Create a new from the give guid.
///
- ///
- ///
+ /// Guid
+ /// Converted ghost type from the give guid.
internal static GhostType FromHash128(Hash128 guid)
{
return new GhostType
@@ -179,8 +179,8 @@ internal static GhostType FromHash128(Hash128 guid)
/// Convert a to a instance. The hash will always match the prefab guid
/// from which the ghost has been created.
///
- ///
- ///
+ /// Ghost type to convert
+ /// Converted ghost type as .
public static explicit operator Hash128(GhostType ghostType)
{
return new Hash128(ghostType.guid0, ghostType.guid1, ghostType.guid2, ghostType.guid3);
@@ -190,9 +190,9 @@ public static explicit operator Hash128(GhostType ghostType)
///
/// Returns whether or not two GhostType are identical.
///
- ///
- ///
- /// True if the the types guids are the same.
+ /// Ghost type
+ /// Ghost type
+ /// Whether the types guids are the same.
public static bool operator ==(GhostType lhs, GhostType rhs)
{
return lhs.guid0 == rhs.guid0 && lhs.guid1 == rhs.guid1 && lhs.guid2 == rhs.guid2 && lhs.guid3 == rhs.guid3;
@@ -200,9 +200,9 @@ public static explicit operator Hash128(GhostType ghostType)
///
/// Returns whether or not two GhostType are distinct.
///
- ///
- ///
- /// True if the the types guids are the different.
+ /// Ghost type
+ /// Ghost type
+ /// Whether the types guids are the same.
public static bool operator !=(GhostType lhs, GhostType rhs)
{
return lhs.guid0 != rhs.guid0 || lhs.guid1 != rhs.guid1 || lhs.guid2 != rhs.guid2 || lhs.guid3 != rhs.guid3;
@@ -210,8 +210,8 @@ public static explicit operator Hash128(GhostType ghostType)
///
/// Returns whether or not the reference is identical to the current instance.
///
- ///
- ///
+ /// Ghost type reference
+ /// whether the reference is identical to the current instance.
public bool Equals(GhostType other)
{
return this == other;
@@ -220,7 +220,7 @@ public bool Equals(GhostType other)
/// Returns whether or not the reference is of type `GhostType`, and
/// whether or not it's identical to the current instance.
///
- ///
+ /// Ghost type reference
/// True if equal to the passed in `GhostType`.
public override bool Equals(object obj)
{
@@ -284,7 +284,7 @@ public struct PredictedGhost : IComponentData
///
/// Query if the entity should be simulated (predicted) for the given tick.
///
- ///
+ /// Network tick to simulate for
/// True if the entity should be simulated.
public bool ShouldPredict(NetworkTick tick)
{
@@ -319,6 +319,10 @@ public struct PredictedGhostSpawnRequest : IComponentData
/// Component on the client signaling that an entity is a placeholder for a "not yet spawned" ghost.
/// I.e. Not yet a "real" ghost.
///
+ ///
+ /// Note: If you query for 's without excluding this component, your query will return placeholder
+ /// ghosts (unless manually excluded).
+ ///
public struct PendingSpawnPlaceholder : IComponentData
{
}
@@ -332,7 +336,7 @@ public static class GhostComponentUtilities
/// Find the first valid ghost type id in an array of ghost components.
/// Pre-spawned ghosts have type id -1.
///
- ///
+ /// NativeArray containing ghost type ids
/// The ghost type index if a ghost with a valid type is found, -1 otherwise
public static int GetFirstGhostTypeId(this NativeArray self)
{
@@ -344,7 +348,7 @@ public static int GetFirstGhostTypeId(this NativeArray self)
/// Pre-spawned ghosts have type id -1.
/// This method returns -1 if no ghost type id is found.
///
- ///
+ /// NativeArray containing ghost type ids
/// The first valid ghost type index found will be stored in this variable.
/// A valid ghost type id, or -1 if no ghost type id was found.
public static int GetFirstGhostTypeId(this NativeArray self, out int firstGhost)
@@ -361,7 +365,7 @@ public static int GetFirstGhostTypeId(this NativeArray self, out
///
/// Retrieve the component name as . The method is burst compatible.
///
- ///
+ /// Component type to get the name from
/// The component name.
public static NativeText.ReadOnly GetDebugTypeName(this ComponentType self)
{
diff --git a/Runtime/Snapshot/GhostComponentSerializer.cs b/Runtime/Snapshot/GhostComponentSerializer.cs
index 04d86f4..25de016 100644
--- a/Runtime/Snapshot/GhostComponentSerializer.cs
+++ b/Runtime/Snapshot/GhostComponentSerializer.cs
@@ -73,67 +73,67 @@ public enum SendMask
///
/// Delegate method to use to post-serialize the component when the ghost use pre-serialization optimization.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot stride
+ /// Maskoffset in bits
+ /// Count
+ /// Snapshot baseline
+ /// Datastream writer
+ /// Compression model
+ /// Entity start bit
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void PostSerializeDelegate(IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit);
///
/// Delegate method to use to post-serialize buffers when the ghost use pre-serialization optimization.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot stride
+ /// Maskoffset in bits
+ /// Change mask bits
+ /// Count
+ /// Snapshot baseline
+ /// Datastream writer
+ /// Compression model
+ /// Entity start bit
+ /// Dynamic data pointer
+ /// Dynamic size per entity
+ /// Dynamic snapshot max offset
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void PostSerializeBufferDelegate(IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, int changeMaskBits, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit, IntPtr snapshotDynamicDataPtr, IntPtr dynamicSizePerEntity, int dynamicSnapshotMaxOffset);
///
/// Delegate method used to serialize the component data for the root entity into the outgoing data stream.
/// Works in batches.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// State data
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot stride
+ /// Maskoffset in bits
+ /// Component data
+ /// Count
+ /// Snapshot baseline
+ /// Datastream writer
+ /// Compression model
+ /// Entity start bit
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void SerializeDelegate(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, IntPtr componentData, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit);
///
/// Delegate method used to serialize the component data present in the child entity into the outgoing data stream.
/// Works on a single entity at time.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// State data
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot stride
+ /// Maskoffset in bits
+ /// Component data
+ /// Count
+ /// Snapshot baseline
+ /// Datastream writer
+ /// Compression model
+ /// Entity start bit
[Obsolete("The SerializeChildDelegate delegate has been deprecated and will be removed. Please use only use the SerializeDelegate instead", false)]
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void SerializeChildDelegate(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, IntPtr componentData, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit);
@@ -141,35 +141,35 @@ public enum SendMask
/// Delegate method used to serialize the buffer content for the whole chunk.
/// Works in batches.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// State data
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot stride
+ /// Maskoffset in bits
+ /// Change mask bits
+ /// Component data
+ /// Component data length
+ /// Count
+ /// Snapshot baseline
+ /// Datastream writer
+ /// Compression model
+ /// Entity start bit
+ /// Dynamic data pointer
+ /// Dynamic data pointer offset
+ /// Dynamic size per entity
+ /// Dynamic snapshot max offset
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void SerializeBufferDelegate(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, int maskOffsetInBits, int changeMaskBits, IntPtr componentData, IntPtr componentDataLen, int count, IntPtr baselines, ref DataStreamWriter writer, ref StreamCompressionModel compressionModel, IntPtr entityStartBit, IntPtr snapshotDynamicDataPtr, ref int snapshotDynamicDataOffset, IntPtr dynamicSizePerEntity, int dynamicSnapshotMaxOffset);
///
/// Delegate method used to transfer the component data to/from the snapshot buffer.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// State data
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot stride
+ /// Component data
+ /// Component stride
+ /// Count
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void CopyToFromSnapshotDelegate(IntPtr stateData, IntPtr snapshotData, int snapshotOffset, int snapshotStride, IntPtr componentData, int componentStride, int count);
///
@@ -177,38 +177,38 @@ public enum SendMask
/// buffer. Because the history buffer perform a memory copy of the whole component data, it is necessary to call this method to
/// ensure only the replicated portion of the component is actually restored.
///
- ///
- ///
+ /// Component data
+ /// Backup data
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void RestoreFromBackupDelegate(IntPtr componentData, IntPtr backupData);
///
/// Calculate the prediction delta for components and buffer. Used for delta-compression.
///
- ///
- ///
- ///
- ///
+ /// Snapshot data
+ /// Snapshot baseline
+ /// Snapshot baseline
+ /// Delta predictor
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void PredictDeltaDelegate(IntPtr snapshotData, IntPtr baseline1Data, IntPtr baseline2Data, ref GhostDeltaPredictor predictor);
///
/// Deserialize the component and buffer data from the received snapshot and store it inside the .
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Snapshot data
+ /// Snapshot baseline
+ /// Datastream reader
+ /// Compression model
+ /// Change mask data
+ /// Start offset
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void DeserializeDelegate(IntPtr snapshotData, IntPtr baselineData, ref DataStreamReader reader, ref StreamCompressionModel compressionModel, IntPtr changeMaskData, int startOffset);
///
/// Delegate used by the , collect and report the prediction error
/// for all the replicated fields.
///
- ///
- ///
- ///
- ///
+ /// Component data
+ /// Backup data
+ /// Errors list
+ /// Error count
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ReportPredictionErrorsDelegate(IntPtr componentData, IntPtr backupData, IntPtr errorsList, int errorsCount);
@@ -366,8 +366,8 @@ public struct State : IBufferElementData
/// For buffers in particular, the contains only offset and length information (the buffer data resides inside the
/// ), and the reported size is always equal to the .
///
- ///
- ///
+ /// Serializer state
+ /// Size in bytes (aligned to 16 bytes boundary)
public static int SizeInSnapshot(in State serializer)
{
return serializer.ComponentType.IsBuffer
@@ -378,10 +378,10 @@ public static int SizeInSnapshot(in State serializer)
///
/// Helper method to get a reference to a struct data from its address in memory.
///
- ///
- ///
- ///
- ///
+ /// Data
+ /// Offset
+ /// Component type
+ /// Reference to component type in data
public static ref T TypeCast(IntPtr value, int offset = 0) where T: struct
{
return ref UnsafeUtility.AsRef((byte*)value+offset);
@@ -389,10 +389,10 @@ public static ref T TypeCast(IntPtr value, int offset = 0) where T: struct
///
/// Helper method to get a reference to a struct data from its address in memory.
///
- ///
- ///
- ///
- ///
+ /// Data
+ /// Offset
+ /// Component type
+ /// Reference to component type in data
public static ref readonly T TypeCastReadonly(IntPtr value, int offset = 0) where T: struct
{
return ref UnsafeUtility.AsRef((byte*)value+offset);
@@ -400,9 +400,9 @@ public static ref readonly T TypeCastReadonly(IntPtr value, int offset = 0) w
///
/// Return a pointer to the memory address for the given instance.
///
- ///
- ///
- ///
+ /// Data
+ /// Component type
+ /// Reference to component type in data
public static IntPtr IntPtrCast(ref T value) where T: struct
{
return (IntPtr)UnsafeUtility.AddressOf(ref value);
@@ -427,10 +427,10 @@ static public int GetDeltaCompressedSizeInBits(uint value, uint baseline, in Str
/// For internal use only, copy the bitmask to a destination buffer,
/// to the given and for the required number of bits.
///
- ///
- ///
- ///
- ///
+ /// Destination buffer
+ /// Bitmask
+ /// Offset to copy to
+ /// Number of bits to copy
public static void CopyToChangeMask(IntPtr bitData, uint src, int offset, int numBits)
{
Assertions.Assert.IsTrue(offset >= 0);
@@ -459,10 +459,10 @@ public static void CopyToChangeMask(IntPtr bitData, uint src, int offset, int nu
/// For internal use only, reset the bitmask bits from the given
/// and for the required number of bits.
///
- ///
- ///
- ///
- static public void ResetChangeMask(IntPtr bitData, int offset, int numBits)
+ /// Bitmask
+ /// Offset
+ /// Number of bits
+ public static void ResetChangeMask(IntPtr bitData, int offset, int numBits)
{
Assertions.Assert.IsTrue(offset >= 0);
Assertions.Assert.IsTrue(numBits >= 0);
@@ -497,13 +497,13 @@ static public void ResetChangeMask(IntPtr bitData, int offset, int numBits)
///
/// Reset the changemask and the snapshot data to the default value (all 0)
///
- ///
- ///
- ///
- ///
- ///
- ///
- static public void ClearSnapshotDataAndMask(IntPtr snapshot, int snapshotOffset, int snapshotSize, IntPtr changeMask, int maskOffset,
+ /// Snapshot data
+ /// Snapshot offset
+ /// Snapshot size
+ /// Change mask
+ /// Mask offset
+ /// Change mask bits
+ public static void ClearSnapshotDataAndMask(IntPtr snapshot, int snapshotOffset, int snapshotSize, IntPtr changeMask, int maskOffset,
int changeMaskBits)
{
ResetChangeMask(changeMask, maskOffset, changeMaskBits);
@@ -513,10 +513,10 @@ static public void ClearSnapshotDataAndMask(IntPtr snapshot, int snapshotOffset,
}
///
- /// For internal use only, reset one bit in the bitmask array at the given
+ /// For internal use only, reset one bit in the bitmask array at the given .
///
- ///
- ///
+ /// Bitmask array
+ /// Offset to reset bit
static internal void ResetChangeMaskBit(IntPtr bitData, int offset)
{
Assertions.Assert.IsTrue(offset >= 0);
@@ -530,10 +530,10 @@ static internal void ResetChangeMaskBit(IntPtr bitData, int offset)
/// Extract from the source buffer an unsigned integer, representing a portion of a bitmask
/// starting from the given offset and number of bits (up to 32 bits max).
///
- ///
- ///
- ///
- ///
+ /// Bitmask array
+ /// Offset to extract integer
+ /// Number of bits to extract
+ /// Extracted unsigned integer
public static uint CopyFromChangeMask(IntPtr bitData, int offset, int numBits)
{
Assertions.Assert.IsTrue(offset >= 0);
@@ -554,9 +554,9 @@ public static uint CopyFromChangeMask(IntPtr bitData, int offset, int numBits)
///
/// Helper method to construct an from a given IntPtr and length.
///
- ///
- ///
- ///
+ /// Float data
+ /// Number of bits to convert
+ /// List of converted floats
public static UnsafeList ConvertToUnsafeList(IntPtr floatData, int len)
{
return new UnsafeList((float*)floatData.ToPointer(), len);
@@ -570,7 +570,7 @@ internal static int SnapshotHeaderSizeInBytes(in GhostCollectionPrefabSerializer
///
/// Compute the number of uint necessary to encode the required number of bits
///
- ///
+ /// Number of bits to convert.
/// The uint mask to encode this number of bits.
public static int ChangeMaskArraySizeInUInts(int numBits)
{
@@ -580,7 +580,7 @@ public static int ChangeMaskArraySizeInUInts(int numBits)
///
/// Compute the number of bytes necessary to encode the required number of bits
///
- ///
+ /// Number of bits to convert.
/// The min number of bytes to store this number of bits, rounded to the nearest 4 bytes (for data-alignment).
public static int ChangeMaskArraySizeInBytes(int numBits)
{
@@ -590,8 +590,8 @@ public static int ChangeMaskArraySizeInBytes(int numBits)
///
/// Align the give size to 16 byte boundary.
///
- ///
- ///
+ /// Size to align
+ /// New size aligned to 16 byte
public static int SnapshotSizeAligned(int size)
{
//TODO: we can use the CollectionHelper.Align for that
@@ -601,8 +601,8 @@ public static int SnapshotSizeAligned(int size)
///
/// Align the give size to 16 byte boundary
///
- ///
- ///
+ /// Size to align
+ /// New size aligned to 16 byte
public static uint SnapshotSizeAligned(uint size)
{
return (size + 15u) & (~15u);
@@ -631,9 +631,9 @@ internal static class DynamicBufferExtensions
///
/// Get a readonly reference to the element at the given index.
///
- ///
- ///
- ///
+ /// Element buffer
+ /// Index of element
+ /// Element type
/// A readonly reference to the element
public static ref readonly T ElementAtRO(this DynamicBuffer buffer, int index) where T: unmanaged, IBufferElementData
{
diff --git a/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs b/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs
index 9cb1535..653f512 100644
--- a/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs
+++ b/Runtime/Snapshot/GhostComponentSerializerCollectionSystemGroup.cs
@@ -70,7 +70,7 @@ public enum DefaultType : byte
public GhostPrefabType PrefabType;
///Override which client type it will be sent to, if we're able to determine.
public GhostSendType SendTypeOptimization;
- ///
+ /// See: .
public DefaultType DefaultRule;
// TODO - Create a flag byte enum for all of these.
///
@@ -79,7 +79,6 @@ public enum DefaultType : byte
///
/// Types like `Translation` don't have a default serializer as the type itself doesn't define any GhostFields, but they do have serialized variants.
public byte IsDefaultSerializer;
- ///
/// True if this is an editor test variant. Forces this variant to be considered a "default" which makes writing tests easier.
///
public byte IsTestVariant;
@@ -92,7 +91,7 @@ public enum DefaultType : byte
/// Does this component explicitly opt-out of overrides (regardless of variant count)?
public byte HasDontSupportPrefabOverridesAttribute;
- /// and .
+ /// See: and .
internal byte IsInput => (byte) (IsInputComponent | IsInputBuffer);
/// The type name, unless it has a Variant (in which case it'll use the Variant Display name... assuming that is not null).
public FixedString64Bytes DisplayName;
@@ -107,8 +106,8 @@ public enum DefaultType : byte
///
/// Check if two VariantType are identical.
///
- ///
- ///
+ /// Variant type
+ /// Whether is identical.
public int CompareTo(ComponentTypeSerializationStrategy other)
{
if (IsSerialized != other.IsSerialized)
@@ -123,7 +122,7 @@ public int CompareTo(ComponentTypeSerializationStrategy other)
///
/// Convert the instance to its string representation.
///
- ///
+ /// String representation of instance
public override string ToString() => ToFixedString().ToString();
/// Logs a burst compatible debug string (if in burst), otherwise logs even more info.
@@ -211,7 +210,7 @@ protected override void OnDestroy()
}
}
- /// . Blittable. For internal use only.
+ /// Blittable. . For internal use only.
[StructLayout(LayoutKind.Sequential)]
[BurstCompile]
public struct GhostComponentSerializerCollectionData : IComponentData
@@ -268,7 +267,7 @@ ulong HashGhostComponentSerializer(in GhostComponentSerializer.State comp)
/// Used by code-generated systems to register SerializationStrategies.
/// Internal use only.
///
- ///
+ /// Strategy to register.
public void AddSerializationStrategy(ref ComponentTypeSerializationStrategy serializationStrategy)
{
ThrowIfNotInRegistrationPhase("register a SerializationStrategy");
@@ -312,7 +311,7 @@ private void AddSerializationStrategyInternal(ref ComponentTypeSerializationStra
/// Used by code-generated systems and meant for internal use only.
/// Adds the generated ghost serializer to collection.
///
- ///
+ /// Serializer state.
public void AddSerializer(GhostComponentSerializer.State state)
{
ThrowIfNotInRegistrationPhase("register a Serializer");
@@ -365,8 +364,8 @@ public void ThrowIfCollectionNotFinalized(in FixedString512Bytes context)
///
/// Lookup a component type to use as a buffer for a given IInputComponentData.
///
- ///
- ///
+ /// Component type
+ /// Buffer type
/// True if the component has an assosiated buffer to use, false if it does not.
[Obsolete("TryGetBufferForInputComponent has been deprecated. In order to find the buffer associated with an IInputComponentData please just use" +
"IInputBuffer where T is the IInputComponentData type you are looking for.", false)]
@@ -380,8 +379,8 @@ public bool TryGetBufferForInputComponent(ComponentType inputType, out Component
/// Used by code-generated systems and meant for internal use only.
/// Adds a mapping from an IInputComponentData to the buffer it should use.
///
- ///
- ///
+ /// Input type
+ /// Buffer type
public void AddInputComponent(ComponentType inputType, ComponentType bufferType)
{
InputComponentBufferMap.TryAdd(inputType, bufferType);
@@ -786,8 +785,8 @@ bool VariantIsUserSpecifiedDefaultRule(ComponentType componentType, ulong varian
/// Validation that the SourceGenerators return valid hashes for "default serializers".
/// Hash to check.
- ///
- ///
+ /// String context
+ /// If cannot add variant for .
[System.Diagnostics.Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
public static void ThrowIfNoHash(ulong hash, FixedString512Bytes context)
{
diff --git a/Runtime/Snapshot/GhostCount.cs b/Runtime/Snapshot/GhostCount.cs
index 857457d..fdda60e 100644
--- a/Runtime/Snapshot/GhostCount.cs
+++ b/Runtime/Snapshot/GhostCount.cs
@@ -1,3 +1,4 @@
+using System;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
@@ -11,16 +12,87 @@ namespace Unity.NetCode
public struct GhostCount : IComponentData
{
///
- /// The total number of ghosts on the server the last time a snapshot was received. Use this and GhostCountOnClient to figure out how much of the state the client has received.
+ /// The total number of relevant (to our connection) ghosts that the server wishes to send this client.
+ /// Sent in each snapshot, thus count is updated whenever a snapshot was received.
///
- public int GhostCountOnServer => m_GhostCompletionCount[0];
+ ///
+ ///
+ public int GhostCountOnServer => IsCreated ? m_GhostCompletionCount[0] : 0;
+
+ ///
+ [Obsolete("Prefer either GhostCountInstantiatedOnClient or GhostCountReceivedOnClient, as this variable is ambiguous (and maps to GhostCountReceivedOnClient). RemoveAfter 1.x.", false)]
+ public int GhostCountOnClient => IsCreated ? m_GhostCompletionCount[1] : 0;
+
+ ///
+ /// The total number of relevant (to our connection) ghosts that the client has actually instantiated
+ /// (but skips/ignores ghost instances).
+ /// Count is updated any time a finalized ghost is actually instantiated or destroyed (via the ).
+ ///
Zero if is false.
+ /// Use this with to figure out how much of the state the client has received.
+ ///
+ ///
+ /// Note: If the relevant set suddenly changes - or the server destroys many ghosts within a single frame -
+ /// it's possible to have more ghosts on the client than the client should have.
+ ///
+ ///
+ ///
+ public int GhostCountInstantiatedOnClient => IsCreated ? m_GhostCompletionCount[2] : 0;
///
- /// The total number of ghosts received by this client the last time a snapshot was received. The number of received ghosts can be different from the number of currently spawned ghosts. Use this and GhostCountOnServer to figure out how much of the state the client has received.
+ /// The total number of relevant (to our connection) ghosts that the client has received (NOT instantiated!).
+ /// Count is updated any time a snapshot is received and processed.
+ ///
The number of received ghosts can be different from the number of currently spawned ghosts.
+ ///
Zero if is false.
+ /// Use this with to figure out how much of the state the client has received.
///
- public int GhostCountOnClient => m_GhostCompletionCount[1];
+ ///
+ /// Note: If the relevant set suddenly changes - or the server destroys many ghosts within a single frame -
+ /// it's possible to have more ghosts on the client than the client should have.
+ ///
+ ///
+ ///
+ public int GhostCountReceivedOnClient => IsCreated ? m_GhostCompletionCount[1] : 0;
+
+ ///
+ /// Denotes the percentage of ghosts instantiated on the client ()
+ /// versus the number of ghosts the server has said exist (i.e. ).
+ ///
Only counts relevant ghosts!
+ ///
0% when no ghosts are expected: I.e. No ghosts spawned on server, or no ghosts considered relevant,
+ /// or if this struct is not initialized (i.e. when is false).
+ /// Distinct from !
+ ///
+ ///
+ /// Note: If the relevant set suddenly changes - or the server destroys many ghosts within a single frame -
+ /// it's possible to have more ghosts on the client than the client should have. Therefore, this value can be
+ /// greater than 100%.
+ ///
Also note: Due to above nuances, it's possible to have the correct count of ghosts, but it's the
+ /// incorrect set. In other words: This percentage is a naive approximation of 'the client has replicated
+ /// everything they need'.
+ ///
+ public float InstantiatedPercent => IsCreated && GhostCountOnServer != 0 ? (float) GhostCountInstantiatedOnClient / GhostCountOnServer : -1;
+
+ ///
+ /// Denotes the percentage of ghosts received by the client ()
+ /// versus the number of ghosts the server has said exist (i.e. ).
+ ///
Only counts relevant ghosts!
+ ///
0% when no ghosts are expected: I.e. No ghosts spawned on server, or no ghosts considered relevant,
+ /// or if this struct is not initialized (i.e. when is false).
+ /// Distinct from !
+ ///
+ ///
+ /// Note: If the relevant set suddenly changes - or the server destroys many ghosts within a single frame -
+ /// it's possible to have more ghosts on the client than the client should have. Therefore, this value can be
+ /// greater than 100%.
+ ///
Also note: Due to above nuances, it's possible to have the correct count of ghosts, but it's the
+ /// incorrect set. In other words: This percentage is a naive approximation of 'the client has replicated
+ /// everything they need'.
+ ///
+ public float ReceivedPercent => IsCreated && GhostCountOnServer != 0 ? (float) GhostCountReceivedOnClient / GhostCountOnServer : -1;
+
+ /// Helper denoting if the values are valid.
+ public bool IsCreated => m_GhostCompletionCount.IsCreated;
- private NativeArray m_GhostCompletionCount;
+ internal NativeArray m_GhostCompletionCount;
///
/// Construct and initialize the new ghost count instance.
@@ -32,10 +104,10 @@ internal GhostCount(NativeArray ghostCompletionCount)
}
///
- /// Logs 'GhostCount[c:X,s:X]'.
+ /// For debugging and logging.
///
- /// Logs 'GhostCount[c:X,s:X]'.
- public FixedString128Bytes ToFixedString() => $"GhostCount[c:{GhostCountOnClient},s:{GhostCountOnServer}]";
+ /// Logs GhostCount[received:GhostCountReceivedOnClient %, inst:GhostCountInstantiatedOnClient %, server:GhostCountOnServer].
+ public FixedString128Bytes ToFixedString() => IsCreated ? $"GhostCount[received:{GhostCountReceivedOnClient} {(int)(ReceivedPercent * 100)}%, inst:{GhostCountInstantiatedOnClient} {(int)(InstantiatedPercent * 100)}%, server:{GhostCountOnServer}]" : "GhostCount[default]";
///
public override string ToString() => ToFixedString().ToString();
diff --git a/Runtime/Snapshot/GhostDeltaPredictor.cs b/Runtime/Snapshot/GhostDeltaPredictor.cs
index 379ff5d..3d319d0 100644
--- a/Runtime/Snapshot/GhostDeltaPredictor.cs
+++ b/Runtime/Snapshot/GhostDeltaPredictor.cs
@@ -22,9 +22,9 @@ public struct GhostDeltaPredictor
/// the relative weight that is applied to the baseline values.
///
/// the current server tick
- ///
- ///
- ///
+ /// Network tick baseline
+ /// Network tick baseline
+ /// Network tick baseline
public GhostDeltaPredictor(NetworkTick tick, NetworkTick baseline0_tick, NetworkTick baseline1_tick, NetworkTick baseline2_tick)
{
predictFrac = 16 * baseline0_tick.TicksSince(baseline1_tick) / baseline1_tick.TicksSince(baseline2_tick);
@@ -34,10 +34,10 @@ public GhostDeltaPredictor(NetworkTick tick, NetworkTick baseline0_tick, Network
///
/// Calculate the predicted value for the given integer, using the previous three baselines.
///
- ///
- ///
- ///
- ///
+ /// Tick baseline
+ /// Tick baseline
+ /// Tick baseline
+ /// Predicted value for given integer
public int PredictInt(int baseline0, int baseline1, int baseline2)
{
int delta = baseline1 - baseline2;
@@ -51,10 +51,10 @@ public int PredictInt(int baseline0, int baseline1, int baseline2)
///
/// Calculate the predicted value for the given long, using the previous three baselines.
///
- ///
- ///
- ///
- ///
+ /// Tick baseline
+ /// Tick baseline
+ /// Tick baseline
+ /// Predicted value for given long
public long PredictLong(long baseline0, long baseline1, long baseline2)
{
long delta = baseline1 - baseline2;
diff --git a/Runtime/Snapshot/GhostImportance.cs b/Runtime/Snapshot/GhostImportance.cs
index 8cc76de..4d71031 100644
--- a/Runtime/Snapshot/GhostImportance.cs
+++ b/Runtime/Snapshot/GhostImportance.cs
@@ -38,8 +38,8 @@ public struct PrioChunk : IComparable
///
/// Used for sorting the based on the priority in descending order.
///
- ///
- ///
+ /// Prio chunk
+ /// Descending order.
public int CompareTo(PrioChunk other)
{
// Reverse priority for sorting
@@ -61,7 +61,7 @@ public struct GhostImportance : IComponentData
/// Optional configuration data. Ex. Each tile's configuration. Handle IntPtr.Zero!
/// Per chunk information. Ex. each entity's tile index.
/// Priority computed by after computing tick when last updated and irrelevance.
- ///
+ /// Scale importance value.
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate int ScaleImportanceDelegate(IntPtr connectionData, IntPtr importanceData, IntPtr chunkTile, int basePriority);
@@ -80,7 +80,7 @@ public struct GhostImportance : IComponentData
/// Per connection data. Ex. position in the world that should be prioritized.
/// Optional configuration data. Ex. Each tile's configuration. Handle IntPtr.Zero!
/// to retrieve the per-chunk tile information. Ex. each chunk's tile index.
- ///
+ /// Chunk data.
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void BatchScaleImportanceDelegate(IntPtr connectionData, IntPtr importanceData, IntPtr sharedComponentTypeHandlePtr,
ref UnsafeList chunkData);
diff --git a/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs b/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs
index 2b5aa10..94c8bcb 100644
--- a/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs
+++ b/Runtime/Snapshot/GhostPredictionSmoothingSystem.cs
@@ -31,9 +31,9 @@ internal GhostPredictionSmoothing(NativeParallelHashMap
/// All the smoothing action must have this signature. The smoothing actions must also be burst compatible.
///
- ///
- ///
- ///
+ /// Current data
+ /// Previous data
+ /// User data
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void SmoothingActionDelegate(IntPtr currentData, IntPtr previousData, IntPtr userData);
diff --git a/Runtime/Snapshot/GhostPrefabCreation.cs b/Runtime/Snapshot/GhostPrefabCreation.cs
index be1b10d..307560e 100644
--- a/Runtime/Snapshot/GhostPrefabCreation.cs
+++ b/Runtime/Snapshot/GhostPrefabCreation.cs
@@ -32,10 +32,10 @@ namespace Unity.NetCode
{
///
/// Stores the `Supported Ghost Mode` by a ghost at authoring time.
- ///
- /// - Interpolated:
- /// - Predicted:
- /// - All:
+ ///
+ /// - Interpolated
+ /// - Predicted
+ /// - All
///
///
public enum GhostModeMask
@@ -73,17 +73,15 @@ public enum GhostModeMask
///
public enum GhostMode
{
- ///
///
Interpolated,
- ///
///
Predicted,
///
/// The ghost will be by the Ghost Owner (set via )
/// and by every other client.
///
- OwnerPredicted
+ OwnerPredicted,
}
///
@@ -132,9 +130,14 @@ public struct Config
public Hash128 UUID5GhostType;
///
/// Higher importance means the ghost will be sent more frequently if there is not enough bandwidth to send everything.
+ /// Minimum value: 1.
///
public int Importance;
///
+ /// Denotes the max send frequency of a ghost, similar to .
+ ///
+ public byte MaxSendRate;
+ ///
/// The ghost modes this prefab can be instantiated as. If for example set the Interpolated it is not possible to use this prefab for prediction.
///
public GhostModeMask SupportedGhostModes;
@@ -181,8 +184,8 @@ public struct Component : IEquatable
///
/// Compare two Component. Component are equals if the type and entity index are the same.
///
- ///
- ///
+ /// Component to compare
+ /// Whether the type and entity index are the same
public bool Equals(Component other)
{
return ComponentType == other.ComponentType && ChildIndex == other.ChildIndex;
@@ -190,7 +193,7 @@ public bool Equals(Component other)
///
/// Calculate a unique hash for the component based on type and index.
///
- ///
+ /// A unique hash based on the component type and index.
public override int GetHashCode()
{
return (ComponentType.GetHashCode() * 397) ^ ChildIndex.GetHashCode();
@@ -443,6 +446,7 @@ internal static BlobAssetReference CreateBlobAsset(
// Store importance, supported modes, default mode and name in the meta data blob asset
root.Importance = ghostConfig.Importance;
+ root.MaxSendRate = ghostConfig.MaxSendRate;
root.SupportedModes = GhostPrefabBlobMetaData.GhostMode.Both;
root.DefaultMode = GhostPrefabBlobMetaData.GhostMode.Interpolated;
if (ghostConfig.SupportedGhostModes == GhostModeMask.Interpolated)
diff --git a/Runtime/Snapshot/GhostReceiveSystem.cs b/Runtime/Snapshot/GhostReceiveSystem.cs
index 967e48e..e3371c6 100644
--- a/Runtime/Snapshot/GhostReceiveSystem.cs
+++ b/Runtime/Snapshot/GhostReceiveSystem.cs
@@ -31,7 +31,7 @@ public struct SpawnedGhost : IEquatable
///
/// Produce the hash code for the SpawnedGhost.
///
- ///
+ /// Ghost id
public override int GetHashCode()
{
return ghostId;
@@ -39,17 +39,17 @@ public override int GetHashCode()
///
/// Construct a SpawnedEntity from a
///
- /// The ghost from witch t
+ /// The ghost from which to construct a SpawnedEntity
public SpawnedGhost(in GhostInstance ghostInstance)
{
ghostId = ghostInstance.ghostId;
spawnTick = ghostInstance.spawnTick;
}
///
- /// Construct a SpawnedEntity using the ghost identifier and the spawn tick>
+ /// Construct a SpawnedEntity using the ghost identifier and the spawn tick
///
- ///
- ///
+ /// Ghost id
+ /// Spawn tick
public SpawnedGhost(int ghostId, NetworkTick spawnTick)
{
this.ghostId = ghostId;
@@ -58,8 +58,8 @@ public SpawnedGhost(int ghostId, NetworkTick spawnTick)
///
/// The SpawnedGhost are identical if both id and tick match.
///
- ///
- ///
+ /// Ghost to compare with
+ /// Whether ghost id and spawn tick are identical
public bool Equals(SpawnedGhost ghost)
{
return ghost.ghostId == ghostId && ghost.spawnTick == spawnTick;
@@ -182,7 +182,7 @@ public void OnCreate(ref SystemState state)
#endif
m_GhostEntityMap = new NativeParallelHashMap(2048, Allocator.Persistent);
m_SpawnedGhostEntityMap = new NativeParallelHashMap(2048, Allocator.Persistent);
- m_GhostCompletionCount = new NativeArray(2, Allocator.Persistent);
+ m_GhostCompletionCount = new NativeArray(3, Allocator.Persistent);
var componentTypes = new NativeArray(1, Allocator.Temp);
componentTypes[0] = ComponentType.ReadWrite();
@@ -298,6 +298,17 @@ public void Execute()
SpawnedGhostMap.Remove(keys[i]);
}
}
+
+ // Bug fix: Some ghosts have not spawned yet, but they still need to be removed from this map, to prevent the error:
+ // "Found a ghost in the ghost map which does not have an entity connected to it. This can happen if you delete ghost entities on the client.".
+ var ghostMapKeys = GhostMap.GetKeyArray(Allocator.Temp);
+ for (int i = 0; i < ghostMapKeys.Length; ++i)
+ {
+ if (PrespawnHelper.IsRuntimeSpawnedGhost(ghostMapKeys[i]))
+ {
+ GhostMap.Remove(ghostMapKeys[i]);
+ }
+ }
}
}
@@ -381,15 +392,16 @@ public void Execute()
// Read the ghost stream
// find entities to spawn or destroy
var serverTick = new NetworkTick{SerializedData = dataStream.ReadUInt()};
+ ref var ack = ref SnapshotAckFromEntity.GetRefRW(Connections[0]).ValueRW;
#if NETCODE_DEBUG
FixedString512Bytes debugLog = TimestampAndTick;
m_EnablePacketLogging = EnablePacketLogging.InitAndFetch(Connections[0], EnableLoggingFromEntity, in NetDebugPacket);
+ // TODO - Map CurrentSnapshotSequenceId exactly to the snapshot data itself, rather than fetching it "second hand" here.
if (m_EnablePacketLogging == 1)
- debugLog.Append(FixedString.Format(" ServerTick:{0}\n", serverTick.ToFixedString()));
+ debugLog.Append(FixedString.Format(" ServerTick:{0} [SSId:{1}]\n", serverTick.ToFixedString(), ack.CurrentSnapshotSequenceId));
#endif
- ref var ack = ref SnapshotAckFromEntity.GetRefRW(Connections[0]).ValueRW;
// Load all new prefabs
uint numPrefabs = dataStream.ReadPackedUInt(CompressionModel);
#if NETCODE_DEBUG
@@ -971,7 +983,7 @@ bool DeserializeEntity(NetworkTick serverTick, ref DataStreamReader dataStream,
}
if(!isPrespawn || data.BaselineTick.IsValid)
// If the server specifies a baseline for a ghost we do not have that is an error
- NetDebug.LogError($"Received baseline for a ghost we do not have ghostId={ghostId} baselineTick={data.BaselineTick.ToFixedString()} serverTick={serverTick.ToFixedString()}");
+ NetDebug.LogError($"Received baseline for a ghost we do not have ghostId={ghostId} baselineTick={data.BaselineTick.ToFixedString()} serverTick={serverTick.ToFixedString()} existingGhost={existingGhost}");
#if NETCODE_DEBUG
if (m_EnablePacketLogging == 1)
{
@@ -1367,7 +1379,7 @@ public void OnUpdate(ref SystemState state)
var commandBuffer = SystemAPI.GetSingleton().CreateCommandBuffer(state.WorldUnmanaged);
if (m_ConnectionsQuery.IsEmptyIgnoreFilter)
{
- m_GhostCompletionCount[0] = m_GhostCompletionCount[1] = 0;
+ m_GhostCompletionCount[0] = m_GhostCompletionCount[1] = m_GhostCompletionCount[2] = 0;
state.CompleteDependency(); // Make sure we can access the spawned ghost map
// If there were no ghosts spawned at runtime we don't need to cleanup
if (m_GhostCleanupQuery.IsEmptyIgnoreFilter &&
diff --git a/Runtime/Snapshot/GhostRelevancy.cs b/Runtime/Snapshot/GhostRelevancy.cs
index 3715b71..485fd6a 100644
--- a/Runtime/Snapshot/GhostRelevancy.cs
+++ b/Runtime/Snapshot/GhostRelevancy.cs
@@ -38,7 +38,7 @@ public struct RelevantGhostForConnection : IEquatable
/// The connection id
- ///
+ /// Ghost id
public RelevantGhostForConnection(int connection, int ghost)
{
Connection = connection;
@@ -47,8 +47,8 @@ public RelevantGhostForConnection(int connection, int ghost)
///
/// return whenever the RelevantGhostForConnection is equals the current instance.
///
- ///
- ///
+ /// Instance to compare with
+ /// Whether connection and ghost id are identical
public bool Equals(RelevantGhostForConnection other)
{
return Connection == other.Connection && Ghost == other.Ghost;
@@ -56,8 +56,8 @@ public bool Equals(RelevantGhostForConnection other)
///
/// Comparison operator, used for sorting.
///
- ///
- ///
+ /// Instance to compare with
+ /// Sorting order using ghost id and connection
public int CompareTo(RelevantGhostForConnection other)
{
if (Connection == other.Connection)
@@ -68,7 +68,7 @@ public int CompareTo(RelevantGhostForConnection other)
/// A hash code suitable to insert the RelevantGhostForConnection into an hashmap or
/// other key-value pair containers. Is guarantee to be unique for the connection, ghost pairs.
///
- ///
+ /// Hash code basd on connection and ghost id
public override int GetHashCode()
{
return (Connection << 24) | Ghost;
diff --git a/Runtime/Snapshot/GhostSendSystem.cs b/Runtime/Snapshot/GhostSendSystem.cs
index 66a87f4..fd5721f 100644
--- a/Runtime/Snapshot/GhostSendSystem.cs
+++ b/Runtime/Snapshot/GhostSendSystem.cs
@@ -117,8 +117,8 @@ public uint FirstSendImportanceMultiplier
/// If not 0, denotes the desired size of an individual snapshot (unless the per-connection component is present).
/// If zero, is used (minus headers).
///
- [Tooltip("- If zero (the default), NetworkParameterConstants.MTU is used (minus headers).\n\n - Otherwise, denotes the desired size of an individual snapshot (unless the per-connection NetworkStreamSnapshotTargetSize component is present).")]
- [Range(0, NetworkParameterConstants.MTU)]
+ [Tooltip("- If zero (the default), NetworkParameterConstants.MTU is used (minus headers).\n\n - Otherwise, denotes the desired size of an individual snapshot (unless the per-connection NetworkStreamSnapshotTargetSize component is present).")]
+ [Min(0)]
public int DefaultSnapshotPacketSize;
///
@@ -126,8 +126,9 @@ public uint FirstSendImportanceMultiplier
/// than this value will not be added to the snapshot, even if there is enough space in the packet.
///
///
+ /// As of 1.4, prefer , which you can author via the `GhostAuthoringComponent`.
/// Counted on a per-connection, per-chunk basis, where importance increases by the Importance value every tick, until sent (NOT confirmed delivered).
- /// E.g. Value=60, SimulationTickRate=60, GhostAuthoringComponent.Importance=1 implies a ghost will be replicated roughly once per second.
+ /// E.g. MinSendImportance=60, SimulationTickRate=60, GhostAuthoringComponent.Importance=1 implies a ghost will be replicated roughly once per second.
///
[Tooltip("The minimum importance considered for inclusion in a snapshot. The Defaults to 0 (disabled).\n\nAny ghost chunk with an importance value lower than this value will not be added to the snapshot, even if there is enough space in the packet. Use to reduce send-rate for low-importance ghosts.\n\nDefaults to 0 (OFF).")]
[Min(0)]
@@ -143,31 +144,62 @@ public uint FirstSendImportanceMultiplier
public int MinDistanceScaledSendImportance;
///
- /// The maximum number of chunks the GhostSendSystem will try to send to a single connection in a single NetworkTickRate snapshot send interval.
- /// A chunk will count as sent even if it does not contain any ghosts which needed to be sent (because
- /// of relevancy or static optimization).
- /// If there are more chunks than this, the least important chunks will not be sent even if there is space
- /// in the packet. This can be used to reduce / control CPU time on the server.
+ /// Denotes the maximum number of chunks the will iterate over in a single
+ /// tick, for a given connection, within a single
+ /// snapshot send interval. It's an optimization in use-cases where you have many thousands of static ghosts
+ /// (and thus hundreds of static chunks which are iterated over unneccessarily to find ones containing possible changes).
///
///
- /// Note: MaxSendChunks limits the number of chunks we process, and this filtering is applied BEFORE we check if ghosts are irrelevant.
- /// Therefore, if MaxSendChunks is 4 (for example), and the 4 highest importance chunks ONLY contain irrelevant ghosts,
- /// we will NOT send any ghosts in this snapshot.
- /// If this is undesirable, you can correct this by tweaking the multiplier for irrelevant chunks (see )
- /// so that they are rarely the most important chunks.
+ /// A positive value will clamp the maximum number of chunks we iterate over (but cannot be less than
+ /// , thus clamped automatically to it).
+ /// Use 0 (the default) to denote that you want to use the value as the
+ /// value (but note that this can lead to snapshot packets being less full than expected).
+ /// Use -1 to denote that you want to iterate until the packet is filled (or send rules like are encountered).
+ ///
+ /// 1st Warning: If netcode cannot fill the packet within chunks (for
+ /// any reason), any ghost chunks after this index will not be processed (even if there is still space in
+ /// the packet). Therefore, if you're encountering less-than-full packets in cases where you expect the packet
+ /// to be full, increase this!
+ ///
+ /// 2nd Warning: limits the number of chunks we process, and this filtering
+ /// is applied BEFORE we check if ghosts are irrelevant. Therefore, if is 4 (for example),
+ /// and the 4 highest importance chunks ONLY contain irrelevant ghosts, we will NOT send ANY ghosts in this snapshot.
+ /// Therefore, we recommend setting to a value at least 2x higher than .
///
- [Tooltip("The maximum number of chunks the GhostSendSystem will try to send to a single connection in a single NetworkTickRate snapshot send interval. A chunk will count as sent even if it does not contain any ghosts which needed to be sent (because of relevancy or static optimization).\n\nDefaults to 0 (OFF).\n\nIf there are more chunks than this, the least important chunks will not be sent even if there is space in the packet. This can be used to reduce / control CPU time on the server.")]
+ [Tooltip("Denotes the maximum number of chunks the GhostSendSystem will iterate over in a single tick, for a given connection, within a single NetworkTickRate snapshot send interval.\n\nIt's an optimization in use-cases where you have many thousands of static ghosts (and thus hundreds of static chunks which are iterated over unneccessarily to find ones containing possible changes).\n\nDefaults to 0 (i.e. use MinSendImportance)\nRecommendation: ~10\n\n - A positive value will clamp the maximum number of chunks we iterate over (but cannot be less than MaxSendChunks, thus clamped automatically to it).\n - Use 0 to denote that MaxIterateChunks should use MaxSendChunks.\n\n - Use -1 to denote that you want to iterate until the packet is filled - or send rules (like MaxSendChunks) are encountered.")]
+ [Min(0)]
+ public int MaxIterateChunks;
+
+ ///
+ /// The maximum number of chunks the will add to the snapshot for any given connection,
+ /// within a single snapshot send interval. Only incremented
+ /// when at least one ghost is added to the snapshot for a chunk.
+ ///
+ /// Warning: may lead to unnecessarily empty snapshot packets, in cases where
+ /// adding this many chunks to the snapshot does not completely fill it. See for resolution.
+ ///
+ [Tooltip("The maximum number of chunks the GhostSendSystem will add to the snapshot for any given connection, within a single NetworkTickRate snapshot send interval. Only incremented when at least one ghost is added to the snapshot for a chunk. Warning: MaxSendChunks may lead to unnecessarily empty snapshot packets, in cases where adding this many chunks to the snapshot does not completely fill it. See MaxIterateChunks for resolution.\n\nDefaults to 0 (OFF).")]
[Min(0)]
public int MaxSendChunks;
///
- /// The maximum number of entities the system will try to send to a single connection in a single NetworkTickRate snapshot send interval.
- /// An entity will count even if it is not actually sent (because of relevancy or static optimization).
- /// If there are more chunks than this, the least important chunks will not be sent even if there is space
- /// in the packet. This can be used to reduce / control CPU time on the server.
+ /// The maximum number of entities the will add to the snapshot for any given connection,
+ /// within a single snapshot send interval.
+ /// Ignores irrelevant ghosts and cancelled sends (e.g. zero change static optimized chunks).
+ /// This can be used to reduce / control CPU time on the server.
+ /// Warning: may lead to unnecessarily empty snapshot packets, in cases where
+ /// adding this many entities to the snapshot does not completely fill it.
+ /// Prefer and .
///
- [Tooltip("The maximum number of entities the system will try to send to a single connection in a single NetworkTickRate snapshot send interval. An entity will count even if it is not actually sent (because of relevancy or static optimization).\n\nDefaults to 0 (OFF).\n\nIf there are more chunks than this, the least important chunks will not be sent even if there is space in the packet. This can be used to reduce / control CPU time on the server.")]
+ ///
+ /// An implementation detail to be aware of here is that we can currently only check this value
+ /// after a chunk has been written (partially or in full) to the snapshot. Therefore, in practice, a value of 1
+ /// is equivalent to MaxSendChunks = 1;.
+ ///
+ [Tooltip("Obsolete: No longer functional!\n\nThe maximum number of entities the GhostSendSystem will add to the snapshot for any given connection, within a single NetworkTickRate snapshot send interval. Ignores irrelevant ghosts and cancelled sends (e.g. zero change static optimized chunks). This can be used to reduce / control CPU time on the server.\n\nWarning: MaxSendChunks may lead to unnecessarily empty snapshot packets, in cases where adding this many entities to the snapshot does not completely fill it. Prefer MaxSendChunks and MaxIterateChunks.\n\nDefaults to 0 (OFF).")]
[Min(0)]
+ [ReadOnly]
+ [Obsolete("No longer functional! Prefer MaxSendChunks and MaxIterateChunks to tweak GhostSendSystem CPU characteristics. (RemovedAfter 1.x)", false)]
public int MaxSendEntities;
///
@@ -310,7 +342,7 @@ internal void Initialize()
MinSendImportance = 0;
MinDistanceScaledSendImportance = 0;
MaxSendChunks = 0;
- MaxSendEntities = 0;
+ MaxIterateChunks = 0;
ForceSingleBaseline = false;
ForcePreSerialize = false;
KeepSnapshotHistoryOnStructuralChange = true;
@@ -399,8 +431,9 @@ public partial struct GhostSendSystem : ISystem
NativeParallelHashMap m_GhostMap;
NativeQueue m_FreeSpawnedGhostQueue;
- Profiling.ProfilerMarker m_PrioritizeChunksMarker;
- Profiling.ProfilerMarker m_GhostGroupMarker;
+ internal static readonly Profiling.ProfilerMarker k_PrioritizeChunksMarker = new Profiling.ProfilerMarker("PrioritizeChunks");
+ internal static readonly Profiling.ProfilerMarker k_GhostGroupMarker = new Profiling.ProfilerMarker("GhostGroup");
+ internal static readonly Profiling.ProfilerMarker k_CanUseStaticOptimization = new Profiling.ProfilerMarker("CanUseStaticOptimization");
static readonly Profiling.ProfilerMarker k_Scheduling = new Profiling.ProfilerMarker("GhostSendSystem_Scheduling");
GhostPreSerializer m_GhostPreSerializer;
@@ -433,7 +466,6 @@ public partial struct GhostSendSystem : ISystem
BufferLookup m_GhostComponentIndexFromEntity;
BufferLookup m_PrespawnAckFromEntity;
BufferLookup m_PrespawnSceneLoadedFromEntity;
- ComponentLookup m_CustomSerializerFromEntity;
int m_CurrentCleanupConnectionState;
uint m_SentSnapshots;
@@ -504,9 +536,6 @@ 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 Profiling.ProfilerMarker("PrioritizeChunks");
- m_GhostGroupMarker = new Profiling.ProfilerMarker("GhostGroup");
-
#if NETCODE_DEBUG
m_PacketLogEnableQuery = state.GetEntityQuery(ComponentType.ReadOnly());
#endif
@@ -554,7 +583,6 @@ public void OnCreate(ref SystemState state)
m_GhostCollectionFromEntity = state.GetBufferLookup(true);
m_GhostComponentCollectionFromEntity = state.GetBufferLookup(true);
m_GhostComponentIndexFromEntity = state.GetBufferLookup(true);
- m_CustomSerializerFromEntity = state.GetComponentLookup(true);
m_PrespawnAckFromEntity = state.GetBufferLookup(true);
m_PrespawnSceneLoadedFromEntity = state.GetBufferLookup(true);
}
@@ -789,7 +817,6 @@ struct SerializeJob : IJobParallelForDefer
[ReadOnly] public ComponentLookup prefabNamesFromEntity;
[NativeDisableContainerSafetyRestriction] public ComponentLookup enableLoggingFromEntity;
public FixedString32Bytes timestamp;
- public byte enablePerComponentProfiling;
byte enablePacketLogging;
#endif
@@ -802,21 +829,7 @@ struct SerializeJob : IJobParallelForDefer
ConnectionStateData.GhostStateList ghostStateData;
int connectionIdx;
- public Profiling.ProfilerMarker prioritizeChunksMarker;
- public Profiling.ProfilerMarker ghostGroupMarker;
-
- public uint FirstSendImportanceMultiplier;
- public int MinSendImportance;
- public int MinDistanceScaledSendImportance;
- public int MaxSendChunks;
- public int MaxSendEntities;
- public int IrrelevantImportanceDownScale;
- public int useCustomSerializer;
- public byte forceSingleBaseline;
- public byte keepSnapshotHistoryOnStructuralChange;
- public byte snaphostHasCompressedGhostSize;
- public int defaultSnapshotPacketSize;
- public int initialTempWriterCapacity;
+ public GhostSendSystemData systemData;
[ReadOnly] public NativeParallelHashMap SnapshotPreSerializeData;
#if UNITY_EDITOR
@@ -858,9 +871,9 @@ public unsafe void Execute(int idx)
{
targetSnapshotSize = snapshotTargetSizeFromEntity[connectionEntity].Value;
}
- else if (defaultSnapshotPacketSize > 0)
+ else if (systemData.DefaultSnapshotPacketSize > 0)
{
- targetSnapshotSize = math.min(defaultSnapshotPacketSize, targetSnapshotSize);
+ targetSnapshotSize = systemData.DefaultSnapshotPacketSize;
}
if (prespawnSceneLoadedEntity != Entity.Null)
@@ -888,6 +901,7 @@ public unsafe void Execute(int idx)
if ((result = driver.EndSend(dataStream)) >= (int) Networking.Transport.Error.StatusCode.Success)
{
snapshotAck.CurrentSnapshotSequenceId++;
+ snapshotAck.SnapshotPacketLoss.NumPacketsReceived++;
}
else
{
@@ -926,7 +940,7 @@ public unsafe void Execute(int idx)
serializeResult = SerializeEnitiesResult.Abort;
}
}
- Debug.Assert( targetSnapshotSize > 0 );
+ UnityEngine.Debug.Assert( targetSnapshotSize > 0 );
targetSnapshotSize += targetSnapshotSize;
}
}
@@ -958,7 +972,7 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
{
debugLog.Append(FixedString.Format(" Protocol:{0} LocalTime:{1} ReturnTime:{2} CommandAge:{3}",
(byte) NetworkStreamProtocol.Snapshot, localTime, returnTime, snapshotAckCopy.ServerCommandAge));
- debugLog.Append(FixedString.Format(" Tick: {0}, SSId: {1}\n", currentTick.ToFixedString(), snapshotAckCopy.CurrentSnapshotSequenceId));
+ debugLog.Append(FixedString.Format(" ServerTick:{0} [SSId:{1}]\n", currentTick.ToFixedString(), snapshotAckCopy.CurrentSnapshotSequenceId));
}
#endif
@@ -1009,21 +1023,9 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
}
}
-
- NativeList serialChunks;
- int totalCount, maxCount;
- if (BatchScaleImportance.Ptr.IsCreated)
- {
- prioritizeChunksMarker.Begin();
- serialChunks = GatherGhostChunksBatch(out maxCount, out totalCount);
- prioritizeChunksMarker.End();
- }
- else
- {
- prioritizeChunksMarker.Begin();
- serialChunks = GatherGhostChunks(out maxCount, out totalCount);
- prioritizeChunksMarker.End();
- }
+ k_PrioritizeChunksMarker.Begin();
+ var serialChunks = GatherGhostChunksBatch(out var maxCount, out var totalCount);
+ k_PrioritizeChunksMarker.End();
switch (relevancyMode)
{
@@ -1068,7 +1070,6 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
startPos = dataStream.LengthInBits;
#endif
- uint updateLen = 0;
bool didFillPacket = false;
var serializerData = new GhostChunkSerializer
{
@@ -1076,7 +1077,6 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
GhostTypeCollection = GhostTypeCollection,
GhostComponentIndex = GhostComponentIndex,
PrespawnIndexType = prespawnGhostIdType,
- ghostGroupMarker = ghostGroupMarker,
childEntityLookup = childEntityLookup,
linkedEntityGroupType = linkedEntityGroupType,
prespawnBaselineTypeHandle = prespawnBaselineTypeHandle,
@@ -1106,15 +1106,11 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
#if NETCODE_DEBUG
netDebugPacket = netDebugPacket,
enablePacketLogging = enablePacketLogging,
- enablePerComponentProfiling = enablePerComponentProfiling,
#endif
+ systemData = systemData,
SnapshotPreSerializeData = SnapshotPreSerializeData,
- forceSingleBaseline = forceSingleBaseline,
- keepSnapshotHistoryOnStructuralChange = keepSnapshotHistoryOnStructuralChange,
- snaphostHasCompressedGhostSize = snaphostHasCompressedGhostSize,
- useCustomSerializer = (byte)useCustomSerializer,
};
- //usa a better initial size for the temp stream. There is one big of a problem with the current
+ //We now use a better initial size for the temp stream. There is one big of a problem with the current
//serialization logic: multiple full serialization loops in case the chunk does not fit into the current
//temp stream. That can happen if either:
//There are big ghosts (large components or buffers)
@@ -1126,18 +1122,28 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
//gaining already a 2/3 perf out of the box in many cases. I choose a 8 kb buffer, that is a little large, but
//give overall a very good boost in many scenario.
//The parameter is tunable though via GhostSendSystemData, so you can tailor that to the game as necessary.
- var streamCapacity = useCustomSerializer == 0
- ? math.max(initialTempWriterCapacity, dataStream.Capacity)
+ var streamCapacity = systemData.UseCustomSerializer == 0
+ ? math.max(systemData.TempStreamInitialSize, dataStream.Capacity)
: dataStream.Capacity;
serializerData.AllocateTempData(maxCount, streamCapacity);
- var numChunks = serialChunks.Length;
- if (MaxSendChunks > 0 && numChunks > MaxSendChunks)
- numChunks = MaxSendChunks;
+ // MaxIterateChunks is how many we process (i.e. query i.e. how many we ATTEMPT to send),
+ // MaxSendChunks is how many we ALLOW to send.
+ var maxChunksToIterate = serialChunks.Length;
+ if (systemData.MaxIterateChunks == 0 && systemData.MaxSendChunks > 0)
+ systemData.MaxIterateChunks = systemData.MaxSendChunks;
+ if(systemData.MaxIterateChunks > 0)
+ maxChunksToIterate = math.min(systemData.MaxIterateChunks, serialChunks.Length);
- for (int pc = 0; pc < numChunks; ++pc)
+#if NETCODE_DEBUG
+ if (enablePacketLogging == 1)
+ netDebugPacket.Log($"GatherGhostChunks(batched:{BatchScaleImportance.Ptr.IsCreated}) gathered and sorted {serialChunks.Length} serialChunks (MaxSendChunks:{systemData.MaxSendChunks},MaxIterateChunks:{systemData.MaxIterateChunks},iterate:{maxChunksToIterate}) of {ghostChunks.Length} ghostChunks!");
+#endif
+
+ uint totalSentChunks = 0;
+ uint totalSentEntities = 0;
+ for (int pc = 0; pc < maxChunksToIterate; ++pc)
{
- var chunk = serialChunks[pc].chunk;
var ghostType = serialChunks[pc].ghostType;
#if NETCODE_DEBUG
serializerData.ghostTypeName = default;
@@ -1161,14 +1167,12 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
continue;
}
-#if UNITY_EDITOR || NETCODE_DEBUG
- var prevUpdateLen = updateLen;
-#endif
var serializeResult = default(SerializeEnitiesResult);
+ uint thisChunkSentEntities;
try
{
serializeResult = serializerData.SerializeChunk(serialChunks[pc], ref dataStream,
- ref updateLen, ref didFillPacket);
+ out thisChunkSentEntities, ref totalSentEntities, ref totalSentChunks, ref didFillPacket);
}
finally
{
@@ -1181,10 +1185,10 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
}
#if UNITY_EDITOR || NETCODE_DEBUG
- if (updateLen > prevUpdateLen)
+ if (thisChunkSentEntities > 0)
{
// indexing starts at 4 due to slots 0-3 are reserved.
- netStats[ghostType * 3 + 4] = netStats[ghostType * 3 + 4] + updateLen - prevUpdateLen;
+ netStats[ghostType * 3 + 4] = netStats[ghostType * 3 + 4] + thisChunkSentEntities;
netStats[ghostType * 3 + 5] =
netStats[ghostType * 3 + 5] + (uint)(dataStream.LengthInBits - startPos);
netStats[ghostType * 3 + 6] = netStats[ghostType * 3 + 6] + 1; // chunk count
@@ -1194,11 +1198,12 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
if (serializeResult == SerializeEnitiesResult.Failed)
break;
- if (MaxSendEntities > 0)
+ if (thisChunkSentEntities > 0 && systemData.MaxSendChunks > 0 && totalSentChunks >= systemData.MaxSendChunks)
{
- MaxSendEntities -= chunk.Count;
- if (MaxSendEntities <= 0)
- break;
+#if NETCODE_DEBUG
+ if (enablePacketLogging == 1) netDebugPacket.Log($"Encountered MaxSendChunks:{totalSentChunks}/{systemData.MaxSendChunks}!");
+#endif
+ break;
}
}
@@ -1211,20 +1216,20 @@ unsafe SerializeEnitiesResult sendEntities(ref DataStreamWriter dataStream, Netw
dataStream.Flush();
lenWriter.WriteUInt(despawnLen);
- lenWriter.WriteUInt(updateLen);
+ lenWriter.WriteUInt(totalSentEntities);
#if UNITY_EDITOR
- if (updateLen > 0)
+ if (totalSentEntities > 0)
{
- UpdateLen[ThreadIndex] += updateLen;
+ UpdateLen[ThreadIndex] += totalSentEntities;
UpdateCounts[ThreadIndex] += 1;
}
#endif
#if NETCODE_DEBUG
if (enablePacketLogging == 1)
- netDebugPacket.Log(FixedString.Format("Despawn: {0} Update:{1} {2}B\n\n", despawnLen, updateLen, dataStream.Length));
+ netDebugPacket.Log(FixedString.Format("Despawn: {0} Update:{1} {2}B\n\n", despawnLen, totalSentEntities, dataStream.Length));
#endif
- if (didFillPacket && updateLen == 0)
+ if (didFillPacket && totalSentEntities == 0)
{
RevertDespawnGhostState(ackTick);
return SerializeEnitiesResult.Failed;
@@ -1297,6 +1302,7 @@ uint EncodeGhostId(int ghostId)
var despawnCheckTick = state.LastDespawnSendTick;
for (uint i = 1; i < despawnRepeatTicks; ++i)
{
+ // TODO - Convert this into a masked check of the UnsafeBitArray.
despawnCheckTick.Increment();
isReceived |= snapshotAck.IsReceivedByRemote(despawnCheckTick);
}
@@ -1422,10 +1428,12 @@ uint EncodeGhostId(int ghostId)
// Check if despawn has been acked, if it has update all state and do not try to send a despawn again
if (despawnTick.IsValid)
{
+ // TODO - Merge this with method above?
bool isReceived = snapshotAck.IsReceivedByRemote(despawnTick);
var despawnCheckTick = despawnTick;
for (uint dst = 1; dst < despawnRepeatTicks; ++dst)
{
+ // TODO - Convert this into a masked check of the UnsafeBitArray.
despawnCheckTick.Increment();
isReceived |= snapshotAck.IsReceivedByRemote(despawnCheckTick);
}
@@ -1504,93 +1512,6 @@ int FindGhostTypeIndex(Entity ent)
return ghostType;
}
- /// Collect a list of all chunks which could be serialized and sent. Sort the list so other systems get it in priority order.
- /// Also cleanup any stale ghost state in the map and create new storage buffers for new chunks so all chunks are in a valid state after this has executed
- unsafe NativeList GatherGhostChunks(out int maxCount, out int totalCount)
- {
- var serialChunks = new NativeList(ghostChunks.Length, Allocator.Temp);
- maxCount = 0;
- totalCount = 0;
-
- var connectionChunkInfo = childEntityLookup[connectionEntity];
- var connectionHasConnectionData = TryGetComponentPtrInChunk(connectionChunkInfo, ghostConnectionDataTypeHandle, ghostConnectionDataTypeSize, out var connectionDataPtr);
- var chunkStates = connectionState[connectionIdx].SerializationState;
- var scalePriorities = connectionHasConnectionData && ScaleGhostImportance.Ptr.IsCreated;
-
- for (int chunk = 0; chunk < ghostChunks.Length; ++chunk)
- {
- var ghostChunk = ghostChunks[chunk];
- if (!TryGetChunkStateOrNew(ghostChunk, ref *chunkStates, out var chunkState))
- {
- continue;
- }
-
- chunkState.SetLastValidTick(currentTick);
-
- totalCount += ghostChunk.Count;
- maxCount = math.max(maxCount, ghostChunk.Count);
-
- //Prespawn ghost chunk should be considered only if the subscene wich they belong to as been loaded (acked) by the client.
- if (ghostChunk.Has(ref prespawnGhostIdType))
- {
- var ackedPrespawnSceneMap = connectionState[connectionIdx].AckedPrespawnSceneMap;
- //Retrieve the subscene hash from the shared component index.
- var sharedComponentIndex = ghostChunk.GetSharedComponentIndex(subsceneHashSharedTypeHandle);
- var hash = SubSceneHashSharedIndexMap[sharedComponentIndex];
- //Skip the chunk if the client hasn't acked/requested streaming that subscene
- if (!ackedPrespawnSceneMap.ContainsKey(hash))
- {
-#if NETCODE_DEBUG
- if (enablePacketLogging == 1)
- netDebugPacket.Log(FixedString.Format("Skipping prespawn chunk with TypeID:{0} for scene {1} not acked by the client\n", chunkState.ghostType, NetDebug.PrintHex(hash)));
-#endif
- continue;
- }
- }
-
- if (ghostChunk.Has(ref ghostChildEntityComponentType))
- continue;
-
- var ghostType = chunkState.ghostType;
- var chunkPriority = chunkState.baseImportance *
- currentTick.TicksSince(chunkState.GetLastUpdate());
- if (chunkState.GetAllIrrelevant())
- chunkPriority /= IrrelevantImportanceDownScale;
- if (chunkPriority < MinSendImportance)
- continue;
- if (scalePriorities && ghostChunk.Has(ref ghostImportancePerChunkTypeHandle))
- {
- unsafe
- {
- IntPtr chunkTile = new IntPtr(ghostChunk.GetDynamicSharedComponentDataAddress(ref ghostImportancePerChunkTypeHandle));
- var func = (delegate *unmanaged[Cdecl])ScaleGhostImportance.Ptr.Value;
- chunkPriority = func(connectionDataPtr, ghostImportanceDataIntPtr, chunkTile, chunkPriority);
- }
-
- if (chunkPriority < MinDistanceScaledSendImportance)
- continue;
- }
-
- var pc = new PrioChunk
- {
- chunk = ghostChunk,
- priority = chunkPriority,
- startIndex = chunkState.GetStartIndex(),
- ghostType = ghostType
- };
-
- //Using AddNoResize, while tecnically better because does 0 checks, internally use atomics.
- //That make that slower.
- serialChunks.Add(pc);
-#if NETCODE_DEBUG
- if (enablePacketLogging == 1)
- netDebugPacket.Log(FixedString.Format("Adding chunk ID:{0} TypeID:{1} Priority:{2}\n", chunk, ghostType, chunkPriority));
-#endif
- }
- NativeArray serialChunkArray = serialChunks.AsArray();
- serialChunkArray.Sort();
- return serialChunks;
- }
static unsafe IntPtr GetComponentPtrInChunk(
EntityStorageInfo storageInfo,
@@ -1627,10 +1548,16 @@ unsafe bool TryGetChunkStateOrNew(ArchetypeChunk ghostChunk,
chunkState.sequenceNumber = ghostChunk.SequenceNumber;
ref readonly var prefabSerializer = ref GhostTypeCollection.ElementAtRO(chunkState.ghostType);
int serializerDataSize = prefabSerializer.SnapshotSize;
- chunkState.baseImportance = prefabSerializer.BaseImportance;
+ chunkState.baseImportance = (ushort) math.max(1, prefabSerializer.BaseImportance);
+ chunkState.maxSendRateAsSimTickInterval = prefabSerializer.MaxSendRateAsSimTickInterval;
chunkState.AllocateSnapshotData(serializerDataSize, ghostChunk.Capacity);
var importanceTick = currentTick;
- importanceTick.Subtract(FirstSendImportanceMultiplier);
+ // We include MinSendImportance/MaxSendRate because there is no good reason to gate/defer the FIRST SEND of ALL
+ // ghost chunks behind this threshold. I.e. It's valid to assume every new ghost wants to be replicated NOW.
+ // Therefore, FirstSendImportanceMultiplier is more about HOW MUCH we want to bias the first send of a
+ // low importance ghost type (e.g. a tree) ABOVE the resending of a very high importance existing ghost (like the player).
+ var maxResendIntervalTicks = math.max((uint) (systemData.MinSendImportance / chunkState.baseImportance), chunkState.maxSendRateAsSimTickInterval);
+ importanceTick.Subtract(systemData.FirstSendImportanceMultiplier + maxResendIntervalTicks);
chunkState.SetLastUpdate(importanceTick);
chunkStates.TryAdd(ghostChunk, chunkState);
@@ -1669,8 +1596,10 @@ static bool TryGetComponentPtrInChunk(EntityStorageInfo connectionChunkInfo, Dyn
return connectionHasType;
}
+ ///
/// Collect a list of all chunks which could be serialized and sent. Sort the list so other systems get it in priority order.
- /// Also cleanup any stale ghost state in the map and create new storage buffers for new chunks so all chunks are in a valid state after this has executed
+ /// Also cleanup any stale ghost state in the map and create new storage buffers for new chunks so all chunks are in a valid state after this has executed.
+ ///
unsafe NativeList GatherGhostChunksBatch(out int maxCount, out int totalCount)
{
var serialChunks = new NativeList(ghostChunks.Length, Allocator.Temp);
@@ -1684,14 +1613,17 @@ unsafe NativeList GatherGhostChunksBatch(out int maxCount, out int to
{
var ghostChunk = ghostChunks[chunk];
if (!TryGetChunkStateOrNew(ghostChunk, ref *chunkStates, out var chunkState))
- {
continue;
- }
chunkState.SetLastValidTick(currentTick);
totalCount += ghostChunk.Count;
maxCount = math.max(maxCount, ghostChunk.Count);
+ // Caveat: Entity structural changes completely invalidates both Importance & MaxSendRate.
+ var ticksSinceLastSent = currentTick.TicksSince(chunkState.GetLastUpdate());
+ if (ticksSinceLastSent < math.select(chunkState.maxSendRateAsSimTickInterval, chunkState.maxSendRateAsSimTickInterval * systemData.IrrelevantImportanceDownScale, chunkState.GetAllIrrelevant()))
+ continue;
+
//Prespawn ghost chunk should be considered only if the subscene wich they belong to as been loaded (acked) by the client.
if (ghostChunk.Has(ref prespawnGhostIdType))
{
@@ -1715,35 +1647,51 @@ unsafe NativeList GatherGhostChunksBatch(out int maxCount, out int to
if (ghostChunk.Has(ref ghostChildEntityComponentType))
continue;
- var chunkPriority = chunkState.baseImportance *
- currentTick.TicksSince(chunkState.GetLastUpdate());
+ var chunkPriority = chunkState.baseImportance * ticksSinceLastSent;
if (chunkState.GetAllIrrelevant())
- chunkPriority /= IrrelevantImportanceDownScale;
- if (chunkPriority < MinSendImportance)
+ chunkPriority /= systemData.IrrelevantImportanceDownScale;
+ if (chunkPriority < systemData.MinSendImportance)
continue;
- var pc = new PrioChunk
+ serialChunks.Add(new PrioChunk
{
chunk = ghostChunk,
priority = chunkPriority,
startIndex = chunkState.GetStartIndex(),
- ghostType = chunkState.ghostType
- };
- serialChunks.Add(pc);
+ ghostType = chunkState.ghostType,
+ });
}
- if (connectionHasConnectionData)
+
+ // Importance Scaling:
+ var hasBatched = BatchScaleImportance.Ptr.IsCreated;
+ var hasNonBatched = ScaleGhostImportance.Ptr.IsCreated;
+ if (connectionHasConnectionData && (hasBatched || hasNonBatched))
{
- ref var unsafeList = ref (*serialChunks.GetUnsafeList());
- var func = (delegate *unmanaged[Cdecl], void>)BatchScaleImportance.Ptr.Value;
- func(connectionDataPtr, ghostImportanceDataIntPtr,
- GhostComponentSerializer.IntPtrCast(ref ghostImportancePerChunkTypeHandle),
- ref unsafeList);
- if (MinDistanceScaledSendImportance > 0)
+ if (hasBatched)
+ {
+ ref var unsafeList = ref (*serialChunks.GetUnsafeList());
+ var func = (delegate *unmanaged[Cdecl], void>)BatchScaleImportance.Ptr.Value;
+ func(connectionDataPtr, ghostImportanceDataIntPtr,
+ GhostComponentSerializer.IntPtrCast(ref ghostImportancePerChunkTypeHandle),
+ ref unsafeList);
+ }
+ else
+ {
+ for (int i = 0; i < serialChunks.Length; ++i)
+ {
+ ref var serialChunk = ref serialChunks.ElementAt(i);
+ IntPtr chunkTile = new IntPtr(serialChunk.chunk.GetDynamicSharedComponentDataAddress(ref ghostImportancePerChunkTypeHandle));
+ var func = (delegate *unmanaged[Cdecl])ScaleGhostImportance.Ptr.Value;
+ serialChunk.priority = func(connectionDataPtr, ghostImportanceDataIntPtr, chunkTile, serialChunk.priority);
+ }
+ }
+
+ if (systemData.MinDistanceScaledSendImportance > 0)
{
var chunk = 0;
while(chunk < serialChunks.Length)
{
- if (serialChunks.ElementAt(chunk).priority < MinDistanceScaledSendImportance)
+ if (serialChunks.ElementAt(chunk).priority < systemData.MinDistanceScaledSendImportance)
{
serialChunks.RemoveAtSwapBack(chunk);
}
@@ -1963,7 +1911,6 @@ public void OnUpdate(ref SystemState state)
ref readonly var networkStreamDriver = ref SystemAPI.GetSingletonRW().ValueRO;
// If there are any connections to send data to, serialize the data for them in parallel
UpdateSerializeJobDependencies(ref state);
- var customSerializers = m_CustomSerializerFromEntity[ghostCollectionSingleton];
var serializeJob = new SerializeJob
{
GhostCollectionSingleton = ghostCollectionSingleton,
@@ -2015,28 +1962,13 @@ public void OnUpdate(ref SystemState state)
prespawnSceneLoadedFromEntity = m_PrespawnSceneLoadedFromEntity,
CurrentSystemVersion = state.GlobalSystemVersion,
- prioritizeChunksMarker = m_PrioritizeChunksMarker,
- ghostGroupMarker = m_GhostGroupMarker,
#if NETCODE_DEBUG
prefabNamesFromEntity = m_PrefabDebugNameFromEntity,
enableLoggingFromEntity = m_EnablePacketLoggingFromEntity,
timestamp = packetDumpTimestamp,
- enablePerComponentProfiling = (byte) (systemData.m_EnablePerComponentProfiling ? 1 : 0),
#endif
netDebug = netDebug,
- FirstSendImportanceMultiplier = systemData.FirstSendImportanceMultiplier,
- MinSendImportance = systemData.MinSendImportance,
- MinDistanceScaledSendImportance = systemData.MinDistanceScaledSendImportance,
- MaxSendChunks = systemData.MaxSendChunks,
- MaxSendEntities = systemData.MaxSendEntities,
- IrrelevantImportanceDownScale = systemData.IrrelevantImportanceDownScale,
- useCustomSerializer = systemData.UseCustomSerializer,
- forceSingleBaseline = (byte) (systemData.m_ForceSingleBaseline ? 1 : 0),
- keepSnapshotHistoryOnStructuralChange = (byte) (systemData.m_KeepSnapshotHistoryOnStructuralChange ? 1 : 0),
- snaphostHasCompressedGhostSize = GhostSystemConstants.SnaphostHasCompressedGhostSize ? (byte)1u :(byte)0u,
- defaultSnapshotPacketSize = systemData.DefaultSnapshotPacketSize,
- initialTempWriterCapacity = systemData.TempStreamInitialSize,
-
+ systemData = systemData,
#if UNITY_EDITOR
UpdateLen = m_UpdateLen,
UpdateCounts = m_UpdateCounts,
@@ -2174,20 +2106,17 @@ void UpdateSerializeJobDependencies(ref SystemState state)
m_SnapshotAckFromEntity.Update(ref state);
m_ConnectionFromEntity.Update(ref state);
m_GhostFromEntity.Update(ref state);
- m_SnapshotTargetFromEntity.Update(ref state);
m_EnablePacketLoggingFromEntity.Update(ref state);
m_GhostSystemStateType.Update(ref state);
m_PreSerializedGhostType.Update(ref state);
m_GhostChildEntityComponentType.Update(ref state);
m_PrespawnedGhostIdType.Update(ref state);
- m_GhostGroupType.Update(ref state);
m_EntityType.Update(ref state);
m_LinkedEntityGroupType.Update(ref state);
m_PrespawnGhostBaselineType.Update(ref state);
m_SubsceneGhostComponentType.Update(ref state);
m_GhostComponentCollectionFromEntity.Update(ref state);
m_GhostComponentIndexFromEntity.Update(ref state);
- m_CustomSerializerFromEntity.Update(ref state);
m_PrespawnAckFromEntity.Update(ref state);
m_PrespawnSceneLoadedFromEntity.Update(ref state);
}
diff --git a/Runtime/Snapshot/GhostSpawnSystem.cs b/Runtime/Snapshot/GhostSpawnSystem.cs
index 993386d..12a0316 100644
--- a/Runtime/Snapshot/GhostSpawnSystem.cs
+++ b/Runtime/Snapshot/GhostSpawnSystem.cs
@@ -3,6 +3,7 @@
using Unity.Collections;
using Unity.Entities;
using Unity.Collections.LowLevel.Unsafe;
+using UnityEngine;
namespace Unity.NetCode
{
@@ -53,6 +54,7 @@ struct DelayedSpawnGhost
EntityQuery m_InGameGroup;
EntityQuery m_NetworkIdQuery;
+ EntityQuery m_InstanceCount;
public void OnCreate(ref SystemState state)
{
@@ -60,6 +62,7 @@ public void OnCreate(ref SystemState state)
m_DelayedPredictedGhostSpawnQueue = new NativeQueue(Allocator.Persistent);
m_InGameGroup = state.GetEntityQuery(ComponentType.ReadOnly());
m_NetworkIdQuery = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude());
+ m_InstanceCount = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadWrite(), ComponentType.Exclude());
var ent = state.EntityManager.CreateEntity();
state.EntityManager.SetName(ent, "GhostSpawnQueue");
@@ -93,6 +96,7 @@ public unsafe void OnUpdate(ref SystemState state)
var prefabsEntity = SystemAPI.GetSingletonEntity();
var prefabs = stateEntityManager.GetBuffer(prefabsEntity).ToNativeArray(Allocator.Temp);
+ ref var ghostCount = ref SystemAPI.GetSingletonRW().ValueRW;
var ghostSpawnEntity = SystemAPI.GetSingletonEntity();
var ghostSpawnBufferComponent = stateEntityManager.GetBuffer(ghostSpawnEntity);
var snapshotDataBufferComponent = stateEntityManager.GetBuffer(ghostSpawnEntity);
@@ -221,6 +225,8 @@ public unsafe void OnUpdate(ref SystemState state)
}
}
ghostEntityMap.UpdateClientSpawnedGhosts(spawnedGhosts.AsArray(), netDebug);
+
+ ghostCount.m_GhostCompletionCount[2] = m_InstanceCount.CalculateEntityCountWithoutFiltering();
}
void ConfigurePrespawnGhost(ref EntityManager entityManager, Entity entity, in GhostSpawnBuffer ghost)
@@ -253,6 +259,9 @@ unsafe Entity AddToDelayedSpawnQueue(ref EntityManager entityManager, NativeQueu
bool hasBuffers = ghostTypeCollection[ghost.GhostType].NumBuffers > 0;
var entity = entityManager.CreateEntity();
+#if !DOTS_DISABLE_DEBUG_NAMES
+ entityManager.SetName(entity, $"GHOST-PLACEHOLDER-{ghost.GhostType}");
+#endif
entityManager.AddComponentData(entity, new GhostInstance { ghostId = ghost.GhostID, ghostType = ghost.GhostType, spawnTick = ghost.ServerSpawnTick });
entityManager.AddComponent(entity);
if (PrespawnHelper.IsPrespawnGhostId(ghost.GhostID))
diff --git a/Runtime/Snapshot/GhostSpawnSystemGroup.cs.meta b/Runtime/Snapshot/GhostSpawnSystemGroup.cs.meta
index ac5b513..8c1c60c 100644
--- a/Runtime/Snapshot/GhostSpawnSystemGroup.cs.meta
+++ b/Runtime/Snapshot/GhostSpawnSystemGroup.cs.meta
@@ -1,2 +1,11 @@
fileFormatVersion: 2
-guid: d4a2ecf06c331434a8df4ce275470363
\ No newline at end of file
+guid: d4a2ecf06c331434a8df4ce275470363
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Snapshot/NetcodeBitArrayExtensions.cs b/Runtime/Snapshot/NetcodeBitArrayExtensions.cs
new file mode 100644
index 0000000..c65abd8
--- /dev/null
+++ b/Runtime/Snapshot/NetcodeBitArrayExtensions.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Diagnostics;
+using Unity.Mathematics;
+using Unity.Collections;
+using Unity.Collections.LowLevel.Unsafe;
+
+namespace Unity.NetCode
+{
+ ///
+ /// For .
+ /// Only needed until those changes land in those packages.
+ ///
+ public static class NetcodeBitArrayExtensions
+ {
+ ///
+ /// Shifts the entire bit array left (in other words: upwards away from 0, towards ).
+ /// Discards all bits shifted off the top, and all new bits shifted into existence from the bottom are 0.
+ ///
+ /// Instance to apply the operation on.
+ /// How far should all the bits be shifted (in number of bits i.e. bit indexes)?
+ public static unsafe void ShiftLeftExt(ref this UnsafeBitArray bitArray, int shiftBits)
+ {
+ if (shiftBits >= bitArray.Capacity)
+ {
+ bitArray.Clear();
+ return;
+ }
+ CheckShiftArgs(shiftBits);
+
+ var ptrLength = bitArray.Capacity >> 6;
+
+ // Shift entire 64bit blocks first:
+ {
+ var num64BitHops = shiftBits >> 6;
+#if ENABLE_UNITY_COLLECTIONS_CHECKS || UNITY_DOTS_DEBUG
+ UnityEngine.Debug.Assert(num64BitHops < ptrLength);
+#endif
+ for (int i = ptrLength - num64BitHops - 1; i >= 0; i--)
+ bitArray.Ptr[i + num64BitHops] = bitArray.Ptr[i];
+ // Zero out bottom indexes.
+ for (int i = 0; i < num64BitHops; i++)
+ bitArray.Ptr[i] = 0;
+ shiftBits -= num64BitHops * 64;
+ }
+
+ // Shift any remaining bits, running backwards (downwards) so we don't clobber previous values.
+ if (shiftBits > 0)
+ {
+#if ENABLE_UNITY_COLLECTIONS_CHECKS || UNITY_DOTS_DEBUG
+ UnityEngine.Debug.Assert(shiftBits < 64);
+#endif
+ for (int i = ptrLength - 1; i >= 1; i--)
+ {
+ bitArray.Ptr[i] <<= shiftBits;
+ bitArray.Ptr[i] |= bitArray.Ptr[i - 1] >> (64 - shiftBits);
+ }
+
+ bitArray.Ptr[0] <<= shiftBits;
+ }
+ }
+
+ ///
+ /// Shifts the entire bit array right (in other words: downwards towards 0, away from ).
+ /// Discards all bits shifted off the bottom, and all new bits shifted into existence at the top are 0.
+ ///
+ /// Instance to apply the operation on.
+ /// How far should all the bits be shifted (in number of bits i.e. bit indexes)?
+ public static unsafe void ShiftRightExt(ref this UnsafeBitArray bitArray, int shiftBits)
+ {
+ if (shiftBits >= bitArray.Capacity)
+ {
+ bitArray.Clear();
+ return;
+ }
+
+ CheckShiftArgs(shiftBits);
+ var ptrLength = bitArray.Capacity >> 6;
+
+ // Shift entire 64bit blocks first:
+ {
+ var num64BitHops = shiftBits >> 6;
+#if ENABLE_UNITY_COLLECTIONS_CHECKS || UNITY_DOTS_DEBUG
+ UnityEngine.Debug.Assert(num64BitHops < ptrLength);
+#endif
+ for (int i = 0; i < ptrLength - num64BitHops; i++)
+ bitArray.Ptr[i] = bitArray.Ptr[i + num64BitHops];
+ // Zero out top indexes.
+ for (int i = ptrLength - num64BitHops; i < ptrLength; i++)
+ bitArray.Ptr[i] = 0;
+ shiftBits -= num64BitHops * 64;
+ }
+
+ // Shift any remaining bits.
+ if (shiftBits > 0)
+ {
+#if ENABLE_UNITY_COLLECTIONS_CHECKS || UNITY_DOTS_DEBUG
+ UnityEngine.Debug.Assert(shiftBits < 64);
+#endif
+ for (int i = 0; i < ptrLength - 1; i++)
+ {
+ bitArray.Ptr[i] >>= shiftBits;
+ bitArray.Ptr[i] |= bitArray.Ptr[i + 1] << (64 - shiftBits);
+ }
+
+ bitArray.Ptr[ptrLength - 1] >>= shiftBits;
+ }
+ }
+
+ [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS"), Conditional("UNITY_DOTS_DEBUG")]
+ static void CheckShiftArgs(int shiftBits)
+ {
+ if (shiftBits < 0)
+ throw new ArgumentOutOfRangeException($"Shift called with negative bits value {shiftBits}!");
+ }
+
+ /// Logs a human-readable format for this bit array.
+ /// Instance to apply the operation on.
+ /// Denotes the max fixed string length, if you want a concatenated string.
+ /// Example: BitArray[num_bits,length,numTrueBits,indexOfLastTrueBit][10011100-00000000-00100000-00000000-00000000-00000000-00000000-0000000...]
+ public static unsafe FixedString4096Bytes ToDecimalFixedStringExt(ref this UnsafeBitArray bitArray, int maxFixedStringLength = 4093)
+ {
+ var ptrLength = bitArray.Capacity >> 6;
+ var lastTrueBitIndex = bitArray.FindLastSetBitExt();
+ var numTrueBits = lastTrueBitIndex >= 0 ? bitArray.CountBits(0, lastTrueBitIndex + 1) : 0;
+ FixedString32Bytes end = default;
+ if (numTrueBits == 0)
+ end = ",ZEROS";
+ else if (numTrueBits == bitArray.Length)
+ end = ",ONES";
+
+ FixedString4096Bytes sb = $"BitArray[bits:{bitArray.Length},len:{ptrLength}ul,num1s:{numTrueBits},last1:{lastTrueBitIndex}{end}][";
+ var exitCap = math.min(maxFixedStringLength, sb.Capacity);
+ for (var i = 0; i < ptrLength; i++)
+ {
+ var maxBit = i == ptrLength - 1 && bitArray.Length != bitArray.Capacity ? bitArray.Length % 64 : 64;
+ for (int b = 0; b < maxBit; b++)
+ {
+ sb.Append((1ul << b & bitArray.Ptr[i]) != 0 ? '1' : '0');
+ if (exitCap - sb.Length <= 5)
+ {
+ sb.Append((FixedString32Bytes) "...");
+ goto doubleBreak;
+ }
+
+ if (b % 8 == 7) sb.Append(b != 63 ? '_' : '|');
+ }
+ }
+
+ doubleBreak:
+ sb.Append(']');
+ return sb;
+ }
+
+ /// Finds the index of the last true bit in the BitArray, and returns said bit index.
+ /// The bitArray to query.
+ /// -1 if no true bits found.
+ public static unsafe int FindLastSetBitExt(ref this UnsafeBitArray bitArray)
+ {
+ var ptrLength = bitArray.Capacity >> 6;
+ var ptrIndex = ptrLength - 1;
+ // Special case for first index because of length:
+ if (bitArray.Length != bitArray.Capacity)
+ {
+ var maxIndex = bitArray.Length % 64;
+ var leastSignificantMask = (1ul << maxIndex) - 1;
+ var mask = bitArray.Ptr[ptrIndex] & leastSignificantMask;
+ if (mask != default) return (ptrIndex * 64) + (63 - math.lzcnt(mask));
+ ptrIndex--;
+ }
+
+ for (; ptrIndex >= 0; ptrIndex--)
+ {
+ var mask = bitArray.Ptr[ptrIndex];
+ if (mask != default) return (ptrIndex * 64) + (63 - math.lzcnt(mask));
+ }
+
+ return -1;
+ }
+ }
+}
diff --git a/Runtime/Snapshot/NetcodeBitArrayExtensions.cs.meta b/Runtime/Snapshot/NetcodeBitArrayExtensions.cs.meta
new file mode 100644
index 0000000..1c406ec
--- /dev/null
+++ b/Runtime/Snapshot/NetcodeBitArrayExtensions.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 53f4da06e3fb4258996e4155a467c0ca
+timeCreated: 1727711327
\ No newline at end of file
diff --git a/Runtime/Snapshot/Prespawn/PrespawnHelper.cs b/Runtime/Snapshot/Prespawn/PrespawnHelper.cs
index e328062..d20b652 100644
--- a/Runtime/Snapshot/Prespawn/PrespawnHelper.cs
+++ b/Runtime/Snapshot/Prespawn/PrespawnHelper.cs
@@ -50,11 +50,12 @@ static public Entity CreatePrespawnSceneListGhostPrefab(EntityManager entityMana
var config = new GhostPrefabCreation.Config
{
Name = "PrespawnSceneList",
- Importance = 1,
+ Importance = 1000,
+ MaxSendRate = 0,
SupportedGhostModes = GhostModeMask.Predicted,
DefaultGhostMode = GhostMode.Predicted,
OptimizationMode = GhostOptimizationMode.Static,
- UsePreSerialization = false
+ UsePreSerialization = false,
};
//I need an unique identifier and should not clash with any loaded prefab.
diff --git a/Runtime/Snapshot/SnapshotData.cs b/Runtime/Snapshot/SnapshotData.cs
index 8f255a2..cf5a4a1 100644
--- a/Runtime/Snapshot/SnapshotData.cs
+++ b/Runtime/Snapshot/SnapshotData.cs
@@ -288,8 +288,8 @@ public unsafe struct SnapshotDynamicBuffersHelper
/// Get the size of the header at the beginning of the dynamic snapshot buffer. The size
/// of the header is constant.
///
- ///
- static public uint GetHeaderSize()
+ /// Size of the header at the beginning of the dynamic snapshot buffer
+ public static uint GetHeaderSize()
{
return (uint)GhostComponentSerializer.SnapshotSizeAligned(sizeof(uint) * GhostSystemConstants.SnapshotHistorySize);
}
@@ -297,12 +297,12 @@ static public uint GetHeaderSize()
///
/// Retrieve the dynamic buffer history slot pointer
///
- ///
- ///
- ///
- ///
- ///
- ///
+ /// Dynamic data buffer
+ /// history position in buffer
+ /// Length of buffer
+ /// pointer to dynamic buffer
+ /// Thrown if the position is invalid
+ /// Thrown if bufferlength is less than headersize
static public byte* GetDynamicDataPtr(byte* dynamicDataBuffer, int historyPosition, int bufferLength)
{
var headerSize = GetHeaderSize();
@@ -319,9 +319,9 @@ static public uint GetHeaderSize()
///
/// Return the currently available space (masks + buffer data) available in each slot.
///
- ///
- ///
- ///
+ /// Header size
+ /// Length
+ /// The currently available space (masks + buffer data) available in each slot
static public uint GetDynamicDataCapacity(uint headerSize, int length)
{
if (length < headerSize)
@@ -333,9 +333,9 @@ static public uint GetDynamicDataCapacity(uint headerSize, int length)
/// Return the history buffer capacity and the resulting size of each history buffer slot necessary to store
/// the given dynamic data size.
///
- ///
- ///
- ///
+ /// Dynamic data size
+ /// Slot size
+ /// History buffer capacity
static public uint CalculateBufferCapacity(uint dynamicDataSize, out uint slotSize)
{
var headerSize = GetHeaderSize();
@@ -347,9 +347,9 @@ static public uint CalculateBufferCapacity(uint dynamicDataSize, out uint slotSi
///
/// Compute the size of the bitmask for the given number of elements and mask bits. The size is aligned to 16 bytes.
///
- ///
- ///
- ///
+ /// Change mask bits
+ /// Number of elements
+ /// Size of bitmask
public static int GetDynamicDataChangeMaskSize(int changeMaskBits, int numElements)
{
return GhostComponentSerializer.SnapshotSizeAligned(GhostComponentSerializer.ChangeMaskArraySizeInUInts(numElements * changeMaskBits)*4);
diff --git a/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs b/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs
index 97fc4d4..2894646 100644
--- a/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs
+++ b/Runtime/Snapshot/SnapshotDataBufferComponentLookup.cs
@@ -42,7 +42,7 @@ internal SnapshotDataBufferComponentLookup(
///
/// Check if the spawning ghost mode is owner predicted.
///
- ///
+ /// The spawning ghost
/// True if the spawning ghost is owner predicted
public bool IsOwnerPredicted(in GhostSpawnBuffer ghost)
{
@@ -52,7 +52,7 @@ public bool IsOwnerPredicted(in GhostSpawnBuffer ghost)
///
/// Check if the spawning ghost has a .
///
- ///
+ /// The spawning ghost
/// True if the spawning ghost is owner predicted
public bool HasGhostOwner(in GhostSpawnBuffer ghost)
{
@@ -63,8 +63,8 @@ public bool HasGhostOwner(in GhostSpawnBuffer ghost)
/// Retrieve the network id of the player owning the ghost if the ghost archetype has a
/// .
///
- ///
- ///
+ /// The spawning ghost
+ /// Snapshot data buffers
/// the id of the player owning the ghost, if the is present, 0 otherwise.
public int GetGhostOwner(in GhostSpawnBuffer ghost, in DynamicBuffer data)
{
@@ -84,7 +84,7 @@ public int GetGhostOwner(in GhostSpawnBuffer ghost, in DynamicBuffer
- ///
+ /// The spawning ghost
/// The fallback mode to use
public GhostSpawnBuffer.Type GetFallbackPredictionMode(in GhostSpawnBuffer ghost)
{
@@ -92,24 +92,28 @@ public GhostSpawnBuffer.Type GetFallbackPredictionMode(in GhostSpawnBuffer ghost
}
///
- /// Check if the a component of type is present this spawning ghost.
+ /// Check if the component of type is present in this spawning ghost.
///
/// The index in the collection
- ///
- ///
- //This work for both IComponentData and IBufferElementData
+ /// Component type in spawning ghost.
+ /// Whether the component is present in this spawning ghost.
+ ///
+ /// This work for both IComponentData and IBufferElementData
+ ///
public bool HasComponent(int ghostTypeIndex) where T: unmanaged, IComponentData
{
return GetComponentDataOffset(TypeManager.GetTypeIndex(), ghostTypeIndex, out _) >= 0;
}
///
- /// Check if the a component of type is present this spawning ghost.
+ /// Check if the a component of type is present in this spawning ghost.
///
/// The index in the collection
- ///
- ///
- //This work for both IComponentData and IBufferElementData
+ /// Component type
+ /// Whether the type is present in this spawning ghost
+ ///
+ /// This work for both IComponentData and IBufferElementData
+ ///
public bool HasBuffer(int ghostTypeIndex) where T: unmanaged, IBufferElementData
{
return GetComponentDataOffset(TypeManager.GetTypeIndex(), ghostTypeIndex, out _) >= 0;
@@ -125,7 +129,7 @@ public bool HasBuffer(int ghostTypeIndex) where T: unmanaged, IBufferElementD
/// The entity snapshot history buffer.
/// The deserialized component data.
/// The slot in the history buffer to use.
- ///
+ /// Component type
/// True if the component is present and the component data is initialized. False otherwise
public bool TryGetComponentDataFromSnapshotHistory(int ghostTypeIndex, in DynamicBuffer snapshotBuffer,
out T componentData, int slotIndex=0) where T : unmanaged, IComponentData
@@ -151,10 +155,10 @@ public bool TryGetComponentDataFromSnapshotHistory(int ghostTypeIndex, in Dyn
///
/// Buffers aren't supported. Only components present on the root entity can be retrieved. Trying to get data for components in a child entity is not supported.
///
- ///
- ///
- ///
- ///
+ /// Spawning buffer
+ /// Snapshot data
+ /// Component data
+ /// Component type
/// True if the component is present and the component data is initialized. False otherwise
public bool TryGetComponentDataFromSpawnBuffer(in GhostSpawnBuffer ghost,
in DynamicBuffer snapshotData, out T componentData) where T: unmanaged, IComponentData
diff --git a/Runtime/Snapshot/SnapshotDataLookupHelper.cs b/Runtime/Snapshot/SnapshotDataLookupHelper.cs
index 6c8ed9a..3ffa5d9 100644
--- a/Runtime/Snapshot/SnapshotDataLookupHelper.cs
+++ b/Runtime/Snapshot/SnapshotDataLookupHelper.cs
@@ -25,7 +25,7 @@ public struct SnapshotDataLookupHelper
/// Default constructor, collect and initialize all the internal handles
/// and collect the necessary data structures.
///
- ///
+ /// See: .
/// The entity that hold the GhostCollection component
/// The entity that hold the SpawnedGhostEntityMap component
public SnapshotDataLookupHelper(ref SystemState state,
@@ -47,7 +47,7 @@ public SnapshotDataLookupHelper(ref SystemState state,
///
/// Call this method in your system OnUpdate to refresh all the internal handles.
///
- ///
+ /// See: .
public void Update(ref SystemState state)
{
m_GhostCollectionPrefabSerializerLookup.Update(ref state);
diff --git a/Runtime/SourceGenerators/NetCodeSourceGenerator.dll b/Runtime/SourceGenerators/NetCodeSourceGenerator.dll
index ad70ec5..8b3aae9 100644
Binary files a/Runtime/SourceGenerators/NetCodeSourceGenerator.dll and b/Runtime/SourceGenerators/NetCodeSourceGenerator.dll differ
diff --git a/Runtime/SourceGenerators/NetCodeSourceGenerator.pdb b/Runtime/SourceGenerators/NetCodeSourceGenerator.pdb
index a02863c..e8ae22f 100644
Binary files a/Runtime/SourceGenerators/NetCodeSourceGenerator.pdb and b/Runtime/SourceGenerators/NetCodeSourceGenerator.pdb differ
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/ComponentSerializer.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/ComponentSerializer.cs
new file mode 100644
index 0000000..18ab79a
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/CodeGenerator/ComponentSerializer.cs
@@ -0,0 +1,435 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis;
+
+namespace Unity.NetCode.Generators
+{
+ //ComponentGenerator instances are created by CodeGenerator. The class itseld is not threadsafe but since every
+ //SourceGenerator has its own Context it is safe use. Avoid to use shared static variables or state here and verify
+ //that in case you need, they are immutable or thread safe.
+ //The GhostCodeGen is per context so no special handling is necessary
+ internal class ComponentSerializer
+ {
+ private readonly TypeInformation m_TypeInformation;
+ public GhostCodeGen m_TargetGenerator;
+ private GhostCodeGen m_ActiveGenerator;
+ private readonly TypeTemplate m_Template;
+ //The Regex is immutable and threadsafe. The match collection can be used by a single thread only
+ private static Regex m_usingRegex = new Regex("(\\w+)(?=;)");
+
+ public bool IsContainerType => m_Template == null && m_ActiveGenerator == null;
+ public bool Composite => m_Template?.Composite ?? false;
+ public TypeInformation TypeInformation => m_TypeInformation;
+ public string TemplateOverridePath => m_Template?.TemplateOverridePath;
+ public string TemplatePath => m_Template?.TemplatePath;
+
+ public bool Quantized => m_Template?.SupportsQuantization ?? false;
+
+ private string[,] k_OverridableFragments =
+ {
+ // fragment + alernative fragment in case of interpolation
+ {"GHOST_FIELD", "GHOST_FIELD"},
+ {"GHOST_AGGREGATE_WRITE", "GHOST_AGGREGATE_WRITE"},
+ {"GHOST_COPY_TO_SNAPSHOT", "GHOST_COPY_TO_SNAPSHOT"},
+ {"GHOST_COPY_FROM_SNAPSHOT", "GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE"},
+ {"GHOST_RESTORE_FROM_BACKUP", "GHOST_RESTORE_FROM_BACKUP"},
+ {"GHOST_PREDICT", "GHOST_PREDICT"},
+ {"GHOST_REPORT_PREDICTION_ERROR", "GHOST_REPORT_PREDICTION_ERROR"},
+ {"GHOST_GET_PREDICTION_ERROR_NAME", "GHOST_GET_PREDICTION_ERROR_NAME"},
+ };
+
+ private string m_OverridableFragmentsList = "";
+
+ public void GenerateFields(CodeGenerator.Context context, string parent = null, Dictionary overrides = null)
+ {
+ if (m_Template == null)
+ return;
+
+ var quantization = m_TypeInformation.Attribute.quantization;
+ var interpolate = m_TypeInformation.Attribute.smoothing > 0;
+ var generator = context.codeGenCache.GetTemplateWithOverride(m_Template.TemplatePath, m_Template.TemplateOverridePath);
+ generator = generator.Clone();
+
+ // Prefix and Variable Replacements
+ var reference = string.IsNullOrEmpty(parent)
+ ? m_TypeInformation.FieldName
+ : $"{parent}.{m_TypeInformation.FieldName}";
+ var name = reference.Replace('.', '_');
+
+ generator.Replacements.Add("GHOST_FIELD_NAME", $"{name}");
+ generator.Replacements.Add("GHOST_FIELD_REFERENCE", $"{reference}");
+ generator.Replacements.Add("GHOST_FIELD_TYPE_NAME", m_TypeInformation.FieldTypeName);
+
+ if (quantization > 0)
+ {
+ generator.Replacements.Add("GHOST_QUANTIZE_SCALE", quantization.ToString());
+ generator.Replacements.Add("GHOST_DEQUANTIZE_SCALE",
+ $"{(1.0f / quantization).ToString(CultureInfo.InvariantCulture)}f");
+ }
+ float maxSmoothingDistSq = m_TypeInformation.Attribute.maxSmoothingDist * m_TypeInformation.Attribute.maxSmoothingDist;
+ bool enableExtrapolation = m_TypeInformation.Attribute.smoothing == (uint)TypeAttribute.AttributeFlags.InterpolatedAndExtrapolated;
+ generator.Replacements.Add("GHOST_MAX_INTERPOLATION_DISTSQ", maxSmoothingDistSq.ToString(CultureInfo.InvariantCulture));
+
+ // Skip fragments which have been overridden already
+ for (int i = 0; i < k_OverridableFragments.GetLength(0); i++)
+ {
+ if (overrides == null || !overrides.ContainsKey(k_OverridableFragments[i, 0]))
+ {
+ var fragment = k_OverridableFragments[i, 1];
+ var targetFragment = k_OverridableFragments[i, 0];
+ if (targetFragment == "GHOST_COPY_FROM_SNAPSHOT")
+ {
+ if (interpolate)
+ {
+ m_TargetGenerator.GenerateFragment(enableExtrapolation ? "GHOST_COPY_FROM_SNAPSHOT_ENABLE_EXTRAPOLATION" : "GHOST_COPY_FROM_SNAPSHOT_DISABLE_EXTRAPOLATION",
+ generator.Replacements, m_TargetGenerator, "GHOST_COPY_FROM_SNAPSHOT");
+ // The setup section is optional, so do not generate error if it is not present
+ generator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_SETUP", generator.Replacements, m_TargetGenerator,
+ "GHOST_COPY_FROM_SNAPSHOT", null, true);
+ // only generate max distance checks if clamp is enabled
+ if (maxSmoothingDistSq > 0)
+ {
+ generator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_DISTSQ", generator.Replacements, m_TargetGenerator,
+ "GHOST_COPY_FROM_SNAPSHOT");
+ m_TargetGenerator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_CLAMP_MAX", generator.Replacements, m_TargetGenerator,
+ "GHOST_COPY_FROM_SNAPSHOT");
+ }
+ }
+ else
+ fragment = "GHOST_COPY_FROM_SNAPSHOT";
+ }
+ generator.GenerateFragment(fragment, generator.Replacements, m_TargetGenerator,
+ targetFragment);
+ }
+ }
+
+ // Imports
+ var imports = generator.GetFragmentTemplate("GHOST_IMPORTS");
+ if (!string.IsNullOrEmpty(imports))
+ {
+ foreach (var import in imports.Split('\n'))
+ {
+ if (string.IsNullOrEmpty(import))
+ continue;
+ var matches = m_usingRegex.Matches(import);
+ if (matches.Count == 1)
+ {
+ context.imports.Add(matches[0].Value);
+ }
+ }
+ }
+
+ ulong fieldHash = 0;
+ fieldHash = Utilities.TypeHash.CombineFNV1A64(fieldHash, Utilities.TypeHash.FNV1A64(m_TypeInformation.Attribute.aggregateChangeMask?1:0));
+ fieldHash = Utilities.TypeHash.CombineFNV1A64(fieldHash, Utilities.TypeHash.FNV1A64(m_TypeInformation.Attribute.subtype));
+ fieldHash = Utilities.TypeHash.CombineFNV1A64(fieldHash, (ulong)m_TypeInformation.Attribute.quantization);
+ fieldHash = Utilities.TypeHash.CombineFNV1A64(fieldHash, Utilities.TypeHash.FNV1A64((int)m_TypeInformation.Attribute.smoothing));
+ context.ghostFieldHash = Utilities.TypeHash.CombineFNV1A64(context.ghostFieldHash, fieldHash);
+ m_ActiveGenerator = generator;
+ }
+
+ internal Dictionary GenerateCompositeOverrides(CodeGenerator.Context context, string parent = null)
+ {
+ var fragments = new Dictionary();
+ if (m_Template == null || string.IsNullOrEmpty(m_Template.TemplateOverridePath))
+ return null;
+
+ var quantization = m_TypeInformation.Attribute.quantization;
+ var interpolate = m_TypeInformation.Attribute.smoothing > 0;
+ var generator = context.codeGenCache.GetTemplate(m_Template.TemplateOverridePath);
+ generator = generator.Clone();
+
+ // Prefix and Variable Replacements
+ var reference = string.IsNullOrEmpty(parent)
+ ? m_TypeInformation.FieldName
+ : $"{parent}.{m_TypeInformation.FieldName}";
+ var name = reference.Replace('.', '_');
+
+ generator.Replacements.Add("GHOST_FIELD_NAME", $"{name}");
+ generator.Replacements.Add("GHOST_FIELD_REFERENCE", $"{reference}");
+ generator.Replacements.Add("GHOST_FIELD_TYPE_NAME", m_TypeInformation.FieldTypeName);
+
+ if (quantization > 0)
+ {
+ generator.Replacements.Add("GHOST_QUANTIZE_SCALE", quantization.ToString());
+ generator.Replacements.Add("GHOST_DEQUANTIZE_SCALE",
+ $"{(1.0f / quantization).ToString(CultureInfo.InvariantCulture)}f");
+ }
+ float maxSmoothingDistSq = m_TypeInformation.Attribute.maxSmoothingDist * m_TypeInformation.Attribute.maxSmoothingDist;
+ bool enableExtrapolation = m_TypeInformation.Attribute.smoothing == (uint)TypeAttribute.AttributeFlags.InterpolatedAndExtrapolated;
+ generator.Replacements.Add("GHOST_MAX_INTERPOLATION_DISTSQ", maxSmoothingDistSq.ToString(CultureInfo.InvariantCulture));
+
+ // Type Info
+ if (generator.GenerateFragment("GHOST_FIELD", generator.Replacements, m_TargetGenerator, null, null, true))
+ fragments.Add("GHOST_FIELD", m_TargetGenerator.Fragments["__GHOST_FIELD__"]);
+ // CopyToSnapshot
+ if (generator.GenerateFragment("GHOST_COPY_TO_SNAPSHOT", generator.Replacements, m_TargetGenerator, null, null, true))
+ fragments.Add("GHOST_COPY_TO_SNAPSHOT", m_TargetGenerator.Fragments["__GHOST_COPY_TO_SNAPSHOT__"]);
+
+ // CopyFromSnapshot
+ if (interpolate)
+ {
+ if (generator.HasFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE"))
+ {
+ m_TargetGenerator.GenerateFragment(enableExtrapolation ? "GHOST_COPY_FROM_SNAPSHOT_ENABLE_EXTRAPOLATION" : "GHOST_COPY_FROM_SNAPSHOT_DISABLE_EXTRAPOLATION",
+ generator.Replacements, m_TargetGenerator, "GHOST_COPY_FROM_SNAPSHOT");
+ // The setup section is optional, so do not generate error if it is not present
+ generator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_SETUP", generator.Replacements, m_TargetGenerator,
+ "GHOST_COPY_FROM_SNAPSHOT", null, true);
+ // only generate max distance checks if clamp is enabled
+ if (maxSmoothingDistSq > 0)
+ {
+ generator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_DISTSQ", generator.Replacements, m_TargetGenerator,
+ "GHOST_COPY_FROM_SNAPSHOT");
+ m_TargetGenerator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_CLAMP_MAX", generator.Replacements, m_TargetGenerator,
+ "GHOST_COPY_FROM_SNAPSHOT");
+ }
+ generator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE" ,
+ generator.Replacements, m_TargetGenerator, "GHOST_COPY_FROM_SNAPSHOT");
+ fragments.Add("GHOST_COPY_FROM_SNAPSHOT", generator.Fragments["__GHOST_COPY_FROM_SNAPSHOT__"]);
+ fragments.Add("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE", generator.Fragments["__GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE__"]);
+ }
+ }
+ else
+ {
+ if (generator.GenerateFragment("GHOST_COPY_FROM_SNAPSHOT",
+ generator.Replacements, m_TargetGenerator, "GHOST_COPY_FROM_SNAPSHOT", null, true))
+ {
+ fragments.Add("GHOST_COPY_FROM_SNAPSHOT", generator.Fragments["__GHOST_COPY_FROM_SNAPSHOT__"]);
+ fragments.Add("GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE", generator.Fragments["__GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE__"]);
+ }
+ }
+ // RestoreFromBackup
+ if (generator.GenerateFragment("GHOST_RESTORE_FROM_BACKUP", generator.Replacements, m_TargetGenerator, null, null, true))
+ fragments.Add("GHOST_RESTORE_FROM_BACKUP", m_TargetGenerator.Fragments["__GHOST_RESTORE_FROM_BACKUP__"]);
+ // PredictDelta
+ if (generator.GenerateFragment("GHOST_PREDICT", generator.Replacements, m_TargetGenerator, null, null, true))
+ fragments.Add("GHOST_PREDICT", m_TargetGenerator.Fragments["__GHOST_PREDICT__"]);
+
+ // ReportPredictionError
+ if (generator.GenerateFragment("GHOST_REPORT_PREDICTION_ERROR", generator.Replacements, m_TargetGenerator, null, null, true))
+ fragments.Add("GHOST_REPORT_PREDICTION_ERROR", m_TargetGenerator.Fragments["__GHOST_REPORT_PREDICTION_ERROR__"]);
+ // GetPredictionErrorName
+ if (generator.GenerateFragment("GHOST_GET_PREDICTION_ERROR_NAME", generator.Replacements, m_TargetGenerator, null, null, true))
+ fragments.Add("GHOST_GET_PREDICTION_ERROR_NAME", m_TargetGenerator.Fragments["__GHOST_GET_PREDICTION_ERROR_NAME__"]);
+
+ ValidateOverridableFragments(context, generator.Fragments);
+
+ m_ActiveGenerator = generator;
+ return fragments;
+ }
+
+ private void ValidateOverridableFragments(CodeGenerator.Context context, Dictionary fragments)
+ {
+ foreach (var fragment in fragments)
+ {
+ bool supported = false;
+ foreach (var goodFrag in k_OverridableFragments)
+ if (fragment.Key.Contains(goodFrag))
+ supported = true;
+ if (!supported)
+ context.diagnostic.LogWarning($"{fragment.Key} is not overridable. Supported fragments are: {m_OverridableFragmentsList}");
+ }
+ }
+
+ public void GenerateMasks(CodeGenerator.Context context, bool aggregateMask = false, int fieldIndex = 0)
+ {
+ if (m_ActiveGenerator == null)
+ return;
+
+ var changeMaskFrag = "GHOST_CALCULATE_CHANGE_MASK";
+ var changeMaskFragZero = "GHOST_CALCULATE_CHANGE_MASK_ZERO";
+ var ghostWriteFrag = "GHOST_WRITE";
+ var ghostReadFrag = "GHOST_READ";
+ var generator = m_ActiveGenerator;
+ var target = m_TargetGenerator;
+ var curChangeMaskBit = context.curChangeMaskBits;
+
+ if (curChangeMaskBit == 32)
+ {
+ generator.Replacements.Add("GHOST_CURRENT_MASK_BITS", (context.changeMaskBitCount - curChangeMaskBit).ToString());
+ generator.Replacements.Add("GHOST_CHANGE_MASK_BITS", context.changeMaskBitCount.ToString());
+ target.GenerateFragment("GHOST_FLUSH_COMPONENT_CHANGE_MASK", generator.Replacements, target, "GHOST_CALCULATE_CHANGE_MASK");
+ target.GenerateFragment("GHOST_FLUSH_COMPONENT_CHANGE_MASK", generator.Replacements, target, "GHOST_WRITE_COMBINED");
+ target.GenerateFragment("GHOST_REFRESH_CHANGE_MASK", generator.Replacements, target, "GHOST_READ");
+ target.GenerateFragment("GHOST_REFRESH_CHANGE_MASK", generator.Replacements, target, "GHOST_WRITE");
+ curChangeMaskBit = 0;
+ }
+ context.curChangeMaskBits = curChangeMaskBit;
+ generator.Replacements.Add("GHOST_MASK_INDEX", curChangeMaskBit.ToString());
+ if (curChangeMaskBit == 0 && (!aggregateMask || fieldIndex == 0))
+ {
+ generator.GenerateFragment(changeMaskFragZero, generator.Replacements, target, "GHOST_CALCULATE_CHANGE_MASK");
+ generator.GenerateFragment(changeMaskFragZero, generator.Replacements, target, "GHOST_WRITE_COMBINED");
+ }
+ else
+ {
+ generator.GenerateFragment(changeMaskFrag, generator.Replacements, target, "GHOST_CALCULATE_CHANGE_MASK");
+ generator.GenerateFragment(changeMaskFrag, generator.Replacements, target, "GHOST_WRITE_COMBINED");
+ }
+ // Serialize
+ generator.GenerateFragment(ghostWriteFrag, generator.Replacements, target, "GHOST_WRITE");
+ if(!aggregateMask)
+ generator.GenerateFragment(ghostWriteFrag, generator.Replacements, target, "GHOST_WRITE_COMBINED");
+ else
+ generator.GenerateFragment(ghostWriteFrag, generator.Replacements, target, "GHOST_AGGREGATE_WRITE");
+ // Deserialize
+ generator.GenerateFragment(ghostReadFrag, generator.Replacements, target, "GHOST_READ");
+ }
+
+ public ComponentSerializer(CodeGenerator.Context context)
+ {
+ var generator = context.codeGenCache.GetTemplate(CodeGenerator.ComponentSerializer);
+ m_TargetGenerator = generator.Clone();
+ foreach (var frag in k_OverridableFragments.Cast())
+ {
+ if (!m_OverridableFragmentsList.Contains(frag))
+ m_OverridableFragmentsList += " " + frag;
+ }
+ }
+ public ComponentSerializer(CodeGenerator.Context context, TypeInformation information) : this(context)
+ {
+ m_TypeInformation = information;
+ }
+
+ public ComponentSerializer(CodeGenerator.Context context, TypeInformation information, TypeTemplate template) : this(context, information)
+ {
+ m_Template = template;
+ }
+
+ public void AppendTarget(ComponentSerializer componentSerializer)
+ {
+ m_TargetGenerator.Append(componentSerializer.m_TargetGenerator);
+ }
+
+ public void GenerateSerializer(CodeGenerator.Context context, TypeInformation type)
+ {
+ var replacements = new Dictionary(32);
+ if (type.GhostFields.Count > 0)
+ {
+ m_TargetGenerator.GenerateFragment("GHOST_COMPONENT_HAS_FIELDS", replacements);
+ }
+ if (type.ComponentType == ComponentType.Buffer || type.ComponentType == ComponentType.CommandData)
+ {
+ m_TargetGenerator.GenerateFragment("GHOST_COMPONENT_IS_BUFFER", replacements);
+ }
+ if (context.changeMaskBitCount > 0)
+ {
+ m_TargetGenerator.GenerateFragment("GHOST_CALCULATE_CHANGE_MASK_SETUP", replacements, m_TargetGenerator,
+ "GHOST_CALCULATE_CHANGE_MASK", prepend:true);
+ m_TargetGenerator.GenerateFragment("GHOST_CALCULATE_CHANGE_MASK_SETUP", replacements, m_TargetGenerator,
+ "GHOST_WRITE_COMBINED", prepend:true);
+ if(context.curChangeMaskBits > 0)
+ {
+ replacements.Add("GHOST_CURRENT_MASK_BITS", context.curChangeMaskBits.ToString());
+ replacements.Add("GHOST_CHANGE_MASK_BITS", (context.changeMaskBitCount - context.curChangeMaskBits).ToString());
+ m_TargetGenerator.GenerateFragment("GHOST_FLUSH_FINAL_COMPONENT_CHANGE_MASK", replacements);
+ m_TargetGenerator.AppendFragment("GHOST_FLUSH_FINAL_COMPONENT_CHANGE_MASK", m_TargetGenerator, "GHOST_WRITE_COMBINED");
+ }
+ }
+
+ if (!string.IsNullOrEmpty(type.Namespace))
+ context.imports.Add(type.Namespace);
+ foreach (var ns in context.imports)
+ {
+ replacements["GHOST_USING"] = CodeGenerator.GetValidNamespaceForType(context.generatedNs, ns);
+ m_TargetGenerator.GenerateFragment("GHOST_USING_STATEMENT", replacements);
+ }
+
+ replacements.Clear();
+
+ //getting the right fullyqualified typename to use for hash calculation.
+ //for non-generic type, it is namespace.[containtype].typename
+ //for generic type, it is namespace.containtype.typename`N[[fullyqualifiedname, assembly],[..]]
+ //Now, the assembly qualification is really annoying but this is how Entities at runtime calcule type hashes.
+ //so we need to do the same here (because we are not using Type or passing the Type at registration.. that would simplify everything)
+ string fullyQualifiedVariantName;
+ if (string.IsNullOrWhiteSpace(context.variantTypeFullName))
+ {
+ fullyQualifiedVariantName = Roslyn.Extensions.GetMetadataQualifiedName(type.Symbol as INamedTypeSymbol);
+ }
+ else
+ {
+ fullyQualifiedVariantName = context.variantTypeFullName;
+ }
+
+
+ if (context.variantHash == 0)
+ {
+ context.variantHash = Helpers.ComputeVariantHash(type.Symbol, type.Symbol);
+ context.diagnostic.LogDebug($"{type.TypeFullName} had its type hash reset, so recalculating it to {context.variantHash}!");
+ }
+
+ //Calculating the hash for the generic type is a little trickier.
+ //At runtime the CLR give names like XXX`1[FullName
+ replacements.Add("GHOST_NAME", context.generatorName.Replace(".", "").Replace('+', '_'));
+ replacements.Add("GHOST_NAMESPACE", context.generatedNs);
+ replacements.Add("GHOST_COMPONENT_TYPE", type.TypeFullName.Replace('+', '.'));
+ replacements.Add("GHOST_VARIANT_TYPE", fullyQualifiedVariantName);
+ replacements.Add("GHOST_CHANGE_MASK_BITS", context.changeMaskBitCount.ToString());
+ replacements.Add("GHOST_FIELD_HASH", context.ghostFieldHash.ToString());
+ replacements.Add("GHOST_VARIANT_HASH", context.variantHash.ToString());
+ replacements.Add("GHOST_SERIALIZES_ENABLED_BIT", type.ShouldSerializeEnabledBit ? "1" : "0");
+
+ if(type.GhostAttribute != null)
+ {
+ replacements.Add("GHOST_PREFAB_TYPE", $"GhostPrefabType.{type.GhostAttribute.PrefabType.ToString("G").Replace(",", "| GhostPrefabType.")}");
+ if ((type.GhostAttribute.PrefabType&GhostPrefabType.Client) == GhostPrefabType.InterpolatedClient)
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.OnlyInterpolatedClients");
+ else if((type.GhostAttribute.PrefabType&GhostPrefabType.Client) == GhostPrefabType.PredictedClient)
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.OnlyPredictedClients");
+ else if (type.GhostAttribute.PrefabType == GhostPrefabType.Server)
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.DontSend");
+ else if (type.GhostAttribute.SendTypeOptimization == GhostSendType.OnlyInterpolatedClients)
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.OnlyInterpolatedClients");
+ else if (type.GhostAttribute.SendTypeOptimization == GhostSendType.OnlyPredictedClients)
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.OnlyPredictedClients");
+ else if(type.GhostAttribute.SendTypeOptimization == GhostSendType.AllClients)
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.AllClients");
+ else
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.DontSend");
+
+ var ownerType = type.GhostAttribute.OwnerSendType;
+ if (type.ComponentType == ComponentType.CommandData && (ownerType & SendToOwnerType.SendToOwner) != 0)
+ {
+ context.diagnostic.LogWarning($"ICommandData {type.TypeFullName} is configured to be sent to ghost owner. It will be ignored");
+ ownerType &= ~SendToOwnerType.SendToOwner;
+ }
+ replacements.Add("GHOST_SEND_OWNER", "SendToOwnerType." + ownerType);
+ }
+ else if(type.ComponentType != ComponentType.CommandData)
+ {
+ replacements.Add("GHOST_PREFAB_TYPE", "GhostPrefabType.All");
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.AllClients");
+ replacements.Add("GHOST_SEND_OWNER", "SendToOwnerType.All");
+ replacements.Add("GHOST_SEND_CHILD_ENTITY", "0");
+ }
+ else
+ {
+ replacements.Add("GHOST_PREFAB_TYPE", "GhostPrefabType.All");
+ replacements.Add("GHOST_SEND_MASK", "GhostSendType.OnlyPredictedClients");
+ replacements.Add("GHOST_SEND_OWNER", "SendToOwnerType.SendToNonOwner");
+ replacements.Add("GHOST_SEND_CHILD_ENTITY", "0");
+ }
+
+ if (m_TargetGenerator.Fragments["__GHOST_REPORT_PREDICTION_ERROR__"].Content.Length > 0)
+ m_TargetGenerator.GenerateFragment("GHOST_PREDICTION_ERROR_HEADER", replacements, m_TargetGenerator);
+
+ var serializerName = context.generatedFilePrefix + "Serializer.cs";
+ m_TargetGenerator.GenerateFile(serializerName, type.Namespace, replacements, context.batch);
+
+ context.generatedTypes.Add($"global::{context.generatedNs}.{replacements["GHOST_NAME"]}");
+ }
+
+ public override string ToString()
+ {
+ var debugInformation = m_TypeInformation.ToString();
+ debugInformation += m_Template?.ToString();
+ debugInformation += m_TargetGenerator?.ToString();
+ return debugInformation;
+ }
+ }
+}
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/CommandFactory.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/CommandFactory.cs
new file mode 100644
index 0000000..6b24c1c
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/CommandFactory.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using System.Linq;
+using System;
+
+namespace Unity.NetCode.Generators
+{
+ internal class CommandFactory
+ {
+ ///
+ /// Collect and generate commands serialization.
+ ///
+ ///
+ ///
+ ///
+ public static void Generate(IReadOnlyList commandCandidates, CodeGenerator.Context codeGenContext)
+ {
+ var typeBuilder = new TypeInformationBuilder(codeGenContext.diagnostic, codeGenContext.executionContext, TypeInformationBuilder.SerializationMode.Commands);
+ var rootNamespace = codeGenContext.generatedNs;
+
+ foreach (var syntaxNode in commandCandidates)
+ {
+ codeGenContext.executionContext.CancellationToken.ThrowIfCancellationRequested();
+ Profiler.Begin("GetSemanticModel");
+ var model = codeGenContext.executionContext.Compilation.GetSemanticModel(syntaxNode.SyntaxTree);
+ Profiler.End();
+ var candidateSymbol = model.GetDeclaredSymbol(syntaxNode) as INamedTypeSymbol;
+ if (candidateSymbol == null)
+ continue;
+
+ var disableCommandCodeGen = Roslyn.Extensions.GetAttribute(candidateSymbol,
+ "Unity.NetCode", "NetCodeDisableCommandCodeGenAttribute");
+ if (disableCommandCodeGen != null)
+ continue;
+ // If the serializer type already exist we can just skip generation
+ if (codeGenContext.executionContext.Compilation.GetSymbolsWithName(GetCommandSerializerName(candidateSymbol)).FirstOrDefault() != null)
+ {
+ codeGenContext.diagnostic.LogInfo($"Skipping code-gen for {candidateSymbol.Name} because a command serializer for it already exists");
+ continue;
+ }
+
+ var typeNamespace = Roslyn.Extensions.GetFullyQualifiedNamespace(candidateSymbol);
+ if(typeNamespace.StartsWith("__COMMAND", StringComparison.Ordinal) ||
+ typeNamespace.StartsWith("__GHOST", StringComparison.Ordinal))
+ {
+ codeGenContext.diagnostic.LogError($"Invalid namespace {typeNamespace} for {candidateSymbol.Name}. __GHOST and __COMMAND are reserved prefixes and cannot be used in namspace, type and field names",
+ syntaxNode.GetLocation());
+ continue;
+ }
+ var typeInfo = typeBuilder.BuildTypeInformation(candidateSymbol, null);
+ if (typeInfo == null)
+ continue;
+
+ NameUtils.UpdateNameAndNamespace(ref typeInfo, rootNamespace, ref codeGenContext, ref candidateSymbol);
+ codeGenContext.diagnostic.LogInfo($"Generating command for {typeInfo.TypeFullName}");
+ codeGenContext.types.Add(typeInfo);
+ codeGenContext.ResetState();
+ CodeGenerator.GenerateCommand(codeGenContext, typeInfo, CommandSerializer.Type.Command);
+ }
+ codeGenContext.generatedNs = rootNamespace;
+ }
+ static private string GetCommandSerializerName(INamedTypeSymbol symbol)
+ {
+ return $"{symbol.Name}Serializer";
+ }
+ }
+}
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/ComponentFactory.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/ComponentFactory.cs
new file mode 100644
index 0000000..bf865cb
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/ComponentFactory.cs
@@ -0,0 +1,290 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Unity.NetCode.Roslyn;
+
+namespace Unity.NetCode.Generators
+{
+ internal class ComponentFactory
+ {
+ ///
+ /// Collect and generate component serialization. Is also responsible to generate the registration system.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static void Generate(
+ IReadOnlyList componentsCandidates,
+ IReadOnlyList variantsCandidates,
+ CodeGenerator.Context codeGenContext)
+ {
+ GenerateComponents(componentsCandidates, codeGenContext);
+ GenerateVariants(variantsCandidates, codeGenContext);
+ CodeGenerator.GenerateRegistrationSystem(codeGenContext);
+ }
+
+ private static void GenerateComponents(IEnumerable components, CodeGenerator.Context codeGenContext)
+ {
+ var typeBuilder = new TypeInformationBuilder(codeGenContext.diagnostic, codeGenContext.executionContext, TypeInformationBuilder.SerializationMode.Component);
+ var rootNamespace = codeGenContext.generatedNs;
+ foreach (var componentCandidate in components)
+ {
+ codeGenContext.executionContext.CancellationToken.ThrowIfCancellationRequested();
+
+ var syntaxNode = componentCandidate as TypeDeclarationSyntax;
+ var hasGhostEnabledBitAttribute = HasGhostEnabledBitAttribute(syntaxNode);
+ var hasGhostFields = HasGhostFields(syntaxNode);
+
+ // Warning! These only work if the attribute is not inherited (thus they cannot be inherited from).
+ if (!HasGhostComponentAttribute(syntaxNode) && !hasGhostFields && !hasGhostEnabledBitAttribute)
+ continue;
+
+ Profiler.Begin("GetSemanticModel");
+ var model = codeGenContext.executionContext.Compilation.GetSemanticModel(componentCandidate.SyntaxTree);
+ var candidateSymbol = model.GetDeclaredSymbol(componentCandidate) as INamedTypeSymbol;
+ Profiler.End();
+ if (candidateSymbol == null)
+ {
+ codeGenContext.diagnostic.LogError($"No INamedTypeSymbol for componentCandidate '{componentCandidate.ToFullString()}'.", syntaxNode.GetLocation());
+ continue;
+ }
+
+ var typeNamespace = Roslyn.Extensions.GetFullyQualifiedNamespace(candidateSymbol);
+ if (typeNamespace.StartsWith("__COMMAND", StringComparison.Ordinal) ||
+ typeNamespace.StartsWith("__GHOST", StringComparison.Ordinal))
+ {
+ codeGenContext.diagnostic.LogError($"Invalid namespace {typeNamespace} for {candidateSymbol.Name}. __GHOST and __COMMAND are reserved prefixes and cannot be used in namspace, type and field names",
+ syntaxNode.GetLocation());
+ continue;
+ }
+
+ var ghostComponent = TryGetGhostComponent(candidateSymbol);
+ var typeInfo = typeBuilder.BuildTypeInformation(candidateSymbol, ghostComponent);
+ if (typeInfo == null)
+ continue;
+
+ //This is an error for buffers and commands that require serialization. Is handled later, outside, that way
+ //we report first all the errors and then skip the type.
+ if (typeBuilder.MissingGhostFields.Count > 0)
+ {
+ // These need to be fully annotated or not at all. So it's ok all fields have missing
+ // annotations (normal CommandData or buffer with ghost component annotation) but not ok
+ // if one is already present (remote player command buffer sync or just a normal dynamic buffer)
+ if ((typeInfo.ComponentType == ComponentType.Buffer || typeInfo.ComponentType == ComponentType.CommandData) &&
+ typeInfo.GhostFields.Count > 0)
+ {
+ foreach (var field in typeBuilder.MissingGhostFields)
+ codeGenContext.diagnostic.LogError(
+ $"GhostField missing on field {field}. Buffers must have all fields annotated. CommandData must have none, for normal client to server command stream, or all, as a normal stream and also as a buffer sent from server to other (non-owner) clients.",
+ componentCandidate.GetLocation());
+ typeBuilder.MissingGhostFields.Clear();
+ continue;
+ }
+ typeBuilder.MissingGhostFields.Clear();
+ }
+
+ var variantHash = Helpers.ComputeVariantHash(typeInfo.Symbol, typeInfo.Symbol);
+ var isSerialized = hasGhostFields || typeInfo.ShouldSerializeEnabledBit;
+ codeGenContext.serializationStrategies.Add(new CodeGenerator.Context.SerializationStrategyCodeGen
+ {
+ TypeInfo = typeInfo,
+ VariantTypeName = typeInfo.TypeFullName.Replace('+', '.'),
+ ComponentTypeName = typeInfo.TypeFullName.Replace('+', '.'),
+ Hash = variantHash.ToString(),
+ GhostAttribute = ghostComponent,
+ IsSerialized = isSerialized,
+ });
+
+ if (!isSerialized)
+ continue;
+
+ // If the serializer type already exist we can just skip generation
+ if (codeGenContext.executionContext.Compilation.GetSymbolsWithName(GetGhostSerializerName(candidateSymbol)).FirstOrDefault() != null)
+ {
+ codeGenContext.diagnostic.LogDebug($"Skipping code-gen for {candidateSymbol.Name} because a component serializer for it already exists");
+ continue;
+ }
+
+ codeGenContext.diagnostic.LogInfo($"Generating ghost for {typeInfo.TypeFullName}");
+ codeGenContext.ResetState();
+ NameUtils.UpdateNameAndNamespace(ref typeInfo, rootNamespace, ref codeGenContext, ref candidateSymbol);
+ codeGenContext.types.Add(typeInfo);
+ CodeGenerator.GenerateGhost(codeGenContext, typeInfo);
+ }
+
+ codeGenContext.generatedNs = rootNamespace;
+ }
+
+ private static void GenerateVariants(IEnumerable variants, CodeGenerator.Context codeGenContext)
+ {
+ var typeBuilder = new TypeInformationBuilder(codeGenContext.diagnostic, codeGenContext.executionContext,
+ TypeInformationBuilder.SerializationMode.Component);
+ var rootNamespace = codeGenContext.generatedNs;
+
+ foreach (var componentCandidate in variants)
+ {
+ codeGenContext.executionContext.CancellationToken.ThrowIfCancellationRequested();
+ Profiler.Begin("GetSemanticModel");
+ var model = codeGenContext.executionContext.Compilation.GetSemanticModel(componentCandidate.SyntaxTree);
+ var variantSymbol = model.GetDeclaredSymbol(componentCandidate) as INamedTypeSymbol;
+ Profiler.End();
+ if (variantSymbol == null)
+ continue;
+
+ var syntaxNode = componentCandidate as TypeDeclarationSyntax;
+ var ghostComponent = TryGetGhostComponent(variantSymbol);
+ var variation = Roslyn.Extensions.GetAttribute(variantSymbol, "Unity.NetCode", "GhostComponentVariationAttribute");
+ var variantTypeInfo = typeBuilder.BuildVariantTypeInformation(variantSymbol, variation, ghostComponent);
+ if (variantTypeInfo == null)
+ continue;
+
+ var variantHash = Helpers.ComputeVariantHash(variantSymbol, (ITypeSymbol) variation.ConstructorArguments[0].Value);
+ var hasGhostFields = variantTypeInfo.GhostFields.Count != 0;
+ var displayName = variation.ConstructorArguments[1].Value;
+ if (displayName is not string name || string.IsNullOrWhiteSpace(name))
+ displayName = default;
+
+ var isSerialized = hasGhostFields || variantTypeInfo.ShouldSerializeEnabledBit;
+ codeGenContext.serializationStrategies.Add(new CodeGenerator.Context.SerializationStrategyCodeGen
+ {
+ TypeInfo = variantTypeInfo,
+ DisplayName = (string)displayName,
+ VariantTypeName = Roslyn.Extensions.GetFullTypeName(variantSymbol).Replace('+', '.'),
+ ComponentTypeName = variantTypeInfo.TypeFullName.Replace('+', '.'),
+ Hash = variantHash.ToString(),
+ GhostAttribute = ghostComponent,
+ IsSerialized = isSerialized,
+ });
+
+ if (!isSerialized)
+ continue;
+
+ // If the serializer type already exist we can just skip generation
+ if (codeGenContext.executionContext.Compilation.GetSymbolsWithName(GetGhostSerializerName(variantSymbol)).FirstOrDefault() != null)
+ {
+ codeGenContext.diagnostic.LogDebug($"Skipping code-gen for {variantSymbol.Name} because a variant component serializer for it already exists");
+ continue;
+ }
+
+ //This is an error for buffers and commands that require serialization. Is handled later, outside, that way
+ //we report first all the errors and then skip the type.
+ if (variantTypeInfo.ComponentType == ComponentType.Buffer)
+ {
+ if (typeBuilder.MissingGhostFields.Count > 0)
+ {
+ foreach (var field in typeBuilder.MissingGhostFields)
+ codeGenContext.diagnostic.LogError($"GhostField missing on field {field} on Variant {variantTypeInfo.TypeFullName}. Buffers or CommandData must have all fields annotated!",
+ syntaxNode.GetLocation());
+ typeBuilder.MissingGhostFields.Clear();
+ continue;
+ }
+ }
+
+ codeGenContext.types.Add(variantTypeInfo);
+ codeGenContext.diagnostic.LogDebug($"Generating serializer for variant {variantSymbol.ToDisplayString()} for type {variantTypeInfo.TypeFullName}.");
+ codeGenContext.ResetState();
+
+ NameUtils.UpdateNameAndNamespace(ref variantTypeInfo, rootNamespace, ref codeGenContext, ref variantSymbol);
+
+ codeGenContext.variantTypeFullName = Roslyn.Extensions.GetFullTypeName(variantSymbol);
+ codeGenContext.variantHash = variantHash;
+ CodeGenerator.GenerateGhost(codeGenContext, variantTypeInfo);
+ }
+
+ codeGenContext.generatedNs = rootNamespace;
+ }
+
+ ///
+ /// Fast early exit check to determine if we need to serialize a type.
+ ///
+ ///
+ static private bool HasGhostFields(TypeDeclarationSyntax structNode)
+ {
+ using (new Profiler.Auto("HasGhostFields"))
+ {
+ foreach (var t in structNode.Members
+ .SelectMany(attr => attr.AttributeLists, (attr, list) => list.Attributes)
+ .SelectMany(attributes => attributes))
+ {
+ //Remove qualifiers if present
+ var name = t.Name is QualifiedNameSyntax syntax
+ ? syntax.Right.Identifier.ValueText
+ : t.Name.ToString();
+ if (name == "GhostField" || name == "GhostFieldAttribute")
+ return true;
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// Fast early exit check to determine if we need to serialize a type.
+ ///
+ ///
+ static private bool HasGhostEnabledBitAttribute(TypeDeclarationSyntax structNode)
+ {
+ using (new Profiler.Auto("HasGhostEnabledBitAttribute"))
+ {
+ foreach (var t in structNode.AttributeLists
+ .SelectMany(list => list.Attributes))
+ {
+ //Remove qualifiers if present
+ var name = t.Name is QualifiedNameSyntax syntax
+ ? syntax.Right.Identifier.ValueText
+ : t.Name.ToString();
+ if (name == "GhostEnabledBit" || name == "GhostEnabledBitAttribute")
+ return true;
+ }
+ return false;
+ }
+ }
+
+ static internal bool HasGhostComponentAttribute(TypeDeclarationSyntax structNode)
+ {
+ using (new Profiler.Auto("HasGhostComponentAttribute"))
+ {
+ foreach (var t in structNode.AttributeLists
+ .SelectMany(list => list.Attributes))
+ {
+ //Remove qualifiers if present
+ var name = t.Name is QualifiedNameSyntax syntax
+ ? syntax.Right.Identifier.ValueText
+ : t.Name.ToString();
+ if (name == "GhostComponent" || name == "GhostComponentAttribute")
+ return true;
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// Check if a GhostComponentAttribute is present for the given symbol
+ ///
+ ///
+ static internal GhostComponentAttribute TryGetGhostComponent(ISymbol symbol)
+ {
+ using (new Profiler.Auto("TryGetGhostComponent"))
+ {
+ var attributeData = Roslyn.Extensions.GetAttribute(symbol, "Unity.NetCode", "GhostComponentAttribute");
+ if (attributeData == null)
+ return default;
+ var ghostAttribute = new GhostComponentAttribute();
+ if (attributeData.NamedArguments.Length <= 0)
+ return ghostAttribute;
+ var modifierType = typeof(GhostComponentAttribute);
+ foreach (var t in attributeData.NamedArguments)
+ modifierType.GetField(t.Key)?.SetValue(ghostAttribute, t.Value.Value);
+
+ return ghostAttribute;
+ }
+ }
+
+ static private string GetGhostSerializerName(INamedTypeSymbol symbol)
+ {
+ return $"{symbol.Name}GhostComponentSerializer";
+ }
+ }
+}
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/InputFactory.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/InputFactory.cs
new file mode 100644
index 0000000..eb85508
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/Factories/InputFactory.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using System.Linq;
+
+namespace Unity.NetCode.Generators
+{
+ internal class InputFactory
+ {
+ public static void Generate(IReadOnlyList inputCandidates, CodeGenerator.Context codeGenContext, GeneratorExecutionContext executionContext)
+ {
+ var typeBuilder = new TypeInformationBuilder(codeGenContext.diagnostic, codeGenContext.executionContext, TypeInformationBuilder.SerializationMode.Commands);
+ var rootNamespace = codeGenContext.generatedNs;
+ foreach (var syntaxNode in inputCandidates)
+ {
+ codeGenContext.executionContext.CancellationToken.ThrowIfCancellationRequested();
+ Profiler.Begin("GetSemanticModel");
+ var model = codeGenContext.executionContext.Compilation.GetSemanticModel(syntaxNode.SyntaxTree);
+ Profiler.End();
+ var candidateSymbol = model.GetDeclaredSymbol(syntaxNode) as INamedTypeSymbol;
+ if (candidateSymbol == null)
+ continue;
+ // If the serializer type already exist we can just skip generation
+ if (codeGenContext.executionContext.Compilation.GetSymbolsWithName(GetSyncInputName(candidateSymbol)).FirstOrDefault() != null)
+ {
+ codeGenContext.diagnostic.LogDebug($"Skipping code-gen for {candidateSymbol.Name} because a command data wrapper for it exists already");
+ continue;
+ }
+
+ codeGenContext.ResetState();
+ var typeInfo = typeBuilder.BuildTypeInformation(candidateSymbol, null);
+ NameUtils.UpdateNameAndNamespace(ref typeInfo, rootNamespace, ref codeGenContext, ref candidateSymbol);
+ if (typeInfo == null)
+ continue;
+ codeGenContext.types.Add(typeInfo);
+ codeGenContext.diagnostic.LogInfo($"Generating command data wrapper for ${typeInfo.TypeFullName}");
+ CodeGenerator.GenerateCommand(codeGenContext, typeInfo, CommandSerializer.Type.Input);
+ }
+
+ codeGenContext.generatedNs = rootNamespace;
+ }
+ static private string GetSyncInputName(INamedTypeSymbol symbol)
+ {
+ return $"{symbol.Name}InputBufferData";
+ }
+ }
+}
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSourceGenerator.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSourceGenerator.cs
new file mode 100644
index 0000000..97bf757
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/NetCodeSourceGenerator.cs
@@ -0,0 +1,331 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Unity.NetCode.Generators
+{
+ public static class GlobalOptions
+ {
+ ///
+ /// Override the current project path. Used by the generator to flush logs or lookup files.
+ ///
+ public const string ProjectPath = "unity.netcode.sourcegenerator.projectpath";
+ ///
+ /// Override the output folder where the generator flush logs and generated files.
+ ///
+ public const string OutputPath = "unity.netcode.sourcegenerator.outputfolder";
+ ///
+ /// Skip validation of missing assmebly references. Mostly used for testing.
+ ///
+ public const string DisableRerencesChecks = "unity.netcode.sourcegenerator.disable_references_checks";
+ ///
+ /// Enable/Disable support for passing custom templates using additional files. Mostly for testing
+ ///
+ public const string TemplateFromAdditionalFiles = "unity.netcode.sourcegenerator.templates_from_additional_files";
+ ///
+ /// Enable/Disable writing generated code to output folder
+ ///
+ public const string WriteFilesToDisk = "unity.netcode.sourcegenerator.write_files_to_disk";
+ ///
+ /// Enable/Disable writing logs to the file (default is Temp/NetCodeGenerated/sourcegenerator.log)
+ ///
+ public const string WriteLogsToDisk = "unity.netcode.sourcegenerator.write_logs_to_disk";
+ ///
+ /// The minimal log level. Available: Debug, Warning, Error. Default is error. (NOT SUPPORTED YET)
+ ///
+ public const string LoggingLevel = "unity.netcode.sourcegenerator.logging_level";
+ ///
+ /// Enable/Disable writing logs to the file (default is Temp/NetCodeGenerated/sourcegenerator.log)
+ ///
+ public const string EmitTimings = "unity.netcode.sourcegenerator.emit_timing";
+ ///
+ /// Enable/Disable writing logs to the file (default is Temp/NetCodeGenerated/sourcegenerator.log)
+ ///
+ public const string AttachDebugger = "unity.netcode.sourcegenerator.attach_debugger";
+
+ ///
+ /// return if a flag is set in the GlobalOption dictionary.
+ /// A flag is consider set if the key is in the GlobalOptions and its string value is either empty or "1"
+ /// Otherwise the flag is considered as not set.
+ ///
+ public static bool GetOptionsFlag(this GeneratorExecutionContext context, string key, bool defaultValue=false)
+ {
+ if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue(key, out var stringValue))
+ return string.IsNullOrEmpty(stringValue) || (stringValue is "1" or "true");
+ return defaultValue;
+ }
+
+ ///
+ /// Return the string value associated with the key in the GlobalOptions if the key is present
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static string GetOptionsString(this GeneratorExecutionContext context, string key, string defaultValue=null)
+ {
+ if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue(key, out var stringValue))
+ return stringValue;
+ return defaultValue;
+ }
+ }
+
+ ///
+ /// Parse the syntax tree using and generate for Rpc, Commands and Ghost
+ /// serialization code.
+ /// Must be stateless and immutable. Can be called from multiple thread or the instance reused
+ ///
+ [Generator]
+ public class NetCodeSourceGenerator : ISourceGenerator
+ {
+ internal struct Candidates
+ {
+ public List Components;
+ public List Rpcs;
+ public List Commands;
+ public List Inputs;
+ public List Variants;
+ }
+
+ public const string NETCODE_ADDITIONAL_FILE = ".NetCodeSourceGenerator.additionalfile";
+
+ public void Initialize(GeneratorInitializationContext context)
+ {
+ context.RegisterForSyntaxNotifications(() => new NetCodeSyntaxReceiver());
+ //Initialize the profile here also take in account the internal Unity compilation time not
+ //stritcly related to the source generators. This is useful metric to have, since we can then how
+ //much we accounts (in %) in respect to the total compilation time.
+ Profiler.Initialize();
+ }
+
+ static bool ShouldRunGenerator(GeneratorExecutionContext executionContext)
+ {
+ //Skip running if no references to netcode are passed to the compilation
+ return executionContext.Compilation.Assembly.Name.StartsWith("Unity.NetCode", StringComparison.Ordinal) ||
+ executionContext.Compilation.ReferencedAssemblyNames.Any(r=>
+ r.Name.Equals("Unity.NetCode", StringComparison.Ordinal) ||
+ r.Name.Equals("Unity.NetCode.ref", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Main entry point called from Roslyn, after the syntax analysis has been completed.
+ /// At this point we should have collected all the candidates
+ ///
+ ///
+ public void Execute(GeneratorExecutionContext executionContext)
+ {
+ executionContext.CancellationToken.ThrowIfCancellationRequested();
+
+ if (!ShouldRunGenerator(executionContext))
+ return;
+
+ Helpers.SetupContext(executionContext);
+ var diagnostic = new DiagnosticReporter(executionContext);
+ diagnostic.LogInfo($"Begin Processing assembly {executionContext.Compilation.AssemblyName}");
+
+ //If the attach_debugger key is present (but without value) the returned string is the empty string (not null)
+ var debugAssembly = executionContext.GetOptionsString(GlobalOptions.AttachDebugger);
+ if(debugAssembly != null)
+ {
+ Debug.LaunchDebugger(executionContext, debugAssembly);
+ }
+ try
+ {
+ Generate(executionContext, diagnostic);
+ }
+ catch (Exception e)
+ {
+ diagnostic.LogException(e);
+ }
+ diagnostic.LogInfo($"End Processing assembly {executionContext.Compilation.AssemblyName}.");
+ diagnostic.LogInfo(Profiler.PrintStats(executionContext.GetOptionsFlag(GlobalOptions.EmitTimings)));
+ }
+
+ private static void Generate(GeneratorExecutionContext executionContext, IDiagnosticReporter diagnostic)
+ {
+ //Try to dispatch any unknown candidates to the right array by checking what interface the struct is implementing
+ var receiver = (NetCodeSyntaxReceiver)executionContext.SyntaxReceiver;
+ var candidates = ResolveCandidates(executionContext, receiver, diagnostic);
+ var totalCandidates = candidates.Rpcs.Count + candidates.Commands.Count + candidates.Components.Count + candidates.Variants.Count + candidates.Inputs.Count;
+ if (totalCandidates == 0)
+ return;
+
+ //Initialize template registry and register custom user type definitions
+ var typeRegistry = new TypeRegistry(DefaultTypes.Registry);
+ List customUserTypes;
+ using (new Profiler.Auto("LoadRegistryAndOverrides"))
+ {
+ customUserTypes = UserDefinedTemplateRegistryParser.ParseTemplates(executionContext, diagnostic);
+ typeRegistry.AddRange(customUserTypes);
+ }
+ var templateFileProvider = new TemplateFileProvider(diagnostic);
+ //Additional files always provides the extra templates in 2021.2 and newer. The templates files must end with .netcode.additionalfile extensions.
+ templateFileProvider.AddAdditionalTemplates(executionContext.AdditionalFiles, customUserTypes);
+ templateFileProvider.PerformAdditionalTypeRegistryValidation(customUserTypes);
+ if (!Helpers.SupportTemplateFromAdditionalFiles)
+ {
+ //template path are resolved dynamically using the current project path.
+ var pathResolver = new PathResolver(Helpers.ProjectPath);
+ pathResolver.LoadManifestMapping();
+ templateFileProvider.pathResolver = pathResolver;
+ }
+ var codeGenerationContext = new CodeGenerator.Context(typeRegistry, templateFileProvider, diagnostic, executionContext, executionContext.Compilation.AssemblyName);
+ // The ghost,commands and rpcs generation start here. Just loop through all the semantic models, check
+ // the necessary conditions and pass the extract TypeInformation to our custom code generation system
+ // that will build the necessary source code.
+ using (new Profiler.Auto("Generate"))
+ {
+ // Generate command data wrapper for input data and the CopyToBuffer/CopyFromBuffer systems
+ using(new Profiler.Auto("InputGeneration"))
+ InputFactory.Generate(candidates.Inputs, codeGenerationContext, executionContext);
+ //Generate serializers for components and buffers
+ using (new Profiler.Auto("ComponentGeneration"))
+ ComponentFactory.Generate(candidates.Components, candidates.Variants, codeGenerationContext);
+ // Generate serializers for rpcs and commands
+ using(new Profiler.Auto("CommandsGeneration"))
+ CommandFactory.Generate(candidates.Commands, codeGenerationContext);
+ using(new Profiler.Auto("RpcGeneration"))
+ RpcFactory.Generate(candidates.Rpcs, codeGenerationContext);
+ }
+ if (codeGenerationContext.batch.Count > 0)
+ {
+ if(!executionContext.GetOptionsFlag(GlobalOptions.DisableRerencesChecks))
+ {
+ //Make sure the assembly has the right references and treat them as a fatal error
+ var missingReferences = new HashSet{"Unity.Collections", "Unity.Burst", "Unity.Mathematics"};
+ foreach (var r in executionContext.Compilation.ReferencedAssemblyNames)
+ missingReferences.Remove(r.Name);
+ if (missingReferences.Count > 0)
+ {
+ codeGenerationContext.diagnostic.LogError(
+ $"Assembly {executionContext.Compilation.AssemblyName} contains NetCode replicated types. The serialization code will use " +
+ $"burst, collections, mathematics and network data streams but the assembly does not have references to: {string.Join(",", missingReferences)}. " +
+ $"Please add the missing references in the asmdef for {executionContext.Compilation.AssemblyName}.");
+ }
+ }
+ }
+ AddGeneratedSources(executionContext, codeGenerationContext);
+ }
+
+ ///
+ /// Map ambigous syntax nodes to code-generation type candidates.
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static Candidates ResolveCandidates(GeneratorExecutionContext executionContext, NetCodeSyntaxReceiver receiver, IDiagnosticReporter diagnostic)
+ {
+ var candidates = new Candidates
+ {
+ Components = new List(),
+ Rpcs = new List(),
+ Commands = new List(),
+ Inputs = new List(),
+ Variants = receiver.Variants
+ };
+
+ foreach (var candidate in receiver.Candidates)
+ {
+ executionContext.CancellationToken.ThrowIfCancellationRequested();
+
+ var symbolModel = executionContext.Compilation.GetSemanticModel(candidate.SyntaxTree);
+ var candidateSymbol = symbolModel.GetDeclaredSymbol(candidate) as ITypeSymbol;
+ var allComponentTypes = Roslyn.Extensions.GetAllComponentType(candidateSymbol).ToArray();
+ //No valid/known interfaces
+ if (allComponentTypes.Length == 0)
+ continue;
+
+ //The struct is implementing more than one valid interface. Report the error/warning and skip the code-generation
+ if (allComponentTypes.Length > 1)
+ {
+ diagnostic.LogError(
+ $"struct {Roslyn.Extensions.GetFullTypeName(candidateSymbol)} cannot implement {string.Join(",", allComponentTypes)} interfaces at the same time",
+ candidateSymbol?.Locations[0]);
+ continue;
+ }
+ switch (allComponentTypes[0])
+ {
+ case ComponentType.Unknown:
+ break;
+ case ComponentType.Component:
+ candidates.Components.Add(candidate);
+ break;
+ case ComponentType.HybridComponent:
+ candidates.Components.Add(candidate);
+ break;
+ case ComponentType.Buffer:
+ candidates.Components.Add(candidate);
+ break;
+ case ComponentType.Rpc:
+ candidates.Rpcs.Add(candidate);
+ break;
+ case ComponentType.CommandData:
+ candidates.Commands.Add(candidate);
+ candidates.Components.Add(candidate);
+ break;
+ case ComponentType.Input:
+ candidates.Inputs.Add(candidate);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ return candidates;
+ }
+
+ ///
+ /// Add the generated source files to the current compilation and flush everything on disk (if enabled)
+ ///
+ ///
+ ///
+ private static void AddGeneratedSources(GeneratorExecutionContext executionContext, CodeGenerator.Context codeGenContext)
+ {
+ using (new Profiler.Auto("WriteFile"))
+ {
+ executionContext.CancellationToken.ThrowIfCancellationRequested();
+ //Always delete all the previously generated files
+ if (Helpers.CanWriteFiles)
+ {
+ var outputFolder = Path.Combine(Helpers.GetOutputPath(), $"{executionContext.Compilation.AssemblyName}");
+ if(Directory.Exists(outputFolder))
+ Directory.Delete(outputFolder, true);
+ if(codeGenContext.batch.Count != 0)
+ Directory.CreateDirectory(outputFolder);
+ }
+ if (codeGenContext.batch.Count == 0)
+ return;
+
+ foreach (var nameAndSource in codeGenContext.batch)
+ {
+ executionContext.CancellationToken.ThrowIfCancellationRequested();
+ var sourceText = SourceText.From(nameAndSource.Code, System.Text.Encoding.UTF8);
+ //Normalize filename for hint purpose. Special characters are not supported anymore
+ //var hintName = uniqueName.Replace('/', '_').Replace('+', '-');
+ //TODO: compute a normalized hash of that name using a common stable hash algorithm
+ var sourcePath = Path.Combine($"{executionContext.Compilation.AssemblyName}", nameAndSource.GeneratedFileName);
+ var hintName = Utilities.TypeHash.FNV1A64(sourcePath).ToString();
+ //With the new version of roslyn, is necessary to add to the generate file
+ //a first line with #line1 "sourcecodefullpath" so that when debugging the right
+ //file is used. IMPORTANT: the #line directive should be not in the file you save on
+ //disk to correct match the debugging line
+ executionContext.AddSource(hintName, sourceText.WithInitialLineDirective(sourcePath));
+ try
+ {
+ if (Helpers.CanWriteFiles)
+ File.WriteAllText(Path.Combine(Helpers.GetOutputPath(), sourcePath), sourceText.ToString());
+ }
+ catch (System.Exception e)
+ {
+ //In the rare event/occasion when this happen, at the very least don't bother the user and move forward
+ Debug.LogWarning($"cannot write file {Path.Combine(Helpers.GetOutputPath(), sourcePath)}. An exception has been thrown:{e}");
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TypeInformationBuilder.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TypeInformationBuilder.cs
new file mode 100644
index 0000000..5793b28
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/TypeInformationBuilder.cs
@@ -0,0 +1,547 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Unity.NetCode.Roslyn;
+
+namespace Unity.NetCode.Generators
+{
+ ///
+ /// Helper builder that let you to construct a TypeInformation tree from a Rosylin ITypeSymbol
+ ///
+ internal struct TypeInformationBuilder
+ {
+ public enum SerializationMode
+ {
+ Component,
+ Commands,
+ Variant,
+ }
+
+ private GeneratorExecutionContext m_context;
+ private IDiagnosticReporter m_Reporter;
+ private SerializationMode m_SerializationMode;
+ private List m_MissingGhostFields;
+
+ public List MissingGhostFields => m_MissingGhostFields;
+
+ ///
+ /// Used to control the level of accessibility required by struct used for serialization.
+ /// Components and Buffers must have GhostFields only on public members.
+ /// But for variant declaration this is not necessary, since the variant is never used (is only a proxy type)
+ ///
+ private bool m_RequiresPublicFields;
+
+ public TypeInformationBuilder(IDiagnosticReporter reporter, GeneratorExecutionContext context, SerializationMode mode)
+ {
+ m_context = context;
+ m_Reporter = reporter;
+ m_SerializationMode = mode;
+ m_MissingGhostFields = new List();
+ m_RequiresPublicFields = mode != SerializationMode.Variant;
+ }
+
+ ///
+ /// Build a code-generation specific semantic tree model for the type
+ ///
+ ///
+ public TypeInformation BuildTypeInformation(ITypeSymbol symbol, GhostComponentAttribute ghostAttribute, GhostField ghostFieldOverride = null)
+ {
+ m_context.CancellationToken.ThrowIfCancellationRequested();
+ m_Reporter.LogDebug($"Building type info for {symbol}");
+ var isEnableableComponent = Roslyn.Extensions.ImplementsInterface(symbol, "Unity.Entities.IEnableableComponent");
+ var hasGhostEnabledBitAttribute = Roslyn.Extensions.GetAttribute(symbol, "Unity.NetCode", "GhostEnabledBitAttribute") != null;
+ var fullTypeName = Roslyn.Extensions.GetFullTypeName(symbol);
+
+ if (hasGhostEnabledBitAttribute && !isEnableableComponent)
+ {
+ m_Reporter.LogError($"'{fullTypeName}' has attribute `[GhostEnabledBit]` (denoting that its enabled bit will be replicated), but the component is not implementing the `IEnableableComponent` interface! Either remove the attribute, or implement the interface.");
+ return null;
+ }
+
+ var typeInfo = new TypeInformation
+ {
+ Kind = Roslyn.Extensions.GetTypeKind(symbol),
+ ComponentType = Roslyn.Extensions.GetComponentType(symbol),
+ TypeFullName = fullTypeName,
+ Namespace = Roslyn.Extensions.GetFullyQualifiedNamespace(symbol),
+ FieldName = string.Empty,
+ FieldTypeName = Roslyn.Extensions.GetFieldTypeName(symbol),
+ UnderlyingTypeName = String.Empty,
+ Attribute = TypeAttribute.Empty(),
+ AttributeMask = m_SerializationMode != SerializationMode.Commands
+ ? TypeAttribute.AttributeFlags.All
+ : TypeAttribute.AttributeFlags.None,
+ GhostAttribute = ghostAttribute,
+ Location = symbol.Locations[0],
+ Symbol = symbol,
+ ShouldSerializeEnabledBit = isEnableableComponent && hasGhostEnabledBitAttribute,
+ HasDontSupportPrefabOverridesAttribute = Roslyn.Extensions.GetAttribute(symbol, "Unity.NetCode", "DontSupportPrefabOverridesAttribute") != null,
+ IsTestVariant = false,
+ };
+ //Mask out inherited attributes that does not apply. SubType is also never inherited, buffer fields are never interpolated
+ if (typeInfo.ComponentType != ComponentType.Component)
+ typeInfo.AttributeMask &= ~TypeAttribute.AttributeFlags.InterpolatedAndExtrapolated;
+
+ //This can be a little expensive sometime (up to tents of ms)
+ var members = symbol.GetMembers();
+ using (new Profiler.Auto("ParseMembers"))
+ {
+ foreach (var member in members.OfType())
+ {
+ m_context.CancellationToken.ThrowIfCancellationRequested();
+ if (typeInfo.ComponentType is ComponentType.CommandData or ComponentType.Rpc &&
+ m_SerializationMode == SerializationMode.Commands &&
+ ShouldDiscardCommandField(member))
+ continue;
+
+ //This is a little expensive operation (up to some ms)
+ var memberType = member.Type;
+ var field = ParseFieldType(member, memberType, typeInfo, string.Empty, 1, ghostFieldOverride);
+ if (field != null)
+ typeInfo.GhostFields.Add(field);
+ }
+ }
+
+ using (new Profiler.Auto("ParseProperties"))
+ {
+ foreach (var prop in members.OfType())
+ {
+ m_context.CancellationToken.ThrowIfCancellationRequested();
+ if (!CheckIsSerializableProperty(prop))
+ continue;
+
+ if (typeInfo.ComponentType is ComponentType.CommandData or ComponentType.Rpc &&
+ m_SerializationMode == SerializationMode.Commands &&
+ ShouldDiscardCommandField(prop))
+ continue;
+
+ var field = ParseFieldType(prop, prop.Type, typeInfo, string.Empty, 1, ghostFieldOverride);
+ if (field != null)
+ typeInfo.GhostFields.Add(field);
+ }
+ }
+
+ return typeInfo;
+ }
+
+ ///
+ /// Build a TypeDescriptor tree model for the variant type type.
+ ///
+ ///
+ public TypeInformation BuildVariantTypeInformation(ITypeSymbol variantSymbol, AttributeData variantAttribute, GhostComponentAttribute ghostAttribute)
+ {
+ m_context.CancellationToken.ThrowIfCancellationRequested();
+ //Fetch the argument from the template declaration. This is the type for witch we want to inject serialization
+ if (variantAttribute.ConstructorArguments.Length == 0)
+ {
+ m_Reporter.LogError($"{variantSymbol.Name} does not have constructor arguments", variantSymbol.Locations[0]);
+ return null;
+ }
+
+ var adapteeType = (ITypeSymbol)variantAttribute.ConstructorArguments[0].Value;
+ if (adapteeType == null)
+ {
+ m_Reporter.LogError($"{variantSymbol} constructed with a null type", variantSymbol.Locations[0]);
+ return null;
+ }
+ if (adapteeType.DeclaredAccessibility == Accessibility.NotApplicable)
+ {
+ m_Reporter.LogError($"{variantSymbol.Name}: problem parsing this type, make sure the compilation unit compiles", variantSymbol.Locations[0]);
+ return null;
+ }
+ if (adapteeType.DeclaredAccessibility != Accessibility.Public)
+ {
+ m_Reporter.LogError($"{variantSymbol.Name}: the component type must be public accessible", variantSymbol.Locations[0]);
+ return null;
+ }
+ if (Roslyn.Extensions.GetAttribute(adapteeType, "Unity.NetCode", "DontSupportPrefabOverridesAttribute") != null)
+ {
+ m_Reporter.LogError($"{variantSymbol.Name}: the target component does not support variation because it has the DontSupportPrefabOverridesAttribute", variantSymbol.Locations[0]);
+ return null;
+ }
+ var adapteeComponentType = Roslyn.Extensions.GetComponentType(adapteeType);
+ if (adapteeComponentType != ComponentType.Component && adapteeComponentType != ComponentType.Buffer &&
+ !Roslyn.Extensions.InheritsFromBase(adapteeType, "UnityEngine.Component"))
+ {
+ m_Reporter.LogError($"{variantSymbol.Name}: the component type must be IComponentData, IBufferElementData or UnityEngine.Component", variantSymbol.Locations[0]);
+ return null;
+ }
+
+ // TODO - Write test for parsing this.
+ var isTestVariant = false;
+ if (variantAttribute.ConstructorArguments.Length == 2)
+ {
+ // Arg 2 MIGHT be the bool.
+ if (variantAttribute.ConstructorArguments[1].Value is bool testVariant)
+ {
+ isTestVariant = testVariant;
+ }
+ // Else assume it's the string name.
+ }
+ else if (variantAttribute.ConstructorArguments.Length == 3)
+ {
+ if (variantAttribute.ConstructorArguments[2].Value is bool testVariant)
+ {
+ isTestVariant = testVariant;
+ }
+ else
+ {
+ m_Reporter.LogError($"{variantSymbol.Name}: `variantAttribute.ConstructorArguments[2]` is somehow not a bool, but expected it to be `IsTestVariant`.");
+ return null;
+ }
+ }
+
+ //Validation and member collection step: loop over the field in the variant declarion. Only fields that are also prensent int original component are considered.
+ //Any private or missing field are considered an error.
+ var declaredMembers = new List>(32);
+ bool hasErrors = false;
+ using (new Profiler.Auto("ValidationAndExtraction"))
+ {
+ //This check should be part of a RoslynAnalyzer for net code that detect that problem at editing time in an IDE
+ //However, we should still do the check here for robustness.
+ foreach (var member in variantSymbol.GetMembers().OfType())
+ {
+ var originalMember = adapteeType.GetMembers(member.Name).FirstOrDefault();
+ if(originalMember == null ||
+ (originalMember as IFieldSymbol)?.Type.GetFullTypeName() != member.Type.GetFullTypeName())
+ {
+ hasErrors = true;
+ m_Reporter.LogError($"{variantSymbol.Name}: Cannot find member {member.Name} type: {member.Type.Name} in {adapteeType.Name}", member.Locations[0]);
+ continue;
+ }
+ if (originalMember.DeclaredAccessibility != Accessibility.Public)
+ {
+ hasErrors = true;
+ m_Reporter.LogError($"{variantSymbol.Name}: member {member.Name} type: {member.Type.Name} in {adapteeType.Name} must be public", member.Locations[0]);
+ continue;
+ }
+ declaredMembers.Add((member, member.Type));
+ }
+ foreach (var prop in variantSymbol.GetMembers().OfType())
+ {
+ if (!CheckIsSerializableProperty(prop))
+ continue;
+
+ var originalMember = adapteeType.GetMembers(prop.Name).FirstOrDefault();
+ if(originalMember == null ||
+ (originalMember as IPropertySymbol)?.Type.GetFullTypeName() != prop.Type.GetFullTypeName())
+ {
+ hasErrors = true;
+ m_Reporter.LogError($"{variantSymbol.Name}: Cannot find property {prop.Name} type: {prop.Type.Name} in {adapteeType.Name}", prop.Locations[0]);
+ continue;
+ }
+ if (originalMember.DeclaredAccessibility != Accessibility.Public)
+ {
+ hasErrors = true;
+ m_Reporter.LogError($"{variantSymbol.Name}: property {prop.Name} type: {prop.Type.Name} in {adapteeType.Name} must be public", prop.Locations[0]);
+ continue;
+ }
+ declaredMembers.Add((prop, prop.Type));
+ }
+ }
+ //In case of errors, it is safer to just skip
+ if (hasErrors)
+ return null;
+
+ m_context.CancellationToken.ThrowIfCancellationRequested();
+ var fullTypeName = Roslyn.Extensions.GetFullTypeName(adapteeType);
+ var hasGhostEnabledBitAttribute = Roslyn.Extensions.GetAttribute(variantSymbol, "Unity.NetCode", "GhostEnabledBitAttribute") != null;
+ var adapteeIsEnableableComponent = Roslyn.Extensions.ImplementsInterface(adapteeType, "Unity.Entities.IEnableableComponent");
+
+ // TODO - Tests for `[GhostEnabledBit]`s on variants.
+ if (hasGhostEnabledBitAttribute && !adapteeIsEnableableComponent)
+ {
+ m_Reporter.LogError($"'{fullTypeName}' (a variant) has attribute `[GhostEnabledBit]` (denoting that we intend to replicate the enabled bit on the source type), but the source type (`{variantSymbol.Name}`) is not implementing the `IEnableableComponent` interface! Either remove the attribute from the variant, or implement the interface.");
+ return null;
+ }
+
+ var typeInfo = new TypeInformation
+ {
+ Kind = Roslyn.Extensions.GetTypeKind(adapteeType),
+ ComponentType = adapteeComponentType,
+ TypeFullName = fullTypeName,
+ UnderlyingTypeName = String.Empty,
+ Namespace = Roslyn.Extensions.GetFullyQualifiedNamespace(adapteeType),
+ FieldName = string.Empty,
+ FieldTypeName = Roslyn.Extensions.GetFieldTypeName(adapteeType),
+ Attribute = TypeAttribute.Empty(),
+ GhostAttribute = ghostAttribute,
+ Location = variantSymbol.Locations[0],
+ Symbol = variantSymbol,
+ IsTestVariant = isTestVariant,
+ ShouldSerializeEnabledBit = adapteeIsEnableableComponent && hasGhostEnabledBitAttribute,
+ };
+
+ //Mask out inherited attributes that does not apply. SubType is also never inherited, buffer fields are never interpolated
+ if (typeInfo.ComponentType != ComponentType.Component)
+ typeInfo.AttributeMask &= ~TypeAttribute.AttributeFlags.Interpolated;
+
+ using (new Profiler.Auto("ParseMembers"))
+ {
+ foreach (var member in declaredMembers)
+ {
+ m_Reporter.LogDebug($"Parsing field {member}");
+ var field = ParseFieldType(member.Item1, member.Item2, typeInfo, string.Empty);
+ if (field != null)
+ typeInfo.GhostFields.Add(field);
+ }
+ }
+ return typeInfo;
+ }
+
+ ///
+ /// Build a TypeInformation tree for a field if fhe field should be serialized.
+ /// A member of as struct is serialized if the following conditions are true:
+ /// - The member must have public accessibilty.
+ /// - The member must be not static
+ /// - The member must have either a [GhostField] annotation or a custom ghost override.
+ /// - The member type must be one of the supported type: Primitive, Enum, Struct. Class members are considered invalid.
+ /// The function is recursive.
+ ///
+ /// A valid TypeInformation instance if the member fulfills all the requirement. Null otherwise
+ public TypeInformation ParseFieldType(ISymbol member, ITypeSymbol memberType, TypeInformation parent, string fieldPath, int level=1, GhostField ghostFieldOverride = null)
+ {
+ m_context.CancellationToken.ThrowIfCancellationRequested();
+ var ghostField = default(GhostField);
+ if (m_SerializationMode == SerializationMode.Component)
+ {
+ if (ghostFieldOverride != null)
+ {
+ // Only apply overrides to members which are valid as ghost fields
+ if (!member.IsStatic && member.DeclaredAccessibility == Accessibility.Public)
+ ghostField = ghostFieldOverride;
+ }
+ else
+ ghostField = TryGetGhostField(member);
+ }
+ if(m_SerializationMode != SerializationMode.Commands)
+ {
+ if (member.IsStatic || (m_RequiresPublicFields && member.DeclaredAccessibility != Accessibility.Public))
+ {
+ if(ghostField != null)
+ m_Reporter.LogError($"GhostField present on a non public or non instance field '{parent.TypeFullName}.{member.Name}'! GhostFields must be public, instance fields.");
+ return null;
+ }
+
+ //Skip fields who don't have any [GhostField] attribute or the SendData is set to false
+ if ((ghostField == null && string.IsNullOrEmpty(fieldPath)))
+ {
+ //Buffer need some further validation, and we collect here any missing field
+ if ((parent.ComponentType == ComponentType.Buffer || parent.ComponentType == ComponentType.CommandData))
+ {
+ m_MissingGhostFields.Add($"{parent.TypeFullName}.{member.Name}");
+ }
+ return null;
+ }
+ if ((ghostField != null && !ghostField.SendData))
+ return null;
+ }
+ else if (member.IsStatic || member.DeclaredAccessibility != Accessibility.Public)
+ return null;
+
+ //Add some validation and skip irrelevant fields too
+ var typeKind = Roslyn.Extensions.GetTypeKind(memberType);
+ if (typeKind == GenTypeKind.Invalid)
+ {
+ m_Reporter.LogError($"GhostField annotation present on non serializable field '{parent.TypeFullName}.{member.Name}'.");
+ return null;
+ }
+
+ if ((typeKind != GenTypeKind.Struct) && (ghostField != null && ghostField.Composite.HasValue && ghostField.Composite.Value))
+ m_Reporter.LogError($"GhostField for field '{parent.TypeFullName}.{member.Name}' set Composite=True, but this is invalid on primitive types.");
+
+ var typeInfo = new TypeInformation
+ {
+ Kind = typeKind,
+ TypeFullName = Roslyn.Extensions.GetFullTypeName(memberType),
+ Namespace = Roslyn.Extensions.GetFullyQualifiedNamespace(memberType),
+ FieldName = member.Name,
+ UnderlyingTypeName = Roslyn.Extensions.GetUnderlyingTypeName(memberType),
+ DeclaringTypeFullName = Roslyn.Extensions.GetFullTypeName(member.ContainingType),
+ FieldTypeName = Roslyn.Extensions.GetFieldTypeName(memberType),
+ Attribute = parent.Attribute,
+ AttributeMask = parent.AttributeMask,
+ Parent = fieldPath,
+ Location = member.Locations[0],
+ CanBatchPredict = CanBatchPredict(member),
+ Symbol = member as ITypeSymbol
+ };
+
+ if(typeInfo.FieldName.StartsWith("__COMMAND", StringComparison.Ordinal) ||
+ typeInfo.FieldName.StartsWith("__GHOST", StringComparison.Ordinal))
+ {
+ m_Reporter.LogError($"Invalid field name '{parent.TypeFullName}.{typeInfo.FieldName}'. __GHOST and __COMMAND are reserved prefixes and cannot be used in namespace, type and field names!",
+ member.Locations[0]);
+ return null;
+ }
+ if(typeInfo.FieldTypeName.StartsWith("__COMMAND", StringComparison.Ordinal) ||
+ typeInfo.FieldTypeName.StartsWith("__GHOST", StringComparison.Ordinal))
+ {
+ m_Reporter.LogError($"Invalid typename '{typeInfo.FieldTypeName}' for '{parent.TypeFullName}.{typeInfo.FieldName}'. __GHOST and __COMMAND are reserved prefixes and cannot be used in namespace, type and field names!",
+ member.Locations[0]);
+ return null;
+ }
+
+ //Always reset the subtype (is not inherited)
+ typeInfo.Attribute.subtype = 0;
+ //Read the subfield if present
+ if (ghostField != null)
+ {
+ if (ghostField.Quantization >= 0) typeInfo.Attribute.quantization = ghostField.Quantization;
+ if (ghostField.Smoothing > 0) typeInfo.Attribute.smoothing = (uint)ghostField.Smoothing;
+ if (ghostField.SubType != 0) typeInfo.Attribute.subtype = ghostField.SubType;
+ // the inheritance rules say the child attribute has higher priority
+ // in particular for the composite, the rule is the follow
+ // child/parent N/A False True
+ // N/A false false true
+ // False false false true
+ // True true true true
+ if (ghostField.Composite.HasValue && !typeInfo.Attribute.aggregateChangeMask)
+ {
+ typeInfo.Attribute.aggregateChangeMask = ghostField.Composite.Value;
+ if (typeKind != GenTypeKind.Struct && typeInfo.Attribute.aggregateChangeMask)
+ {
+ m_Reporter.LogInfo($"GhostField composite set to true for primitive field '{fieldPath} {parent.TypeFullName}.{member.Name}', which will be ignored. We assume this is fine as the parent having Composite is valid.");
+ typeInfo.Attribute.aggregateChangeMask = false;
+ }
+ }
+
+ if (ghostField.MaxSmoothingDistance > 0) typeInfo.Attribute.maxSmoothingDist = ghostField.MaxSmoothingDistance;
+ }
+ //And then reset based on the mask
+ typeInfo.Attribute.smoothing &= (uint)(typeInfo.AttributeMask & TypeAttribute.AttributeFlags.InterpolatedAndExtrapolated);
+ typeInfo.Attribute.aggregateChangeMask &= (typeInfo.AttributeMask & TypeAttribute.AttributeFlags.Composite) != 0;
+
+ if((typeInfo.AttributeMask & TypeAttribute.AttributeFlags.Quantized) == 0)
+ typeInfo.Attribute.quantization = -1;
+
+ if (typeKind != GenTypeKind.Struct)
+ return typeInfo;
+
+ var members = memberType.GetMembers();
+ foreach (var f in members.OfType())
+ {
+ var path = string.IsNullOrEmpty(fieldPath)
+ ? member.Name
+ : string.Concat(fieldPath, ".", member.Name);
+
+ var field = ParseFieldType(f, f.Type, typeInfo, path,level + 1);
+ if (field != null)
+ typeInfo.GhostFields.Add(field);
+ }
+
+ //We support field member properties but with some restrictions: Only if they return primitive types
+ //- Because we don't have enough control about what kind of property we may get for a given member.
+ // ex: for float3 we don't want for example xyz and other swizzle combination be serialized.
+ //- Member like this[int index] or properties that return the same type can cause recursion.
+ //Also properties like this[] are not supported either
+ foreach (var prop in members.OfType())
+ {
+ if (!CheckIsSerializableProperty(prop))
+ continue;
+ var path = string.IsNullOrEmpty(fieldPath)
+ ? member.Name
+ : string.Concat(fieldPath, ".", member.Name);
+
+ var field = ParseFieldType(prop, prop.Type, typeInfo, path, level + 1);
+ if (field != null)
+ typeInfo.GhostFields.Add(field);
+ }
+ return typeInfo;
+ }
+
+ private bool CheckIsSerializableProperty(IPropertySymbol f)
+ {
+ string GetErrorReason()
+ {
+ //This prevent any indexer like accessor
+ if (f.IsIndexer)
+ return "Indexer.";
+ if (f.GetMethod == null)
+ return "No getter.";
+ if (f.GetMethod.DeclaredAccessibility != Accessibility.Public || f.GetMethod.IsStatic)
+ return "Getter is not public.";
+ if (f.SetMethod == null)
+ return "No setter.";
+ if (f.SetMethod.DeclaredAccessibility != Accessibility.Public || f.GetMethod.IsStatic)
+ return "Setter is not public.";
+ //I only support things that return primitive type and that are not compiler generated
+ var typeKind = Roslyn.Extensions.GetTypeKind(f.GetMethod.ReturnType);
+ if (typeKind == GenTypeKind.Invalid)
+ return "Invalid type kind.";
+ if (typeKind != GenTypeKind.Struct)
+ return null;
+ // Exception for NetworkTick, which is the only supported struct property.
+ if (Roslyn.Extensions.GetFullTypeName(f.GetMethod.ReturnType) == "Unity.NetCode.NetworkTick")
+ return null;
+ return "Property structs are not supported.";
+ }
+
+ var errorReason = GetErrorReason();
+ var isValid = string.IsNullOrEmpty(errorReason);
+ if (!isValid)
+ {
+ var ghostField = TryGetGhostField(f);
+ if (ghostField != null && ghostField.SendData)
+ {
+ m_Reporter.LogError($"GhostField present on an invalid property {f}: {errorReason}");
+ }
+ }
+ return isValid;
+ }
+
+ private bool ShouldDiscardCommandField(ISymbol symbol)
+ {
+ var attribute = Roslyn.Extensions.GetAttribute(symbol, "Unity.NetCode", "DontSerializeForCommandAttribute");
+ if (attribute != null)
+ return true;
+ //Since that can be true only for properties, I should not run this for field type
+ if (symbol is IPropertySymbol)
+ {
+ foreach (var iface in symbol.ContainingType.Interfaces)
+ {
+ var member = iface.GetMembers(symbol.Name);
+ if (member == null || member.Length == 0)
+ continue;
+ if(Roslyn.Extensions.GetAttribute(member[0], "Unity.NetCode", "DontSerializeForCommandAttribute") != null)
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ ///
+ /// Check for the presence of GhostFieldAttribute on the given field .
+ ///
+ ///
+ /// A valid instance of a GhostFieldAttribute if the annotation is present or an override exist. Null otherwise.
+ ///
+ private GhostField TryGetGhostField(ISymbol fieldSymbol)
+ {
+ var ghostField = Roslyn.Extensions.GetAttribute(fieldSymbol, "Unity.NetCode", "GhostFieldAttribute");
+ if (ghostField == null)
+ ghostField = Roslyn.Extensions.GetAttribute(fieldSymbol, "", "GhostField");
+ if (ghostField != null)
+ {
+ var fieldDescriptor = new GhostField();
+ if (ghostField.NamedArguments.Length > 0)
+ foreach (var a in ghostField.NamedArguments)
+ {
+ typeof(GhostField).GetProperty(a.Key)?.SetValue(fieldDescriptor, a.Value.Value);
+ }
+
+ return fieldDescriptor;
+ }
+
+ return default;
+ }
+ private bool CanBatchPredict(ISymbol fieldSymbol)
+ {
+ return Roslyn.Extensions.GetAttribute(fieldSymbol, "Unity.NetCode", "BatchPredictAttribute") != null;
+ }
+ }
+}
diff --git a/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/UserDefinedTemplateRegistryParser.cs b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/UserDefinedTemplateRegistryParser.cs
new file mode 100644
index 0000000..2e84c54
--- /dev/null
+++ b/Runtime/SourceGenerators/Source~/NetCodeSourceGenerator/Generators/UserDefinedTemplateRegistryParser.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Unity.NetCode.Generators
+{
+ ///
+ /// Parse the UserDefinedTemplate.RegisterTemplates partial method implementation and build a list of templates
+ /// entry used later to generate the type serialization.
+ /// String interpolation, like $"{TemplatePath}TheTemplate" or with more params supported.
+ ///
+ internal struct UserDefinedTemplateRegistryParser
+ {
+ public static List ParseTemplates(GeneratorExecutionContext context, IDiagnosticReporter reporter)
+ {
+ var templates = new List();
+ //This is only true for NetCode assembly. All the other don't have any symbols (but only metadata refs)
+ var symbol = context.Compilation.GetSymbolsWithName("UserDefinedTemplates").FirstOrDefault();
+ if (symbol != null)
+ {
+ foreach (var syntaxRef in symbol.DeclaringSyntaxReferences)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+ var method = syntaxRef.GetSyntax().DescendantNodes()
+ .OfType()
+ .FirstOrDefault(m => m.Identifier.ToString() == "RegisterTemplates");
+
+ //Get ther right reference (the one with the body)
+ if (method?.Body != null && method.Body.Statements.Count > 0)
+ {
+ ParseMethod(context, method, templates, reporter);
+ break;
+ }
+ }
+ }
+ else
+ {
+ ParseTemplatesFromMetadata(context, templates);
+ }
+ return templates;
+ }
+
+ static private void ParseTemplatesFromMetadata(GeneratorExecutionContext context, IList templates)
+ {
+ string netCode = null;
+ netCode = context.Compilation.ExternalReferences.FirstOrDefault(r =>
+ {
+ return r.Properties.Kind == MetadataImageKind.Assembly &&
+ r.Display != null && r.Display.EndsWith("Unity.NetCode.dll", StringComparison.Ordinal);
+ })?.Display;
+ if (netCode == null)
+ {
+ var netCodeRef = context.Compilation.ExternalReferences.FirstOrDefault(r =>
+ {
+ return r.Properties.Kind == MetadataImageKind.Assembly &&
+ r.Display != null && r.Display.EndsWith("Unity.NetCode.ref.dll", StringComparison.Ordinal);
+ })?.Display;
+ if (netCodeRef != null)
+ netCode = Path.Combine(Path.GetDirectoryName(netCodeRef), "Unity.NetCode.dll");
+ }
+ if (netCode == null)
+ throw new InvalidOperationException($"Cannot find Unity.NetCode metadata reference for assembly {context.Compilation.AssemblyName}");
+
+ //The dlls must be loaded in the main execution context since we need to execute the constructor code
+ var bytes = File.ReadAllBytes(netCode);
+ var assembly = Assembly.Load(bytes);
+ var type = assembly.GetType("Unity.NetCode.Generators.UserDefinedTemplates");
+ System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(type.TypeHandle);
+ var tmpl = type.GetField("Templates", BindingFlags.Static|BindingFlags.NonPublic).GetValue(null);
+ foreach (var l in (IList)tmpl)
+ {
+ TypeRegistryEntry e = new TypeRegistryEntry
+ {
+ Composite = (bool)l.GetType().GetField("Composite").GetValue(l),
+ Quantized = (bool)l.GetType().GetField("Quantized").GetValue(l),
+ SupportCommand = (bool)l.GetType().GetField("SupportCommand").GetValue(l),
+ Smoothing = (SmoothingAction)l.GetType().GetField("Smoothing").GetValue(l),
+ Template = (string)l.GetType().GetField("Template").GetValue(l),
+ TemplateOverride = (string)l.GetType().GetField("TemplateOverride").GetValue(l),
+ SubType = (int)l.GetType().GetField("SubType").GetValue(l),
+ Type = (string)l.GetType().GetField("Type").GetValue(l),
+ };
+ templates.Add(e);
+ }
+ }
+
+ static private void ParseMethod(GeneratorExecutionContext context, MethodDeclarationSyntax method, List templates, IDiagnosticReporter reporter)
+ {
+ var model = context.Compilation.GetSemanticModel(method.SyntaxTree);
+
+ var entryType = typeof(TypeRegistryEntry);
+ if (method.Body != null)
+ foreach (var s in method.Body.DescendantNodes().OfType())
+ {
+ var templatesList = s.ArgumentList.Arguments[0].Expression
+ .DescendantNodes()
+ .OfType().ToArray();
+ foreach (var template in templatesList)
+ {
+ var entry = new TypeRegistryEntry();
+ if (template.Initializer != null)
+ {
+ foreach (var e in template.Initializer.Expressions)
+ {
+ if (e is AssignmentExpressionSyntax assignment)
+ {
+ var field = ((IdentifierNameSyntax) assignment.Left).Identifier;
+ if (assignment.Right.IsKind(SyntaxKind.InterpolatedStringExpression))
+ {
+ var text = ResolveInterpolatedString(
+ assignment.Right as InterpolatedStringExpressionSyntax, model);
+ entryType.GetField(field.Text).SetValue(entry, text);
+ }
+ else
+ {
+ var text = model.GetConstantValue(assignment.Right);
+ entryType.GetField(field.Text).SetValue(entry, text.Value);
+ }
+ }
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(entry.Type))
+ {
+ reporter.LogError($"UserDefinedTemplate '{method.Identifier.SyntaxTree?.FilePath}' defines a `TypeRegistryEntry` with a missing `Type`. Cannot add it to the list of Templates. [{entry}]!");
+ continue;
+ }
+ if (string.IsNullOrWhiteSpace(entry.Template))
+ {
+ reporter.LogError($"UserDefinedTemplate '{method.Identifier.SyntaxTree?.FilePath}' defines a `TypeRegistryEntry` (Type: {entry.Type}) with a missing `Template` path. Cannot add it to the list of Templates. [{entry}]!");
+ continue;
+ }
+ templates.Add(entry);
+ }
+ }
+ }
+
+ // Resolve and return the interpolated string Don't support super complex interpolation, like with function or expression,
+ // but only the one witch use variables.
+ private static string ResolveInterpolatedString(InterpolatedStringExpressionSyntax interpolatedExpression, SemanticModel model)
+ {
+ var stringBuilder = new StringBuilder();
+ foreach(var content in interpolatedExpression.Contents)
+ {
+ if (content.Kind() == SyntaxKind.Interpolation)
+ {
+ var symbolInfo = model.GetSymbolInfo(((InterpolationSyntax) content).Expression);
+ //Just play safe here but report an error just in case.
+ if (symbolInfo.Symbol != null)
+ {
+ //assuming one declaration here
+ var fieldDeclaration = symbolInfo.Symbol.DeclaringSyntaxReferences[0].GetSyntax() as VariableDeclaratorSyntax;
+ if (fieldDeclaration != null)
+ {
+ stringBuilder.Append((fieldDeclaration.Initializer?
+ .Value as LiteralExpressionSyntax)?.Token.ValueText);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException($"Cannot resolve field declaration: {symbolInfo.ToString()}");
+ }
+ }
+ else if (content.Kind() == SyntaxKind.InterpolatedStringText)
+ {
+ stringBuilder.Append(((InterpolatedStringTextSyntax) content).TextToken.ValueText);
+ }
+ }
+ return stringBuilder.ToString();
+ }
+ }
+}
diff --git a/TestRunnerOptions.json b/TestRunnerOptions.json
new file mode 100644
index 0000000..6756dce
--- /dev/null
+++ b/TestRunnerOptions.json
@@ -0,0 +1,31 @@
+{
+ "enableGraphics": true,
+ "treatCompilationErrorsAsWarnings": true,
+ "ignoreScriptCompilationErrors":
+ [
+ {
+ "test":"Unity.NetCode.Tests.RpcTests.Rpc_MalformedPackets_ThrowsAndLogError",
+ "code":""
+ },
+ {
+ "test":"Unity.NetCode.Tests.RpcTests.SendingBeforeGettingNetworkId_Throws",
+ "code":""
+ },
+ {
+ "test":"Unity.NetCode.Tests.VersionTests.DifferentVersions_AreDisconnnected",
+ "code":""
+ },
+ {
+ "test":"Unity.NetCode.Tests.InvalidUsageTests.CanRecoverFromDeletingGhostOnClient",
+ "code":""
+ },
+ {
+ "test":"Unity.NetCode.Tests.InvalidUsageTests.UnintializedGhostOwnerThrowsException",
+ "code":""
+ },
+ {
+ "test":"Unity.NetCode.Tests.GhostCollectionStreamingTests.OnDemandLoadFailureCauseError",
+ "code":""
+ }
+ ]
+}
diff --git a/ValidationConfig.json.meta b/TestRunnerOptions.json.meta
similarity index 75%
rename from ValidationConfig.json.meta
rename to TestRunnerOptions.json.meta
index df3d9f8..cae434d 100644
--- a/ValidationConfig.json.meta
+++ b/TestRunnerOptions.json.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 2fa250c57bfb84974be45c675529975d
+guid: f596b9076837948398b9a6fd2e9a6dba
TextScriptImporter:
externalObjects: {}
userData:
diff --git a/Tests/Editor/AnalyticsTests.cs b/Tests/Editor/AnalyticsTests.cs
index 67d680a..b30d080 100644
--- a/Tests/Editor/AnalyticsTests.cs
+++ b/Tests/Editor/AnalyticsTests.cs
@@ -88,7 +88,7 @@ class FieldVerification
public void VerifyGhostConfigurationAnalyticsData()
{
var ghostConfigurationAnalyticsDataFields = typeof(GhostConfigurationAnalyticsData).GetFields();
- Assert.That(ghostConfigurationAnalyticsDataFields.Length, Is.EqualTo(7));
+ Assert.That(ghostConfigurationAnalyticsDataFields.Length, Is.EqualTo(8));
Assert.That(ghostConfigurationAnalyticsDataFields[0].Name, Is.EqualTo("id"));
Assert.That(ghostConfigurationAnalyticsDataFields[0].FieldType, Is.EqualTo(typeof(string)));
Assert.That(ghostConfigurationAnalyticsDataFields[1].Name, Is.EqualTo("ghostMode"));
@@ -103,6 +103,8 @@ public void VerifyGhostConfigurationAnalyticsData()
Assert.That(ghostConfigurationAnalyticsDataFields[5].FieldType, Is.EqualTo(typeof(int)));
Assert.That(ghostConfigurationAnalyticsDataFields[6].Name, Is.EqualTo("importance"));
Assert.That(ghostConfigurationAnalyticsDataFields[6].FieldType, Is.EqualTo(typeof(int)));
+ Assert.That(ghostConfigurationAnalyticsDataFields[7].Name, Is.EqualTo("maxSendRateHz"));
+ Assert.That(ghostConfigurationAnalyticsDataFields[7].FieldType, Is.EqualTo(typeof(int)));
}
///
diff --git a/Tests/Editor/ConnectionTests.cs b/Tests/Editor/ConnectionTests.cs
index 33fb5c4..f7a2771 100644
--- a/Tests/Editor/ConnectionTests.cs
+++ b/Tests/Editor/ConnectionTests.cs
@@ -523,6 +523,24 @@ public void DifferentVersions_AreDisconnnected([Values]DifferenceType difference
LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest(.*)\] RpcSystem received bad protocol version from NetworkConnection"));
LogAssert.Expect(LogType.Error, new Regex(@"\[ServerTest(.*)\] RpcSystem received bad protocol version from NetworkConnection"));
+ switch (differenceType)
+ {
+ case DifferenceType.GameVersion:
+ LogAssert.Expect(LogType.Error, "The Game version mismatched between remote and local. Ensure that you are using the same version of the game on both client and server.");
+ break;
+ case DifferenceType.NetCodeVersion:
+ LogAssert.Expect(LogType.Error, "The NetCode version mismatched between remote and local. Ensure that you are using the same version of Netcode for Entities on both client and server.");
+ break;
+ case DifferenceType.RpcVersion:
+ LogAssert.Expect(LogType.Error, "The RPC Collection mismatched between remote and local. Compare the following list of RPCs against the set produced by the remote, to find which RPCs are misaligned. You can also enable `RpcCollection.DynamicAssemblyList` to relax this requirement (which is recommended during development, see documentation for more details).");
+ break;
+ case DifferenceType.ComponentVersion:
+ LogAssert.Expect(LogType.Error, "The Component Collection mismatched between remote and local. Compare the following list of Components against the set produced by the remote, to find which components are misaligned. You can also enable `RpcCollection.DynamicAssemblyList` to relax this requirement (which is recommended during development, see documentation for more details).");
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(differenceType), differenceType, null);
+ }
+
// Connecting triggers the error, as it occurs during handshake.
testWorld.Connect(failTestIfConnectionFails: false);
@@ -604,6 +622,7 @@ void LogExpectProtocolError(NetCodeTestWorld testWorld, World world, bool checkS
LogAssert.Expect(LogType.Error, new Regex(@$"\[{(checkServer ? "Server" : "Client")}Test(.*)\] RpcSystem received bad protocol version from NetworkConnection\[id0,v1\]"
+ @$"\nLocal protocol: NPV\[NetCodeVersion:{NetworkProtocolVersion.k_NetCodeVersion}, GameVersion:{(checkServer ? "0" : "9000")}, RpcCollection:(\d+), ComponentCollection:(\d+)\]"
+ @$"\nRemote protocol: NPV\[NetCodeVersion:{NetworkProtocolVersion.k_NetCodeVersion}, GameVersion:{(!checkServer ? "0" : "9000")}, RpcCollection:(\d+), ComponentCollection:(\d+)\]"));
+ LogAssert.Expect(LogType.Error, "The Game version mismatched between remote and local. Ensure that you are using the same version of the game on both client and server.");
var rpcs = testWorld.GetSingleton(world).Rpcs;
Assert.AreNotEqual(0, rpcs.Length, "Sanity.");
LogAssert.Expect(LogType.Error, "RPC List (for above 'bad protocol version' error): " + rpcs.Length);
diff --git a/Tests/Editor/EditorRateManagerTests.cs b/Tests/Editor/EditorRateManagerTests.cs
index 54cd13a..ca699eb 100644
--- a/Tests/Editor/EditorRateManagerTests.cs
+++ b/Tests/Editor/EditorRateManagerTests.cs
@@ -51,6 +51,29 @@ public partial class BeforeSimulationSystemGroup : BaseCallbackSystem
public class RateManagerTests
{
+
+ [Test]
+ public void TestElapsedTimeNonNegativeAtStart()
+ {
+ const float tickDt = 1f / 60f;
+ using var testWorld = new NetCodeTestWorld(useGlobalConfig: true, initialElapsedTime: 0);
+ NetCodeConfig.Global.ClientServerTickRate.TargetFrameRateMode = ClientServerTickRate.FrameRateMode.BusyWait;
+ NetCodeConfig.Global.ClientServerTickRate.MaxSimulationStepBatchSize = 4;
+ NetCodeConfig.Global.ClientServerTickRate.MaxSimulationStepsPerFrame = 4;
+
+ testWorld.Bootstrap(includeNetCodeSystems: true);
+ testWorld.CreateWorlds(server: true, numClients: 1, tickWorldAfterCreation: false);
+
+ bool didRun = false;
+ testWorld.ServerWorld.GetExistingSystemManaged().OnUpdateCallback += world =>
+ {
+ didRun = true;
+ Assert.That(world.Time.ElapsedTime, Is.GreaterThanOrEqualTo(0), "time should always be positive");
+ };
+ testWorld.Tick(tickDt * 4f); // large dt where multiple ticks run, to see if each one has a non-negative elapsedTime
+ Assert.IsTrue(didRun, "didRun");
+ }
+
[Test]
public void RateManagerTest([Values(BusyWait, Sleep)] ClientServerTickRate.FrameRateMode frameRateMode, [Values(1, 4)] int maxBatchSize, [Values(1, 4)] int maxStepsPerFrame)
{
@@ -71,6 +94,7 @@ public void RateManagerTest([Values(BusyWait, Sleep)] ClientServerTickRate.Frame
int beforeCount = 0;
int duringCount = 0;
int afterCount = 0;
+ int initializationCount = 0;
// test setup with execute methods
TimeData beforeTime = default;
@@ -94,6 +118,13 @@ public void RateManagerTest([Values(BusyWait, Sleep)] ClientServerTickRate.Frame
afterTime = world.Time;
Assert.That(testWorld.GetNetworkTime(world).IsInPredictionLoop, Is.Not.True, "network time flag fail, after prediction");
};
+ TimeData initializationTime = default;
+ testWorld.ServerWorld.GetExistingSystemManaged().OnUpdateCallback += world =>
+ {
+ initializationCount++;
+ initializationTime = world.Time;
+ Assert.That(testWorld.GetNetworkTime(world).IsInPredictionLoop, Is.Not.True, "network time flag fail, in initialization group");
+ };
// frame is 1/4 of a tick. Expect 4 frame to 1 tick ratio. So 3 frames, then 1 frame with a tick, then 3 frames, then 1 frame with a tick
var tickDt = 1f / 60f;
@@ -114,6 +145,7 @@ void ResetTime()
beforeTime = default;
afterTime = default;
duringTime = default;
+ initializationTime = default;
}
{
@@ -125,6 +157,8 @@ void ValidateZeroCountAndDT()
Assert.That(beforeTime.DeltaTime, Is.EqualTo(0), $"beforeTime nothing, server, validating nothing ran");
Assert.That(afterTime.DeltaTime, Is.EqualTo(0), $"afterTime nothing, server, validating nothing ran");
Assert.That(duringTime.DeltaTime, Is.EqualTo(0), $"duringTime nothing, server, validating nothing ran");
+ Assert.That(initializationTime.DeltaTime, Is.EqualTo(frameDt), $"initialization group dt, validating everything is normal");
+ Assert.That(duringTime.ElapsedTime, Is.LessThanOrEqualTo(initializationTime.ElapsedTime), "elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group");
ResetTime();
}
@@ -145,6 +179,9 @@ void ValidateZeroCountAndDT()
Assert.That(beforeTime.DeltaTime, Is.EqualTo(tickDt), $"beforeTime, server");
Assert.That(afterTime.DeltaTime, Is.EqualTo(tickDt), $"afterTime, server");
Assert.That(duringTime.DeltaTime, Is.EqualTo(tickDt), $"duringTime, server");
+ Assert.That(initializationTime.DeltaTime, Is.EqualTo(frameDt), $"initialization group dt, validating everything is normal");
+ Assert.That(duringTime.ElapsedTime, Is.LessThanOrEqualTo(initializationTime.ElapsedTime), "elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group");
+ Assert.That(duringTime.ElapsedTime, Is.GreaterThan(0));
ResetTime();
for (int i = 0; i < frameCountPerTick; i++)
{
@@ -176,6 +213,74 @@ void ValidateZeroCountAndDT()
Assert.That(beforeTime.DeltaTime, Is.EqualTo(expectedDt), "batched dt, before");
Assert.That(duringTime.DeltaTime, Is.EqualTo(expectedDt), "batched dt, during");
Assert.That(afterTime.DeltaTime, Is.EqualTo(expectedDt), "batched dt, after");
+ ResetTime();
+
+ // stabilize
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(tickDt);
+ }
+
+ var epsillon = 0.0001f;
+
+ if (maxBatchSize == 1 && maxStepsPerFrame == 1)
+ {
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(tickDt);
+ Assert.That(duringTime.ElapsedTime, Is.InRange(initializationTime.ElapsedTime - tickDt - epsillon, initializationTime.ElapsedTime), $"elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group, iteration {i}");
+ ResetTime();
+ }
+
+ // harder to catch up with both max to 1, validating we still catch up when back on small frame dt
+ var bigDt = 2 * tickDt;
+ var smallDt = 0.5f * tickDt;
+ // let it fall behind
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(bigDt);
+ }
+ // let it catchup
+ for (int i = 0; i < 200; i++)
+ {
+ testWorld.Tick(smallDt);
+ }
+ Assert.That(duringTime.ElapsedTime, Is.InRange(initializationTime.ElapsedTime - tickDt - epsillon, initializationTime.ElapsedTime), $"elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group");
+ }
+ else
+ {
+ // validate there's no divergence over multiple ticks
+ var batchDt = 3f * tickDt; // smaller than the maxCount = 4 setting, so we should not fall behind
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(tickDt);
+ Assert.That(duringTime.ElapsedTime, Is.InRange(initializationTime.ElapsedTime - tickDt - epsillon, initializationTime.ElapsedTime), $"elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group, iteration {i}");
+ ResetTime();
+ }
+
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(batchDt);
+ Assert.That(duringTime.ElapsedTime, Is.InRange(initializationTime.ElapsedTime - batchDt - epsillon, initializationTime.ElapsedTime), $"elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group, iteration {i}");
+ ResetTime();
+ }
+
+ // let it fall behind
+ batchDt = 6 * tickDt; // larger than the maxCount = 4 setting, so we fall behind
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(batchDt);
+ Assert.That(duringTime.ElapsedTime, Is.LessThan(initializationTime.ElapsedTime));
+ }
+
+ // make sure we can catch up
+ for (int i = 0; i < 100; i++)
+ {
+ testWorld.Tick(tickDt);
+ }
+
+ Assert.That(duringTime.ElapsedTime, Is.InRange(initializationTime.ElapsedTime - tickDt - epsillon, initializationTime.ElapsedTime), $"elapsed time, prediction should always follow, but be behind elapsed time outside the simulation group");
+ }
}
[Test]
diff --git a/Tests/Editor/EditorRateManagerTests.cs.meta b/Tests/Editor/EditorRateManagerTests.cs.meta
index 4ca56f8..e428c81 100644
--- a/Tests/Editor/EditorRateManagerTests.cs.meta
+++ b/Tests/Editor/EditorRateManagerTests.cs.meta
@@ -1,2 +1,11 @@
fileFormatVersion: 2
-guid: 39eadb3d3db694f9ea3ca5d1fb669119
\ No newline at end of file
+guid: 39eadb3d3db694f9ea3ca5d1fb669119
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Editor/GhostCollectionStreamingTests.cs b/Tests/Editor/GhostCollectionStreamingTests.cs
index db8ea3f..814218b 100644
--- a/Tests/Editor/GhostCollectionStreamingTests.cs
+++ b/Tests/Editor/GhostCollectionStreamingTests.cs
@@ -74,15 +74,17 @@ public void OnDemandLoadedPrefabsAreUsed()
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
// Validate that the ghost was deleted on the client
Assert.AreEqual(8, ghostCount.GhostCountOnServer);
- Assert.AreEqual(0, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountReceivedOnClient);
testWorld.BakeGhostCollection(testWorld.ClientWorlds[0]);
onDemandSystem.IsLoading = false;
- for (int i = 0; i < 4; ++i)
+ for (int i = 0; i < 5; ++i)
testWorld.Tick();
// Validate that the ghost was deleted on the client
Assert.AreEqual(8, ghostCount.GhostCountOnServer);
- Assert.AreEqual(8, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(8, ghostCount.GhostCountReceivedOnClient);
+ Assert.AreEqual(8, ghostCount.GhostCountInstantiatedOnClient);
}
}
[Test]
@@ -120,7 +122,8 @@ public void OnDemandLoadFailureCauseError()
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
// Validate that the ghost was deleted on the client
Assert.AreEqual(8, ghostCount.GhostCountOnServer);
- Assert.AreEqual(0, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountReceivedOnClient);
//testWorld.ConvertGhostCollection(testWorld.ClientWorlds[0]);
onDemandSystem.IsLoading = false;
diff --git a/Tests/Editor/GhostSerializationTests.cs b/Tests/Editor/GhostSerializationTests.cs
index 93f49db..a36cf43 100644
--- a/Tests/Editor/GhostSerializationTests.cs
+++ b/Tests/Editor/GhostSerializationTests.cs
@@ -90,11 +90,13 @@ public struct GhostValueSerializer : IComponentData
[GhostField] public FixedString128Bytes StringValue128;
[GhostField] public FixedString512Bytes StringValue512;
[GhostField] public FixedString4096Bytes StringValue4096;
+ [GhostField] public NetworkTick InvalidTickValue;
+ [GhostField] public NetworkTick TickValue;
[GhostField] public Entity EntityValue;
}
public class GhostSerializationTests
{
- void VerifyGhostValues(NetCodeTestWorld testWorld)
+ static void VerifyGhostValues(NetCodeTestWorld testWorld)
{
var serverEntity = testWorld.TryGetSingletonEntity(testWorld.ServerWorld);
var clientEntity = testWorld.TryGetSingletonEntity(testWorld.ClientWorlds[0]);
@@ -104,6 +106,13 @@ void VerifyGhostValues(NetCodeTestWorld testWorld)
var serverValues = testWorld.ServerWorld.EntityManager.GetComponentData(serverEntity);
var clientValues = testWorld.ClientWorlds[0].EntityManager.GetComponentData(clientEntity);
+ Assert.AreEqual(serverEntity, serverValues.EntityValue);
+ Assert.AreEqual(clientEntity, clientValues.EntityValue);
+ VerifyGhostValues(serverValues, clientValues);
+ }
+
+ static void VerifyGhostValues(GhostValueSerializer serverValues, GhostValueSerializer clientValues)
+ {
Assert.AreEqual(serverValues.BoolValue, clientValues.BoolValue);
Assert.AreEqual(serverValues.IntValue, clientValues.IntValue);
Assert.AreEqual(serverValues.UIntValue, clientValues.UIntValue);
@@ -138,15 +147,20 @@ void VerifyGhostValues(NetCodeTestWorld testWorld)
Assert.AreEqual(serverValues.StringValue128, clientValues.StringValue128);
Assert.AreEqual(serverValues.StringValue512, clientValues.StringValue512);
Assert.AreEqual(serverValues.StringValue4096, clientValues.StringValue4096);
-
- Assert.AreEqual(serverEntity, serverValues.EntityValue);
- Assert.AreEqual(clientEntity, clientValues.EntityValue);
+ Assert.AreEqual(serverValues.InvalidTickValue, clientValues.InvalidTickValue);
+ Assert.AreEqual(serverValues.TickValue, clientValues.TickValue);
}
- void SetGhostValues(NetCodeTestWorld testWorld, int baseValue)
+
+ void SetGhostValuesOnServer(NetCodeTestWorld testWorld, int baseValue)
{
var serverEntity = testWorld.TryGetSingletonEntity(testWorld.ServerWorld);
Assert.AreNotEqual(Entity.Null, serverEntity);
- testWorld.ServerWorld.EntityManager.SetComponentData(serverEntity, new GhostValueSerializer
+ testWorld.ServerWorld.EntityManager.SetComponentData(serverEntity, CreateGhostValues(baseValue, serverEntity));
+ }
+
+ private static GhostValueSerializer CreateGhostValues(int baseValue, Entity serverEntity)
+ {
+ return new GhostValueSerializer
{
BoolValue = (baseValue&1) != 0,
IntValue = baseValue,
@@ -182,9 +196,10 @@ void SetGhostValues(NetCodeTestWorld testWorld, int baseValue)
StringValue128 = new FixedString128Bytes($"baseValue = {baseValue*3}"),
StringValue512 = new FixedString512Bytes($"baseValue = {baseValue*4}"),
StringValue4096 = new FixedString4096Bytes($"baseValue = {baseValue*5}"),
-
+ InvalidTickValue = NetworkTick.Invalid,
+ TickValue = new NetworkTick((uint) baseValue),
EntityValue = serverEntity
- });
+ };
}
void SetLargeGhostValues(NetCodeTestWorld testWorld, string baseValue, int size)
@@ -260,31 +275,20 @@ public void GhostValuesAreSerialized()
using (var testWorld = new NetCodeTestWorld())
{
testWorld.Bootstrap(true);
-
var ghostGameObject = new GameObject();
ghostGameObject.AddComponent().Converter = new GhostValueSerializerConverter();
-
Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject));
-
testWorld.CreateWorlds(true, 1);
-
testWorld.SpawnOnServer(ghostGameObject);
- SetGhostValues(testWorld, 42);
-
- // Connect and make sure the connection could be established
+ SetGhostValuesOnServer(testWorld, 42);
testWorld.Connect();
-
- // 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 < 64; ++i)
- testWorld.Tick();
+ testWorld.TickUntilClientsHaveAllGhosts();
VerifyGhostValues(testWorld);
- SetGhostValues(testWorld, 43);
+ SetGhostValuesOnServer(testWorld, 43);
- for (int i = 0; i < 64; ++i)
+ for (int i = 0; i < 8; ++i)
testWorld.Tick();
// Assert that replicated version is correct
@@ -292,37 +296,97 @@ public void GhostValuesAreSerialized()
}
}
[Test]
+ public void GhostValuesAreSerialized_RespectsMaxSendRate([Values(1, 20, 100)]byte sendRate)
+ {
+ using var testWorld = new NetCodeTestWorld();
+ testWorld.Bootstrap(true);
+ var ghostGameObject = new GameObject($"Ghost_MaxSendRate_{sendRate}");
+ var config = ghostGameObject.AddComponent();
+ config.MaxSendRate = sendRate;
+ // Use predicted to always get latest values:
+ config.SupportedGhostModes = GhostModeMask.Predicted;
+ config.DefaultGhostMode = GhostMode.Predicted;
+ ghostGameObject.AddComponent().Converter = new GhostValueSerializerConverter();
+ Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject));
+ testWorld.CreateWorlds(true, 1);
+ testWorld.SpawnOnServer(ghostGameObject);
+ SetGhostValuesOnServer(testWorld, 0);
+ testWorld.Connect();
+ testWorld.GoInGame();
+ testWorld.TickUntilClientsHaveAllGhosts();
+ var firstSpawn = NetCodeTestWorld.TickIndex;
+
+ // Replicate changes over N frames.
+ var serverValues = new NativeList<(int tick, GhostValueSerializer val)>(64, Allocator.Temp);
+ var clientValues = new NativeList<(int tick, GhostValueSerializer val)>(64, Allocator.Temp);
+ Assert.IsTrue(AddIfChanged(serverValues, 0, testWorld.ServerWorld));
+ Assert.IsTrue(AddIfChanged(clientValues, 0, testWorld.ClientWorlds[0]));
+ const int numTicks = 25;
+ for (int i = 1; i < numTicks; ++i)
+ {
+ SetGhostValuesOnServer(testWorld, i);
+ testWorld.Tick();
+ AddIfChanged(serverValues, i, testWorld.ServerWorld);
+ AddIfChanged(clientValues, i, testWorld.ClientWorlds[0]);
+ }
+ Debug.Log($"firstSpawn:{firstSpawn} ticks, serverValues.Length:{serverValues.Length} vs clientValues.Length:{clientValues.Length}");
+ Assert.That(serverValues.Length, Is.EqualTo(numTicks), "Sanity!");
+ var numClientValues = clientValues.Length;
+ switch (sendRate)
+ {
+ case 1:
+ Assert.That(numClientValues, Is.EqualTo(1));
+ break;
+ case 20:
+ Assert.That(numClientValues, Is.EqualTo(9));
+ break;
+ case 100:
+ Assert.That(numClientValues, Is.EqualTo(numTicks));
+ break;
+ default: throw new ArgumentOutOfRangeException(nameof(sendRate), sendRate, null);
+ }
+
+ // Verify each entry:
+ for (int i = 0; i < clientValues.Length; i++)
+ {
+ var (tick, val) = clientValues[i];
+ VerifyGhostValues(serverValues[tick].val, val);
+ }
+
+ unsafe bool AddIfChanged(NativeList<(int tick, GhostValueSerializer val)> list, int tick, World world)
+ {
+ var previous = list.IsEmpty ? default : list[list.Length - 1];
+ var current = testWorld.GetSingleton(world);
+ var memCmp = UnsafeUtility.MemCmp(¤t, &previous.val, UnsafeUtility.SizeOf());
+ //UnityEngine.Debug.Log($" - TestWorld[{NetCodeTestWorld.TickIndex}] iteration:{tick} = previous:{previous.val.IntValue}, current:{current.IntValue} = memCmp:{memCmp} ");
+ if (list.IsEmpty || memCmp != 0)
+ {
+ list.Add((tick, current));
+ return true;
+ }
+ return false;
+ }
+ }
+ [Test]
public void GhostValuesAreSerialized_WithPacketDumpsEnabled()
{
using (var testWorld = new NetCodeTestWorld())
{
testWorld.DebugPackets = true;
testWorld.Bootstrap(true);
-
var ghostGameObject = new GameObject();
ghostGameObject.AddComponent().Converter = new GhostValueSerializerConverter();
-
Assert.IsTrue(testWorld.CreateGhostCollection(ghostGameObject));
-
testWorld.CreateWorlds(true, 1);
-
testWorld.SpawnOnServer(ghostGameObject);
- SetGhostValues(testWorld, 42);
-
- // Connect and make sure the connection could be established
+ SetGhostValuesOnServer(testWorld, 42);
testWorld.Connect();
-
- // 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 < 64; ++i)
- testWorld.Tick();
-
+ testWorld.TickUntilClientsHaveAllGhosts();
VerifyGhostValues(testWorld);
- SetGhostValues(testWorld, 43);
+ SetGhostValuesOnServer(testWorld, 43);
- for (int i = 0; i < 64; ++i)
+ for (int i = 0; i < 8; ++i)
testWorld.Tick();
// Assert that replicated version is correct
@@ -501,7 +565,8 @@ public void ManyEntitiesCanBeDespawnedSameTick()
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
- Assert.AreEqual(10000, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(10000, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(10000, ghostCount.GhostCountReceivedOnClient);
testWorld.ServerWorld.EntityManager.DestroyEntity(entities);
@@ -509,7 +574,8 @@ public void ManyEntitiesCanBeDespawnedSameTick()
testWorld.Tick();
// Assert that replicated version is correct
- Assert.AreEqual(0, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountReceivedOnClient);
}
}
}
@@ -552,7 +618,7 @@ public void SnapshotAckMaskIsReportedCorrectlyByTheClient()
{
currentServerTick.Decrement();
Assert.AreEqual(currentServerTick.TickIndexForValidTick, serverAck.LastReceivedSnapshotByRemote.TickIndexForValidTick);
- serverAck.IsReceivedByRemote(currentServerTick);
+ serverAck.IsReceivedByRemote(currentServerTick); // TODO - This is missing an assert?
}
}
lastReceivedFromClient = serverAck.LastReceivedSnapshotByLocal;
diff --git a/Tests/Editor/LateJoinCompletionTests.cs b/Tests/Editor/LateJoinCompletionTests.cs
index 29693b4..fcb9e16 100644
--- a/Tests/Editor/LateJoinCompletionTests.cs
+++ b/Tests/Editor/LateJoinCompletionTests.cs
@@ -45,7 +45,7 @@ public void ServerGhostCountIsVisibleOnClient()
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
// Validate that the ghost was deleted on the cliet
Assert.AreEqual(8, ghostCount.GhostCountOnServer);
- Assert.AreEqual(8, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(8, ghostCount.GhostCountReceivedOnClient);
// Spawn a few more and verify taht the count is updated
for (int i = 0; i < 8; ++i)
@@ -53,7 +53,7 @@ public void ServerGhostCountIsVisibleOnClient()
for (int i = 0; i < 4; ++i)
testWorld.Tick();
Assert.AreEqual(16, ghostCount.GhostCountOnServer);
- Assert.AreEqual(16, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(16, ghostCount.GhostCountReceivedOnClient);
}
}
[Test]
@@ -99,7 +99,7 @@ public void ServerGhostCountOnlyIncludesRelevantSet()
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
// Validate that the ghost was deleted on the cliet
Assert.AreEqual(6, ghostCount.GhostCountOnServer);
- Assert.AreEqual(6, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(6, ghostCount.GhostCountReceivedOnClient);
// Spawn a few more and verify taht the count is updated
for (int i = 0; i < 8; ++i)
@@ -107,7 +107,7 @@ public void ServerGhostCountOnlyIncludesRelevantSet()
for (int i = 0; i < 4; ++i)
testWorld.Tick();
Assert.AreEqual(6, ghostCount.GhostCountOnServer);
- Assert.AreEqual(6, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(6, ghostCount.GhostCountReceivedOnClient);
}
}
[Test]
@@ -153,7 +153,7 @@ public void ServerGhostCountDoesNotIncludeIrrelevantSet()
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
// Validate that the ghost was deleted on the cliet
Assert.AreEqual(2, ghostCount.GhostCountOnServer);
- Assert.AreEqual(2, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(2, ghostCount.GhostCountReceivedOnClient);
// Spawn a few more and verify taht the count is updated
for (int i = 0; i < 8; ++i)
@@ -161,7 +161,7 @@ public void ServerGhostCountDoesNotIncludeIrrelevantSet()
for (int i = 0; i < 4; ++i)
testWorld.Tick();
Assert.AreEqual(10, ghostCount.GhostCountOnServer);
- Assert.AreEqual(10, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(10, ghostCount.GhostCountReceivedOnClient);
}
}
}
diff --git a/Tests/Editor/Physics/PhysicsLoopConfigurationTests.cs b/Tests/Editor/Physics/PhysicsLoopConfigurationTests.cs
new file mode 100644
index 0000000..43e00a4
--- /dev/null
+++ b/Tests/Editor/Physics/PhysicsLoopConfigurationTests.cs
@@ -0,0 +1,142 @@
+using System;
+using NUnit.Framework;
+using Unity.Entities;
+using Unity.NetCode.Tests;
+using Unity.Physics.Systems;
+using UnityEngine;
+
+namespace Unity.NetCode.Physics.Tests
+{
+ [DisableAutoCreation]
+ [UpdateInGroup(typeof(PhysicsSimulationGroup))]
+ partial class PhysicCheck : SystemBase
+ {
+ public NetworkTick lastTick;
+ protected override void OnUpdate()
+ {
+ lastTick = SystemAPI.GetSingleton().ServerTick;
+ }
+ }
+
+ public class PhysicsLoopConfigurationTests
+ {
+ public enum PhysicsRunMode
+ {
+ RequirePredictedGhost = 0,
+ EnableLagCompensation = 1,
+ RequirePhysicsEntities = 2,
+ AlwaysRun = 3,
+ }
+
+ [Test]
+ public void EnablePhysicToRunWithoutPredictedGhosts([Values]PredictionLoopUpdateMode loopMode,
+ [Values]PhysicsRunMode physicsRunMode)
+ {
+ using (var testWorld = new NetCodeTestWorld())
+ {
+ testWorld.TestSpecificAdditionalAssemblies.Add("Unity.NetCode.Physics,");
+ testWorld.TestSpecificAdditionalAssemblies.Add("Unity.Physics,");
+ testWorld.Bootstrap(true, typeof(PhysicCheck));
+
+ //Static ghost
+ var cubeGameObject = new GameObject();
+ cubeGameObject.name = "StaticGeo";
+ cubeGameObject.isStatic = true;
+ cubeGameObject.AddComponent().size = new Vector3(1,1,1);
+
+ Assert.IsTrue(testWorld.CreateGhostCollection(cubeGameObject));
+ testWorld.CreateWorlds(true, 1);
+
+ if (loopMode == PredictionLoopUpdateMode.AlwaysRun)
+ {
+ var clientTickRate = testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(ClientTickRate));
+ var tickRate = NetworkTimeSystem.DefaultClientTickRate;
+ tickRate.PredictionLoopUpdateMode = PredictionLoopUpdateMode.AlwaysRun;
+ testWorld.ClientWorlds[0].EntityManager.SetComponentData(clientTickRate, tickRate);
+ }
+
+ if (physicsRunMode == PhysicsRunMode.EnableLagCompensation)
+ {
+ //for client we need to set the history size
+ testWorld.ClientWorlds[0].EntityManager.AddComponentData(testWorld.ClientWorlds[0].EntityManager.CreateEntity(), new LagCompensationConfig
+ {
+ ServerHistorySize = 0,
+ ClientHistorySize = 1
+ });
+ testWorld.ServerWorld.EntityManager.AddComponentData(testWorld.ServerWorld.EntityManager.CreateEntity(), new LagCompensationConfig());
+ }
+ else if(physicsRunMode != PhysicsRunMode.RequirePredictedGhost)
+ {
+ var clientConfig = testWorld.ClientWorlds[0].EntityManager.CreateEntity(typeof(PhysicsGroupConfig));
+ testWorld.ClientWorlds[0].EntityManager.SetComponentData(clientConfig, new PhysicsGroupConfig
+ {
+ PhysicsRunMode = physicsRunMode == PhysicsRunMode.RequirePhysicsEntities ? PhysicGroupRunMode.LagCompensationEnabledOrAnyPhysicsEntities : PhysicGroupRunMode.AlwaysRun
+ });
+ var serverConfig = testWorld.ServerWorld.EntityManager.CreateEntity(typeof(PhysicsGroupConfig));
+ testWorld.ServerWorld.EntityManager.SetComponentData(serverConfig, new PhysicsGroupConfig
+ {
+ PhysicsRunMode = physicsRunMode == PhysicsRunMode.RequirePhysicsEntities ? PhysicGroupRunMode.LagCompensationEnabledOrAnyPhysicsEntities : PhysicGroupRunMode.AlwaysRun
+ });
+ }
+
+ testWorld.Connect();
+ testWorld.GoInGame();
+ //TODO we can add more coverage but the logic itself is simple enough to no justify adding more combinations
+ //create the non replicated world static geometry entity.
+ testWorld.SpawnOnServer(0);
+ //var dynamicEnt = testWorld.SpawnOnServer(1);
+ for(int i=0;i<64;++i)
+ testWorld.Tick();
+
+ //On the server the loopMode setting does not matter.
+ if (physicsRunMode == PhysicsRunMode.EnableLagCompensation)
+ {
+ Assert.IsTrue(testWorld.ServerWorld.GetExistingSystemManaged().lastTick ==
+ testWorld.GetNetworkTime(testWorld.ServerWorld).ServerTick);
+ Assert.IsTrue(testWorld.GetSingleton(testWorld.ServerWorld).LatestStoredTick.IsValid, "history must be recorded on the server, even without ghost");
+ }
+ if (physicsRunMode == PhysicsRunMode.RequirePredictedGhost)
+ {
+ Assert.IsFalse(testWorld.ServerWorld.GetExistingSystemManaged().lastTick.IsValid);
+ }
+ else
+ {
+ Assert.IsTrue(testWorld.ServerWorld.GetExistingSystemManaged().lastTick == testWorld.GetNetworkTime(testWorld.ServerWorld).ServerTick);
+ }
+ //On the client if the loopMode is set to RunOnlyWhenPredictedGhostArePresent, the prediction loop does not run
+ //in case no predicted ghost is present. And so, no history should be recorded, nor physics loop run, nor prediction has run
+ var clientNetworkTime = testWorld.GetNetworkTime(testWorld.ClientWorlds[0]);
+ if (loopMode == PredictionLoopUpdateMode.RequirePredictedGhost)
+ {
+ Assert.AreEqual(0,clientNetworkTime.PredictedTickIndex);
+ Assert.IsFalse(testWorld.GetSingleton(testWorld.ClientWorlds[0]).LatestStoredTick.IsValid, "history should not be recorded without ghost because prediction loop does not run");
+ // no need to test further conditions
+ return;
+ }
+ //if the loopMode is set to AlwaysRun, the prediction loop should have run
+ Assert.Greater(clientNetworkTime.PredictedTickIndex, 0);
+
+ // when lag compensation is set, physics however run only once, for firsttimepredicted tick condition only, that it is partially
+ // incorrect in case we have high-frequency physics loop, because physics should be able to run also for partial ticks in case
+ // on the client. But, does it make sense running the physics loop in that case, if there is nothing to actually predict? (everything
+ // is kinematic and driven by server). Looks to me no, so the behaviour seems fine.
+ var time = testWorld.GetNetworkTime(testWorld.ClientWorlds[0]);
+ if(time.IsPartialTick)
+ time.ServerTick.Decrement();
+ if (physicsRunMode == PhysicsRunMode.EnableLagCompensation)
+ {
+ Assert.IsTrue(testWorld.GetSingleton(testWorld.ClientWorlds[0]).LatestStoredTick.IsValid, "history should be recorded when lag compensation is enabled");
+ Assert.IsTrue(testWorld.ClientWorlds[0].GetExistingSystemManaged().lastTick == time.ServerTick);
+ }
+ else if(physicsRunMode == PhysicsRunMode.RequirePredictedGhost)
+ {
+ Assert.IsFalse(testWorld.ClientWorlds[0].GetExistingSystemManaged().lastTick == time.ServerTick);
+ }
+ else
+ {
+ Assert.IsTrue(testWorld.ClientWorlds[0].GetExistingSystemManaged().lastTick == time.ServerTick);
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/Editor/Physics/PhysicsLoopConfigurationTests.cs.meta b/Tests/Editor/Physics/PhysicsLoopConfigurationTests.cs.meta
new file mode 100644
index 0000000..d908ffc
--- /dev/null
+++ b/Tests/Editor/Physics/PhysicsLoopConfigurationTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 325d2509a5a44bf9a704b4a91f4a8de6
+timeCreated: 1718216348
\ No newline at end of file
diff --git a/Tests/Editor/Physics/PhysicsRateManagerTests.cs b/Tests/Editor/Physics/PhysicsRateManagerTests.cs
new file mode 100644
index 0000000..beb0435
--- /dev/null
+++ b/Tests/Editor/Physics/PhysicsRateManagerTests.cs
@@ -0,0 +1,130 @@
+using NUnit.Framework;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode.Tests;
+using Unity.Physics.GraphicsIntegration;
+using Unity.Physics.Systems;
+using UnityEngine;
+
+namespace Unity.NetCode.Physics.Tests
+{
+ [DisableAutoCreation]
+ [UpdateInGroup(typeof(AfterPhysicsSystemGroup))]
+ partial class CheckPhysicsRunOnPartial : SystemBase
+ {
+ public int numPartialTickUpdates;
+ public int numFullTickUpdates;
+ public NetworkTick firstTick;
+ protected override void OnUpdate()
+ {
+ var time = SystemAPI.GetSingleton();
+ Assert.IsFalse(World.IsServer() && time.IsPartialTick);
+ if (World.IsServer())
+ {
+ Assert.IsTrue(time.IsFirstTimeFullyPredictingTick);
+ }
+ if (time.IsPartialTick)
+ ++numPartialTickUpdates;
+ else if (time.IsFirstTimeFullyPredictingTick)
+ {
+ if (firstTick == default)
+ firstTick = time.ServerTick;
+ ++numFullTickUpdates;
+ }
+
+ }
+ }
+ [DisableAutoCreation]
+ [UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]
+ [UpdateAfter(typeof(PredictedSimulationSystemGroup))]
+ partial struct CheckRecordedTime : ISystem
+ {
+ private double elapsedTime;
+ private double lastRecordedTime;
+ private NetworkTick lastTick;
+ void OnUpdate(ref SystemState state)
+ {
+ var time = SystemAPI.GetSingleton();
+ var currentElapsedTime = SystemAPI.Time.ElapsedTime;
+ var deltaTime = SystemAPI.Time.DeltaTime;
+ var rateManager = (NetcodePredictionFixedRateManager)state.World.GetExistingSystemManaged().RateManager;
+ Assert.IsTrue(deltaTime >= rateManager.Timestep);
+ var recordedTime = SystemAPI.GetSingletonBuffer()[0];
+ Assert.GreaterOrEqual(currentElapsedTime, elapsedTime);
+ Assert.GreaterOrEqual(recordedTime.ElapsedTime, lastRecordedTime);
+ Assert.GreaterOrEqual(currentElapsedTime, lastRecordedTime);
+ elapsedTime = currentElapsedTime;
+ lastRecordedTime = recordedTime.ElapsedTime;
+ }
+ }
+
+ public class RateManagerTests
+ {
+ [TestCase(60, 60)]
+ [TestCase(60, 120)]
+ [TestCase(60, 180)]
+ [TestCase(30, 30)]
+ [TestCase(30, 90)]
+ [TestCase(30, 120)]
+ public void PartialTicksFixedStepUpdate_ReportCorrectElapsedTime(int simulationTickRate, int physicsTickRate)
+ {
+ using var testWorld = new NetCodeTestWorld();
+ testWorld.TestSpecificAdditionalAssemblies.Add("Unity.NetCode.Physics,");
+ testWorld.TestSpecificAdditionalAssemblies.Add("Unity.Physics,");
+ testWorld.Bootstrap(true, typeof(CheckPhysicsRunOnPartial));
+
+ var cubeGameObject = new GameObject();
+ var authoringComponent = cubeGameObject.AddComponent();
+ authoringComponent.SupportedGhostModes = GhostModeMask.Predicted;
+ cubeGameObject.name = "Predicted";
+ cubeGameObject.isStatic = false;
+ var rb = cubeGameObject.AddComponent();
+ rb.useGravity = false;
+ cubeGameObject.AddComponent().size = new Vector3(1,1,1);
+
+ Assert.IsTrue(testWorld.CreateGhostCollection(cubeGameObject));
+ testWorld.CreateWorlds(true, 1);
+ SetupTickRate(testWorld, simulationTickRate, physicsTickRate);
+ testWorld.Connect();
+ testWorld.GoInGame();
+
+ for (int i = 0; i < 128; ++i)
+ testWorld.Tick();
+
+ testWorld.SpawnOnServer(0);
+
+ var serverTime0 = testWorld.GetNetworkTime(testWorld.ServerWorld);
+ var clienTime0 = testWorld.GetNetworkTime(testWorld.ClientWorlds[0]);
+ var deltaTime = 1f / 60 / 4;
+ for (int i = 0; i < 128; ++i)
+ testWorld.Tick(deltaTime);
+
+ var serverTime1 = testWorld.GetNetworkTime(testWorld.ServerWorld);
+ var clienTime1 = testWorld.GetNetworkTime(testWorld.ClientWorlds[0]);
+ var physicsFullTicks = serverTime1.ServerTick.TicksSince(serverTime0.ServerTick) * physicsTickRate/simulationTickRate;
+ var runOnPartial = testWorld.ServerWorld.GetExistingSystemManaged();
+ Assert.AreEqual(0, runOnPartial.numPartialTickUpdates);
+ Assert.AreEqual(physicsFullTicks, runOnPartial.numFullTickUpdates);
+ //On the client side, the number of ticks can be slighty higher because of accumulated time for partial ticks and catchup.
+ runOnPartial = testWorld.ClientWorlds[0].GetExistingSystemManaged();
+ physicsFullTicks = clienTime1.ServerTick.TicksSince(runOnPartial.firstTick);
+ physicsFullTicks *= physicsTickRate/simulationTickRate;
+ Assert.AreEqual(physicsFullTicks, runOnPartial.numFullTickUpdates);
+ if(physicsTickRate > simulationTickRate)
+ Assert.AreNotEqual(0, runOnPartial.numPartialTickUpdates);
+ else
+ Assert.AreEqual(0, runOnPartial.numPartialTickUpdates);
+
+ }
+
+ private void SetupTickRate(NetCodeTestWorld testWorld, int simulation, int physics)
+ {
+ var tickRateEntity = testWorld.ServerWorld.EntityManager.CreateEntity(typeof(ClientServerTickRate));
+ var tickRate = new ClientServerTickRate();
+ tickRate.SimulationTickRate = simulation;
+ tickRate.PredictedFixedStepSimulationTickRatio = physics/simulation;
+ tickRate.ResolveDefaults();
+ testWorld.ServerWorld.EntityManager.SetComponentData(tickRateEntity, tickRate);
+ }
+ }
+}
diff --git a/Tests/Editor/Physics/PhysicsRateManagerTests.cs.meta b/Tests/Editor/Physics/PhysicsRateManagerTests.cs.meta
new file mode 100644
index 0000000..4e2bfde
--- /dev/null
+++ b/Tests/Editor/Physics/PhysicsRateManagerTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 9b9be66056c143cca7d2d7a3e2e04ee3
+timeCreated: 1723567611
\ No newline at end of file
diff --git a/Tests/Editor/PredictionTests.cs b/Tests/Editor/PredictionTests.cs
index 434a0d5..4200e45 100644
--- a/Tests/Editor/PredictionTests.cs
+++ b/Tests/Editor/PredictionTests.cs
@@ -152,18 +152,26 @@ protected override void OnUpdate()
[UpdateInGroup(typeof(PredictedFixedStepSimulationSystemGroup))]
partial struct CheckElapsedTime : ISystem
{
- private double ElapsedTime;
+ private double SinceFirstUpdate;
+ private double LastElapsedTime;
public void OnUpdate(ref SystemState state)
{
var timestep = state.World.GetExistingSystemManaged().Timestep;
var time = SystemAPI.Time;
- if (ElapsedTime == 0.0)
+ if (SinceFirstUpdate == 0.0)
{
- ElapsedTime = time.ElapsedTime;
+ SinceFirstUpdate = time.ElapsedTime;
}
- var totalElapsed = math.fmod(time.ElapsedTime - ElapsedTime, timestep);
+ Assert.GreaterOrEqual(time.ElapsedTime, LastElapsedTime);
//the elapsed time must be always an integral multiple of the time step
- Assert.LessOrEqual(totalElapsed, 1e-6);
+ Assert.LessOrEqual(math.fmod(time.ElapsedTime, timestep), 1e-6);
+ //the relative elapsed time since last update should also be equal to the timestep. If the timestep is changed
+ //before the last update, this may be not true
+ var totalElapsedSinceFirstUpdate = math.fmod(time.ElapsedTime - SinceFirstUpdate, timestep);
+ var elapsedTimeSinceLastUpdate = math.fmod(time.ElapsedTime - LastElapsedTime, timestep);
+ Assert.LessOrEqual(elapsedTimeSinceLastUpdate, 1e-6);
+ Assert.LessOrEqual(totalElapsedSinceFirstUpdate, 1e-6);
+ LastElapsedTime = time.ElapsedTime;
}
}
@@ -451,8 +459,8 @@ public void NetcodeClientPredictionRateManager_WillWarnWhenMismatchSimulationTic
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));
+ Assert.That(serverTimeStep, Is.EqualTo(clientRate.SimulationFixedTimeStep));
+ Assert.That(clientTimestep, Is.EqualTo(clientRate.SimulationFixedTimeStep));
//Also check that if the value is overriden, it is still correctly set to the right value
for (int i = 0; i < 8; ++i)
@@ -468,8 +476,8 @@ public void NetcodeClientPredictionRateManager_WillWarnWhenMismatchSimulationTic
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));
+ Assert.That(clientTimestep, Is.EqualTo(clientRate.SimulationFixedTimeStep));
+ Assert.That(serverTimeStep, Is.EqualTo(clientRate.SimulationFixedTimeStep));
}
}
}
@@ -483,6 +491,11 @@ public void PredictedFixedStepSimulation_ElapsedTimeReportedCorrectly(int ratio)
{
testWorld.Bootstrap(true, typeof(CheckElapsedTime));
testWorld.CreateWorlds(true, 1);
+
+ //tick the world before connecting or finalizing the setup to mimic the fact the values has been changed by users
+ //after the world creation later on.
+ for(int i=0;i<10;++i)
+ testWorld.Tick();
var tickRate = testWorld.ServerWorld.EntityManager.CreateEntity(typeof(ClientServerTickRate));
testWorld.ServerWorld.EntityManager.SetComponentData(tickRate, new ClientServerTickRate
{
diff --git a/Tests/Editor/RelevancyTests.cs b/Tests/Editor/RelevancyTests.cs
index 356bd41..458ef5b 100644
--- a/Tests/Editor/RelevancyTests.cs
+++ b/Tests/Editor/RelevancyTests.cs
@@ -595,7 +595,8 @@ public void ManyEntitiesCanBecomeIrrelevantSameTick()
testWorld.Tick();
var ghostCount = testWorld.GetSingleton(testWorld.ClientWorlds[0]);
- Assert.AreEqual(10000, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(10000, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(10000, ghostCount.GhostCountReceivedOnClient);
// Make all 10 000 ghosts irrelevant
ref var ghostRelevancy = ref testWorld.GetSingletonRW(testWorld.ServerWorld).ValueRW;
@@ -605,7 +606,8 @@ public void ManyEntitiesCanBecomeIrrelevantSameTick()
testWorld.Tick();
// Assert that replicated version is correct
- Assert.AreEqual(0, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountReceivedOnClient);
testWorld.ServerWorld.EntityManager.DestroyEntity(entities);
@@ -613,7 +615,8 @@ public void ManyEntitiesCanBecomeIrrelevantSameTick()
testWorld.Tick();
// Assert that replicated version is correct
- Assert.AreEqual(0, ghostCount.GhostCountOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountInstantiatedOnClient);
+ Assert.AreEqual(0, ghostCount.GhostCountReceivedOnClient);
}
}
}
diff --git a/Tests/Editor/RpcTests.cs b/Tests/Editor/RpcTests.cs
index 45ca668..de7f923 100644
--- a/Tests/Editor/RpcTests.cs
+++ b/Tests/Editor/RpcTests.cs
@@ -707,7 +707,7 @@ public void Rpc_WarnIfSendingApprovalRpcWithoutApprovalRequired([Values]bool sup
client.EntityManager.AddComponent(rpcEntity);
if(!suppressWarning)
- LogAssert.Expect(LogType.Warning, new Regex(@"\[ClientTest0(.*)\] Sending approval RPC '(.*)' to Entity\(\d*\:\d*\) \('NetworkConnection(.*)\) but connection approval is disabled\."));
+ LogAssert.Expect(LogType.Warning, new Regex(@"\[ClientTest0(.*)\] Sending approval RPC '(.*)' to the server but connection approval is disabled"));
testWorld.Tick();
LogAssert.NoUnexpectedReceived();
}
@@ -743,8 +743,7 @@ public void Rpc_WarnIfSendingBeforeConnectionEstablished([Values]bool useApprova
client.EntityManager.AddComponent(rpcEntity);
testWorld.Tick();
- LogAssert.Expect(LogType.Warning, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' with no remote connection"));
-
+ LogAssert.Expect(LogType.Warning, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to the server as not connected"));
// Start connection setup for next phase of tests
var ep = NetworkEndpoint.LoopbackIpv4.WithPort(7979);
testWorld.GetSingletonRW(testWorld.ServerWorld).ValueRW.Listen(ep);
@@ -765,7 +764,7 @@ public void Rpc_WarnIfSendingBeforeConnectionEstablished([Values]bool useApprova
client.EntityManager.AddComponentData(rpcEntity, rpcData);
client.EntityManager.AddComponent(rpcEntity);
- LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send non-approval RPC '(.*)' to Entity\(\d*\:\d*\) as NetworkConnection(.*) is in state `Handshake`"));
+ LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to the server as it is not an Approval RPC, and its NetworkConnection(.*) - on Entity(.*) - is in state `Handshake`"));
testWorld.Tick();
// Now with a target connection instead of broadcast
@@ -775,7 +774,7 @@ public void Rpc_WarnIfSendingBeforeConnectionEstablished([Values]bool useApprova
client.EntityManager.AddComponentData(rpcEntity, rpcData);
client.EntityManager.AddComponentData(rpcEntity, new SendRpcCommandRequest(){TargetConnection = clientConnectionToServer});
- LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send non-approval RPC '(.*)' to Entity\(\d*\:\d*\) as NetworkConnection(.*) is in state `Handshake`"));
+ LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to the server as it is not an Approval RPC, and its NetworkConnection(.*) - on Entity(.*) - is in state `Handshake`"));
testWorld.Tick();
// Disconnect to invalidate the connection entity
@@ -794,8 +793,7 @@ public void Rpc_WarnIfSendingBeforeConnectionEstablished([Values]bool useApprova
testWorld.Tick();
// Connection attempt is ongoing but NetworkId not received yet
- LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to Entity\(\d*\:\d*\) as NetworkConnection(.*) is in state `Connecting`"));
-
+ LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to the server as its NetworkConnection(.*) - on Entity(.*) - is in state `Connecting`"));
// Verify the connection did finish
Assert.AreNotEqual(Entity.Null, testWorld.TryGetSingletonEntity(client));
@@ -814,7 +812,7 @@ public void Rpc_WarnIfSendingBeforeConnectionEstablished([Values]bool useApprova
for (int i = 0; i < 5; ++i)
testWorld.Tick();
- LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to Entity\(\d*\:\d*\) as NetworkConnection(.*) is in state `Connecting`"));
+ LogAssert.Expect(LogType.Error, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to the server as its NetworkConnection(.*) - on Entity(.*) - is in state `Connecting`"));
Assert.AreNotEqual(Entity.Null, testWorld.TryGetSingletonEntity(client));
}
@@ -823,7 +821,7 @@ public void Rpc_WarnIfSendingBeforeConnectionEstablished([Values]bool useApprova
client.EntityManager.AddComponentData(rpcEntity, rpcData);
client.EntityManager.AddComponentData(rpcEntity, new SendRpcCommandRequest(){TargetConnection = connectionEntity});
- LogAssert.Expect(LogType.Warning, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to Entity\(\d*\:\d*\) as it does not have a `NetworkStreamConnection` entity"));
+ LogAssert.Expect(LogType.Warning, new Regex(@"\[ClientTest0(.*)\] Cannot send RPC '(.*)' to the server as its connection entity \(Entity(.*)\) does not have a `NetworkStreamConnection` or `OutgoingRpcDataStreamBuffer` component"));
testWorld.Tick();
}
}
diff --git a/Tests/Utils/NetCodeTestWorld.cs b/Tests/Utils/NetCodeTestWorld.cs
index 79ae726..690b43e 100644
--- a/Tests/Utils/NetCodeTestWorld.cs
+++ b/Tests/Utils/NetCodeTestWorld.cs
@@ -88,6 +88,8 @@ public class NetCodeTestWorld : IDisposable, INetworkStreamDriverConstructor
public int DriverFuzzOffset = 0;
public uint DriverRandomSeed = 0;
+ private bool m_IsFirstTimeTicking = true;
+
#if UNITY_EDITOR
private List m_GhostCollection;
private BlobAssetStore m_BlobAssetStore;
@@ -106,7 +108,7 @@ private void SetupNetDebugConfig(World world)
});
}
- public NetCodeTestWorld(bool useGlobalConfig=false)
+ public NetCodeTestWorld(bool useGlobalConfig=false, double initialElapsedTime = 42)
{
#if UNITY_EDITOR
@@ -122,7 +124,7 @@ public NetCodeTestWorld(bool useGlobalConfig=false)
m_OldBootstrapAutoConnectPort = ClientServerBootstrap.AutoConnectPort;
ClientServerBootstrap.AutoConnectPort = 0;
m_DefaultWorld = new World("NetCodeTest");
- m_ElapsedTime = 42;
+ m_ElapsedTime = initialElapsedTime;
TickIndex = -1;
NetworkTimeSystem.ResetFixedTime();
}
@@ -459,6 +461,12 @@ public void Tick(float dt = 1f / 60f)
{
++TickIndex;
//Debug.Log($"[{TickIndex}]: TICK");
+ if (m_IsFirstTimeTicking)
+ {
+ // to emulate time system's logic
+ m_IsFirstTimeTicking = false;
+ m_ElapsedTime = -dt;
+ }
// Use fixed timestep in network time system to prevent time dependencies in tests
NetworkTimeSystem.s_FixedTimestampMS += (uint) (dt * 1000.0f);
@@ -609,6 +617,8 @@ public void CreateClientDriver(World world, ref NetworkDriverStore driverStore,
.WithNetworkConfigParameters
(
maxFrameTimeMS: 100,
+ sendQueueCapacity: 16,
+ receiveQueueCapacity: 64,
fixedFrameTimeMS: DriverFixedTime,
maxMessageSize: DriverMaxMessageSize
);
@@ -657,15 +667,6 @@ public static int CalculateWorldId(World world)
return int.Parse(match.Groups[2].Value);
}
- static int QueueSizeFromPlayerCount(int playerCount)
- {
- if (playerCount <= 16)
- {
- playerCount = 16;
- }
- return playerCount * 4;
- }
-
public void CreateServerDriver(World world, ref NetworkDriverStore driverStore, NetDebug netDebug)
{
var networkSettings = new NetworkSettings();
@@ -674,8 +675,8 @@ public void CreateServerDriver(World world, ref NetworkDriverStore driverStore,
.WithNetworkConfigParameters(
maxFrameTimeMS: 100,
fixedFrameTimeMS: DriverFixedTime,
- receiveQueueCapacity: QueueSizeFromPlayerCount(m_NumClients),
- sendQueueCapacity: QueueSizeFromPlayerCount(m_NumClients),
+ sendQueueCapacity: 16,
+ receiveQueueCapacity: 64,
maxMessageSize: DriverMaxMessageSize
);
var driverInstance = new NetworkDriverStore.NetworkDriverInstance();
@@ -974,5 +975,38 @@ public void SetDynamicAssemblyList(bool useDynamicAssemblyList)
foreach (var clientWorld in ClientWorlds)
GetSingletonRW(clientWorld).ValueRW.DynamicAssemblyList = useDynamicAssemblyList;
}
+
+ public void TickUntilClientsHaveAllGhosts(int maxTicks = 64)
+ {
+ World clientWorld = default;
+ GhostCount ghostCount = default;
+ Assert.IsTrue(ClientWorlds.Length > 0, "Sanity");
+ for (int tickIdx = 0; tickIdx < maxTicks; ++tickIdx)
+ {
+ Tick();
+ for (var worldIdx = 0; worldIdx < ClientWorlds.Length; worldIdx++)
+ {
+ clientWorld = ClientWorlds[worldIdx];
+ ghostCount = GetSingleton(clientWorld);
+ var clientHasAll = ghostCount.GhostCountOnServer != 0 && ghostCount.GhostCountInstantiatedOnClient == ghostCount.GhostCountOnServer;
+ ValidateGhostCount(clientWorld, ghostCount);
+ if (!clientHasAll)
+ goto continueContinue;
+ }
+ return;
+ continueContinue:;
+ }
+ Assert.Fail($"TickUntilClientsHaveAllGhosts failed after {maxTicks} ticks! {clientWorld.Name} has {ghostCount.ToFixedString()}!");
+ }
+
+ public static void ValidateGhostCount(World clientWorld, GhostCount ghostCount)
+ {
+ using var receivedGhostCount = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly());
+ Assert.AreEqual(receivedGhostCount.CalculateEntityCount(), ghostCount.GhostCountReceivedOnClient, $"GhostCount.GhostCountReceivedOnClient struct does not match ghost received count on {clientWorld.Name}!");
+ using var instantiatedGhostCount = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude());
+ var instancedCount = instantiatedGhostCount.CalculateEntityCount();
+ //if(instancedCount > 0) UnityEngine.Debug.Log($"{instancedCount} vs {ghostCount} = {clientWorld.EntityManager.GetChunk(instantiatedGhostCount.ToEntityArray(Allocator.Temp)[0]).Archetype}");
+ Assert.AreEqual(instancedCount, ghostCount.GhostCountInstantiatedOnClient, $"GhostCount.GhostCountInstantiatedOnClient struct does not match ghost instance count on {clientWorld.Name}!");
+ }
}
}
diff --git a/Tests/Utils/NetcodeBitArrayExtensionTests.cs b/Tests/Utils/NetcodeBitArrayExtensionTests.cs
new file mode 100644
index 0000000..9ef2124
--- /dev/null
+++ b/Tests/Utils/NetcodeBitArrayExtensionTests.cs
@@ -0,0 +1,126 @@
+using System;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Collections.LowLevel.Unsafe;
+using Unity.Mathematics;
+using Assert = Unity.Assertions.Assert;
+
+namespace Unity.NetCode.Tests
+{
+ public class NetcodeBitArrayExtensionTests
+ {
+ [Test]
+ public unsafe void UnsafeBitArray_ShiftLeftRightExt([Values] bool up)
+ {
+ const int numBits = 512;
+ using var testBitArray = new UnsafeBitArray(numBits, Allocator.Persistent);
+ ref var test = ref UnsafeUtility.AsRef(&testBitArray);
+ Assert.IsTrue(test.TestNone(0, numBits));
+ test.Set(up ? 0 : numBits - 1, true);
+ for (int i = 0; i < numBits; i++)
+ {
+ try
+ {
+ Assert.IsTrue(test.IsSet(up ? i : (numBits - 1) - i), i.ToString());
+ Assert.AreEqual(1, test.CountBits(0, numBits), i.ToString());
+ if (up) test.ShiftLeftExt(1);
+ else test.ShiftRightExt(1);
+ }
+ catch (Exception)
+ {
+ UnityEngine.Debug.LogError($"Exception at idx {i}, {test.ToDecimalFixedStringExt()}\"");
+ throw;
+ }
+ }
+
+ Assert.AreEqual(0, test.CountBits(0, numBits), "Should be no more true bits as they should have been shifted off the ends!");
+ }
+
+ [Test]
+ public unsafe void UnsafeBitArray_FindLastSetBitExt()
+ {
+ TestOnLength(129);
+ TestOnLength(128);
+ TestOnLength(127);
+ TestOnLength(1);
+
+ // Test zero:
+ var testBitArray = new UnsafeBitArray(0, Allocator.Temp);
+ ref var test = ref UnsafeUtility.AsRef(&testBitArray);
+ Assert.AreEqual(-1, test.FindLastSetBitExt(), "BitArray of size ZERO should return -1 for FindLastSetBit!");
+
+ static void TestOnLength(int bitArrayLength)
+ {
+ var testBitArray = new UnsafeBitArray(bitArrayLength, Allocator.Temp);
+ ref var test = ref UnsafeUtility.AsRef(&testBitArray);
+ Assert.IsTrue(test.TestNone(0, bitArrayLength), $"TestOnLength[{bitArrayLength}] All bits should START as zero! {test.ToDecimalFixedStringExt()}");
+ Assert.AreEqual(-1, test.FindLastSetBitExt(), $"TestOnLength[{bitArrayLength}] FindLastSetBit should be -1 as there should be ZERO true bits! {test.ToDecimalFixedStringExt()}");
+ // Set the lowest bit true, so that we can get a false positive.
+ test.Set(0, true);
+
+ // Set and test every other index (individually).
+ for (int indexToSet = 1; indexToSet < test.Length; indexToSet++)
+ {
+ test.Set(indexToSet, true);
+ Assert.AreEqual(2, test.CountBits(0, bitArrayLength), $"TestOnLength[{bitArrayLength},{indexToSet}] UnsafeBitArray.CountBits {test.ToDecimalFixedStringExt()}");
+ var indexOfLastTrueBit = test.FindLastSetBitExt();
+ Assert.AreEqual(indexToSet, indexOfLastTrueBit, $"TestOnLength[{bitArrayLength},{indexToSet}] UnsafeBitArray.FindLastSetBit {test.ToDecimalFixedStringExt()}");
+ test.Set(indexToSet, false);
+ }
+ }
+ }
+
+ [Test]
+ public unsafe void UnsafeBitArray_ToDecimalFixedStringExt()
+ {
+ const int numBits = 128;
+ var testBitArray = new UnsafeBitArray(numBits, Allocator.Temp);
+ ref var test = ref UnsafeUtility.AsRef(&testBitArray);
+ Assert.IsTrue(test.TestNone(0, numBits));
+ const ulong constant = 15950305135099; // 11101000000110111000010001011000100111111011
+ const int trueBits = 22;
+ const int idxOfLastTrue = 43;
+ FixedString4096Bytes zero = "BitArray[bits:128,len:2ul,num1s:0,last1:-1,ZEROS][00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|]";
+ TestAndAssertShift(ref test, -100, zero);
+ TestAndAssertShift(ref test, -44, zero);
+ TestAndAssertShift(ref test, -1, "BitArray[bits:128,len:2ul,num1s:21,last1:42][10111111_00100011_01000100_00111011_00000010_11100000_00000000_00000000|00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|]");
+ TestAndAssertShift(ref test, 0, "BitArray[bits:128,len:2ul,num1s:22,last1:43][11011111_10010001_10100010_00011101_10000001_01110000_00000000_00000000|00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|]");
+ TestAndAssertShift(ref test, 1, "BitArray[bits:128,len:2ul,num1s:22,last1:44][01101111_11001000_11010001_00001110_11000000_10111000_00000000_00000000|00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|]");
+ TestAndAssertShift(ref test, 63, "BitArray[bits:128,len:2ul,num1s:22,last1:106][00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000001|10111111_00100011_01000100_00111011_00000010_11100000_00000000_00000000|]");
+ TestAndAssertShift(ref test, 64, "BitArray[bits:128,len:2ul,num1s:22,last1:107][00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|11011111_10010001_10100010_00011101_10000001_01110000_00000000_00000000|]");
+ TestAndAssertShift(ref test, 65, "BitArray[bits:128,len:2ul,num1s:22,last1:108][00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|01101111_11001000_11010001_00001110_11000000_10111000_00000000_00000000|]");
+ TestAndAssertShift(ref test, 66, "BitArray[bits:128,len:2ul,num1s:22,last1:109][00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|00110111_11100100_01101000_10000111_01100000_01011100_00000000_00000000|]");
+ TestAndAssertShift(ref test, 127, "BitArray[bits:128,len:2ul,num1s:1,last1:127][00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000|00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000001|]");
+ TestAndAssertShift(ref test, 128, zero);
+ TestAndAssertShift(ref test, 129, zero);
+ TestAndAssertShift(ref test, int.MaxValue, zero);
+
+ // Test all bits being "ONES":
+ test.SetBits(0, true, test.Length);
+ Assert.AreEqual("BitArray[bits:128,len:2ul,num1s:128,last1:127,ONES][11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111|11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111|]", test.ToDecimalFixedStringExt().ToString());
+
+ void TestAndAssertShift(ref UnsafeBitArray test, int shiftDistance, FixedString4096Bytes expectedResult)
+ {
+ // Reset:
+ test.Clear();
+ test.SetBits(0, constant, 64);
+ Assert.AreEqual(trueBits, test.CountBits(0, numBits));
+ Assert.AreEqual(constant, test.GetBits(0, 64));
+ Assert.AreEqual(idxOfLastTrue, test.FindLastSetBitExt());
+
+ // Test:
+ if(shiftDistance < 0) test.ShiftRightExt(math.abs(shiftDistance));
+ else test.ShiftLeftExt(shiftDistance);
+ try
+ {
+ Assert.AreEqual(expectedResult.ToString(), test.ToDecimalFixedStringExt().ToString());
+ }
+ catch (Exception e)
+ {
+ UnityEngine.Debug.LogError($"expect:{expectedResult}\nactual:{test.ToDecimalFixedStringExt()} ShiftUp({shiftDistance})");
+ UnityEngine.Debug.LogException(e);
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/Utils/NetcodeBitArrayExtensionTests.cs.meta b/Tests/Utils/NetcodeBitArrayExtensionTests.cs.meta
new file mode 100644
index 0000000..e94db3b
--- /dev/null
+++ b/Tests/Utils/NetcodeBitArrayExtensionTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 72b1c02d28cf40c191414f1896e29a5d
+timeCreated: 1727712250
\ No newline at end of file
diff --git a/Tests/Utils/NetcodeTransformUsageFlagsTestAuthoring.cs b/Tests/Utils/NetcodeTransformUsageFlagsTestAuthoring.cs
index 7a99997..a6dd4be 100644
--- a/Tests/Utils/NetcodeTransformUsageFlagsTestAuthoring.cs
+++ b/Tests/Utils/NetcodeTransformUsageFlagsTestAuthoring.cs
@@ -3,10 +3,20 @@
using UnityEngine;
using Unity.Entities;
+///
+/// NetcodeTransformUsageFlagsTestAuthoring
+///
public class NetcodeTransformUsageFlagsTestAuthoring : MonoBehaviour
{
+ ///
+ /// Baker for NetcodeTransformUsageFlagsTestAuthoring
+ ///
public class Baker : Baker
{
+ ///
+ /// Baker function
+ ///
+ /// Authoring instance
public override void Bake(NetcodeTransformUsageFlagsTestAuthoring authoring)
{
AddTransformUsageFlags(TransformUsageFlags.Dynamic);
diff --git a/Tests/Utils/TestNetCodeAuthoring.cs b/Tests/Utils/TestNetCodeAuthoring.cs
index 1e8a7b3..0bd735f 100644
--- a/Tests/Utils/TestNetCodeAuthoring.cs
+++ b/Tests/Utils/TestNetCodeAuthoring.cs
@@ -1,12 +1,26 @@
using UnityEngine;
using Unity.Entities;
+///
+/// TestNetCodeAuthoring
+///
public class TestNetCodeAuthoring : MonoBehaviour
{
+ ///
+ /// Interface for TestNetCodeAuthoring.IConverter
+ ///
public interface IConverter
{
+ ///
+ /// Bake function
+ ///
+ /// gameobject
+ /// baker
void Bake(GameObject gameObject, IBaker baker);
}
+ ///
+ /// IConverter
+ ///
public IConverter Converter;
}
diff --git a/ValidationConfig.json b/ValidationConfig.json
deleted file mode 100644
index d82b7b6..0000000
--- a/ValidationConfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "FilenameValidation":
- {
- "Filenames":
- [
- {
- "Filename": "Runtime/Snapshot/SwitchPredictionSmoothingSystem.cs*",
- "Targets": "+Switch"
- }
- ]
- }
-}
diff --git a/ValidationExceptions.json b/ValidationExceptions.json
deleted file mode 100644
index fcd78b3..0000000
--- a/ValidationExceptions.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "ErrorExceptions": [
- {
- "ValidationTest": "API Validation",
- "ExceptionMessage": "Additions require a new minor or major version.",
- "PackageVersion": "1.3.6"
- },
- {
- "ValidationTest": "API Validation",
- "ExceptionMessage": "New assembly \"Unity.NetCode.TestsUtils\" may only be added in a new minor or major version.",
- "PackageVersion": "1.3.6"
- }
- ],
- "WarningExceptions": []
-}
diff --git a/package.json b/package.json
index ef3dfcc..54e571f 100644
--- a/package.json
+++ b/package.json
@@ -1,25 +1 @@
-{
- "name": "com.unity.netcode",
- "displayName": "Netcode for Entities",
- "version": "1.3.6",
- "unity": "2022.3",
- "unityRelease": "11f1",
- "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.2.1",
- "com.unity.entities": "1.3.5",
- "com.unity.modules.animation": "1.0.0"
- },
- "_upm": {
- "changelog": "### Changed\n\n* Improved XML document for `NetworkStreamDriver.ConnectionEventsForTick`.\n* Updated entities packages dependencies\n\n### Fixed\n\n* an issue with netcode source generated files, causing multiple Burst.CompileAsync invocation, ending up in stalling the editor and the player for long time, and / or causing crashes.\n* Issue where `OverrideAutomaticNetcodeBootstrap` instances in scenes would be ignored in the Editor if 'Fast Enter Play-Mode Options' is disabled (i.e. when domain reloads triggered after clicking to enter play-mode).\n* Longstanding API documentation errors across Netcode for Entities API documentation."
- },
- "upmCi": {
- "footprint": "dd892a7e8ce8bd7bb9afdeceaee9a4d43457c0f7"
- },
- "documentationUrl": "https://docs.unity3d.com/Packages/com.unity.netcode@1.3/manual/index.html",
- "repository": {
- "url": "https://github.cds.internal.unity3d.com/unity/dots.git",
- "type": "git",
- "revision": "29c020e0888603e080b55e22043f284ba3bff81d"
- }
-}
+{"name":"com.unity.netcode","displayName":"Netcode for Entities","version":"1.4.0","unity":"2022.3","unityRelease":"11f1","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.4.0","com.unity.entities":"1.3.5","com.unity.modules.animation":"1.0.0"},"repository":{"revision":"8aaa313aaabde7873e7ebbceac4dd9b705072d6c","type":"git","url":"https://github.cds.internal.unity3d.com/unity/dots.git"},"documentationUrl":"https://docs.unity3d.com/Packages/com.unity.netcode@1.4/manual/index.html","_upm":{"changelog":"### Added\n\n* A togglable warning to display when the server is batching ticks.\n* PhysicGroupRunMode property to the NetcodePhysicsConfigAuthoring to let the user configure when the predicted physics loop should run.\n* PredictionLoopUpdateMode property to the ClientTickRate to let the user configure when the PredictionSimulationSystemGroup should update. In particular, it is allow now to have the prediction loop running all the time, regardless of the presence of predicted ghost.\n* `GhostSendSystemData.MaxIterateChunks`, which denotes the maximum number of chunks the `GhostSendSystem` will iterate over in a single tick, for a given connection, within a single `NetworkTickRate` snapshot send interval. It's an optimization in use-cases where you have many thousands of static ghosts (and thus hundreds of static chunks which are iterated over unnecessarily to find ones containing possible changes), but can lead to empty snapshots if set too low. Pairs well with `MaxSendChunks`, and defaults to 0 (OFF) to avoid a behaviour change.\n* Many Unity Transport Package `NetworkConfigParameters` have been added to the `NetCodeConfig`. They are ignored if using a custom driver, unless said driver calls the new static method `DefaultDriverBuilder.AddNetcodePackageNetworkConfigParameters`.\n* `ClientServerTickRate.SnapshotAckMaskCapacity` configures the length of the ack mask history (in `ServerTicks`). It is used by the snapshot system to determine whether or not a ghost has an acked baseline snapshot, and only queried when said chunk is attempting to be resent. Its new default (of 4096, up from 256) supports ~1.1 minutes (up from ~4.26 seconds) under default settings (i.e. assuming a `SimulationTickRate` of 60Hz). Increasing this value further can protect against the aforementioned snapshot acking errors when sending tens of thousands of ghosts to an individual client connection.\n* `GhostAuthoringComponent.MaxSendRate`, which denotes the maximum possible send frequency (in Hz) for ghost chunks of this ghost prefab type. Note, however, that other factors (like `NetworkTickRate`, ghost instance count, the use of Static-Optimization vs Dynamic, `Importance`, Importance-Scaling, `DefaultSnapshotPacketSize` etc.) will determine the final send rate. Use `MaxSendRate` to brute-force reduce the bandwidth consumption of your most impactful ghost types.\n* `GhostCountInstantiatedOnClient` and `GhostCountReceivedOnClient` to the `GhostCount` struct to differentiate ghosts which we have only received the data for, from fully instantiated ghosts (i.e. ghosts with entities). See deprecation entry and `PendingSpawnPlaceholder`.\n* The `AutomaticThinClientWorldsUtility` class, which facilitates runtime creation (and management) of thin clients. It is available to user-code, and when in `PlayType.Server`.\n\n### Changed\n\n* The error for `NetworkProtocolVersion` mismatches will now better indicate what exactly went wrong, and what steps can be taken to resolve the error.\n* Incremental UI improvement to the `MultiplayerPlayModeWindow` netcode worlds display. The server now lists ghost counts (details in tooltip), the client `GhostCount` singleton is now available via hovering over the ping tooltip (as it's often something you want to know), and the `DriverStore` drivers are now displayed consistently.\n* Re-enabled disabled LoadScenes_AllScenesShouldConnect and LoadScenes_NoScenesShouldLog tests randomly failing that were failing because of the CommandSendSystemGroup issue.\n* **Behaviour Breaking Change:** `GhostSendSystemData.MaxSendChunks` no longer limits the max number of chunks to iterate over (i.e. query) - unless `GhostSendSystemData.MaxIterateChunks` is zero - as it no longer counts cancelled chunk snapshot writes towards its total. Therefore, use `GhostSendSystemData.MaxIterateChunks` instead to denote that limit. This should lead to fewer emptier packets, particularly when used in conjunction with many static and irrelevant ghosts.\n* **API & Behaviour Breaking Change:** The netcode package `DefaultDriverConstructor` will now defau"}}
\ No newline at end of file
diff --git a/pvpExceptions.json b/pvpExceptions.json
new file mode 100644
index 0000000..c3186f7
--- /dev/null
+++ b/pvpExceptions.json
@@ -0,0 +1,857 @@
+{
+ "exempts": {
+ "PVP-20-1": {
+ "errors": [
+ "Missing on method System.Void Unity.NetCode.CommandReceiveSystem::OnCreate(SystemState)",
+ "Missing on method System.Void Unity.NetCode.CommandSendSystem::OnCreate(SystemState)",
+ "Missing on method System.Void Unity.NetCode.HeartbeatReceiveSystem::OnUpdate(SystemState)",
+ "Missing on method System.Void Unity.NetCode.HeartbeatReplySystem::OnUpdate(SystemState)",
+ "Missing on method System.Void Unity.NetCode.HeartbeatSendSystem::OnUpdate(SystemState)",
+ "Missing on method System.Void Unity.NetCode.RpcCommandRequest::OnCreate(SystemState)",
+ "Missing on non-void method System.Int32 Unity.NetCode.DefaultVariantSystemBase.Rule::GetHashCode()",
+ "Missing Doc on Generated.GhostSnapshotData",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__SpawnTick",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__W",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__W",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__X",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__X",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__Y",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__Y",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__Z",
+ "Missing Doc on Generated.GhostSnapshotData.Snapshot.__GHOST_FIELD_NAME__Z",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CalculateChangeMask(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyFromSnapshot(GhostDeserializerState, Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, System.Single, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::CopyToSnapshot(GhostSerializerState, Generated.GhostSnapshotData.Snapshot, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamReader, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Deserialize(System.UInt32, Generated.GhostSnapshotData, DataStreamReader, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, RpcDeserializerState, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::DeserializeCommand(DataStreamReader, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::PredictDelta(System.UInt32, Generated.GhostSnapshotData, Generated.GhostSnapshotData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::RestoreFromBackup(IComponentData, IComponentData)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(Generated.GhostSnapshotData.Snapshot, Generated.GhostSnapshotData.Snapshot, DataStreamWriter, StreamCompressionModel, System.UInt32)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::Serialize(System.Int32, Generated.GhostSnapshotData, DataStreamWriter, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, RpcSerializerState, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Generated.GhostSnapshotData::SerializeCommand(DataStreamWriter, IComponentData, IComponentData, StreamCompressionModel)",
+ "Missing Doc on System.Void Unity.NetCode.ApplyCurrentInputBufferElementToInputDataSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ApplyCurrentInputBufferElementToInputDataSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ClientPopulatePrespawnedGhostsSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ClientPopulatePrespawnedGhostsSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ClientPopulatePrespawnedGhostsSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ClientTrackLoadedPrespawnSections::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ClientTrackLoadedPrespawnSections::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ClientTrackLoadedPrespawnSections::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.CommandSendSystemGroup::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.CommandSendSystemGroup::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.CopyInputToCommandBufferSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.CopyInputToCommandBufferSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.DefaultVariantSystemBase::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.DefaultVariantSystemBase::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.DefaultVariantSystemGroup::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.DriverMigrationSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.DriverMigrationSystem::OnDestroy()",
+ "Missing Doc on System.Void Unity.NetCode.DriverMigrationSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.Editor.PrespawnedGhostPreprocessScene::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.Editor.PrespawnedGhostPreprocessScene::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.Editor.PrespawnedGhostPreprocessScene::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostCollectionSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostCollectionSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostCollectionSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostComponentSerializerCollectionSystemGroup::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.GhostComponentSerializerCollectionSystemGroup::OnDestroy()",
+ "Missing Doc on System.Void Unity.NetCode.GhostDespawnSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostDespawnSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostDespawnSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostDistancePartitioningSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostDistancePartitioningSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostDistancePartitioningSystem::OnStartRunning(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostDistancePartitioningSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionHistorySystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionHistorySystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionHistorySystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionSmoothingSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionSmoothingSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionSmoothingSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionSwitchingSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionSwitchingSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostPredictionSwitchingSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostReceiveSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostReceiveSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostReceiveSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSendSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSendSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSendSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSpawnClassificationSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSpawnClassificationSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSpawnClassificationSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSpawnSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSpawnSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostSpawnSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostUpdateSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostUpdateSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.GhostUpdateSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.HeartbeatReceiveSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.HeartbeatReceiveSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.HeartbeatReplySystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.HeartbeatReplySystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.HeartbeatSendSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.HeartbeatSendSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostAnimationControllerInterpolationSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostAnimationControllerInterpolationSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostAnimationControllerPredictionSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostAnimationControllerPredictionSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostAnimationControllerServerSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostAnimationControllerServerSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostPresentationGameObjectSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostPresentationGameObjectSystem::OnDestroy()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostPresentationGameObjectSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostPresentationGameObjectTransformSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.Hybrid.GhostPresentationGameObjectTransformSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.NetDebugSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetDebugSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetDebugSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkGroupCommandBufferSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamConnectSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamConnectSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamConnectSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamListenSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamListenSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamListenSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamReceiveSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamReceiveSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.NetworkStreamReceiveSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.OwnerSwichingSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.OwnerSwichingSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.OwnerSwichingSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PhysicsDefaultVariantSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.PhysicsDefaultVariantSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.PhysicsWorldHistory::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PhysicsWorldHistory::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PhysicsWorldHistory::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PredictedFixedStepSimulationSystemGroup::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.PredictedFixedStepSimulationSystemGroup::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.PredictedGhostSpawnSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PredictedGhostSpawnSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PredictedGhostSpawnSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PredictedPhysicsConfigSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.PredictedPhysicsValidationSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PredictedPhysicsValidationSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.PredictedPhysicsValidationSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.RpcCommandRequestSystemGroup::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.RpcCommandRequestSystemGroup::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.RpcSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.RpcSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.RpcSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.RpcSystemErrors::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.RpcSystemErrors::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.RpcSystemErrors::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ServerPopulatePrespawnedGhostsSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ServerPopulatePrespawnedGhostsSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ServerPopulatePrespawnedGhostsSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ServerTrackLoadedPrespawnSections::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ServerTrackLoadedPrespawnSections::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.ServerTrackLoadedPrespawnSections::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.SwitchPredictionSmoothingPhysicsOrderingSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.SwitchPredictionSmoothingPhysicsOrderingSystem::OnUpdate()",
+ "Missing Doc on System.Void Unity.NetCode.SwitchPredictionSmoothingSystem::OnCreate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.SwitchPredictionSmoothingSystem::OnDestroy(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.SwitchPredictionSmoothingSystem::OnUpdate(SystemState)",
+ "Missing Doc on System.Void Unity.NetCode.TransformDefaultVariantSystem::OnCreate()",
+ "Missing Doc on System.Void Unity.NetCode.TransformDefaultVariantSystem::OnUpdate()",
+ "Missing Doc on System.Void __COMMAND_NAMESPACE__.__COMMAND_NAME__InputBufferData::DecrementEventsAndAssignToInput(System.IntPtr, System.IntPtr)",
+ "Missing Doc on System.Void __COMMAND_NAMESPACE__.__COMMAND_NAME__InputBufferData::IncrementEventsAndSetCurrentInputData(System.IntPtr, System.IntPtr)",
+ "Missing Doc on System.Void __GHOST_NAMESPACE__.GhostComponentSerializerRegistrationSystem::OnCreate()",
+ "Missing Doc on System.Void __GHOST_NAMESPACE__.GhostComponentSerializerRegistrationSystem::OnUpdate()",
+ "Missing Doc on System.Void __GHOST_NAMESPACE__.__REGISTRATION_SYSTEM_FILE_NAME__::OnCreate(Unity.Entities.SystemState)",
+ "Missing Doc on System.Void __GHOST_NAMESPACE__.__REGISTRATION_SYSTEM_FILE_NAME__::OnDestroy(Unity.Entities.SystemState)",
+ "Missing Doc on System.Void __GHOST_NAMESPACE__.__REGISTRATION_SYSTEM_FILE_NAME__::OnUpdate(Unity.Entities.SystemState)",
+ "Missing Doc on __COMMAND_NAMESPACE__.__COMMAND_NAME__InputBufferData",
+ "Missing Doc on __COMMAND_NAMESPACE__.__COMMAND_NAME__InputBufferData.InternalInput",
+ "Missing Doc on __COMMAND_NAMESPACE__.__COMMAND_NAME__InputBufferData.Tick",
+ "Missing Doc on __GHOST_NAMESPACE__.GhostComponentSerializerRegistrationSystem",
+ "Missing Doc on __GHOST_NAMESPACE__.__REGISTRATION_SYSTEM_FILE_NAME__",
+ "Missing Doc on static Unity.NetCode.SnapshotPacketLossStatistics Unity.NetCode.SnapshotPacketLossStatistics::op_Addition(Unity.NetCode.SnapshotPacketLossStatistics, Unity.NetCode.SnapshotPacketLossStatistics)",
+ "Missing Doc on static Unity.NetCode.SnapshotPacketLossStatistics Unity.NetCode.SnapshotPacketLossStatistics::op_Subtraction(Unity.NetCode.SnapshotPacketLossStatistics, Unity.NetCode.SnapshotPacketLossStatistics)"
+ ]
+ },
+ "PVP-25-1": {
+ "errors": [
+ "Runtime/SourceGenerators/NetCodeSourceGenerator.dll",
+ "Runtime/Stats/netdbg.js"
+ ]
+ },
+ "PVP-41-1": {
+ "errors": [
+ "CHANGELOG.md: line 5: Unreleased section is not allowed for public release"
+ ]
+ },
+ "PVP-90-2": {
+ "all": true
+ },
+ "PVP-91-3": {
+ "all": true
+ },
+ "PVP-92-3": {
+ "all": true
+ },
+ "PVP-150-1": {
+ "errors": [
+ "Unity.NetCode.ApplyCurrentInputBufferElementToInputData.ApplyInputDataFromBufferJob: void Execute(ArchetypeChunk, int): empty tag",
+ "Unity.NetCode.ApplyCurrentInputBufferElementToInputData: ApplyCurrentInputBufferElementToInputData.ApplyInputDataFromBufferJob InitJobData(ref SystemState): empty tag",
+ "Unity.NetCode.ApplyCurrentInputBufferElementToInputData: empty tag",
+ "Unity.NetCode.ApplyCurrentInputBufferElementToInputDataSystem: empty tag",
+ "Unity.NetCode.ApplyInputDataFromBufferJob: empty tag",
+ "Unity.NetCode.ApplyInputDataFromBufferJob: void Execute(in ArchetypeChunk, int, bool, in v128): empty tag",
+ "Unity.NetCode.BufferSerializationHelper: bool SetupFunctionPointers(ref State, ref SystemState): in block context (only allowed in top-level context)",
+ "Unity.NetCode.BufferSerializationHelper: bool SetupFunctionPointers(ref State, ref SystemState): empty tag",
+ "Unity.NetCode.ClientPopulatePrespawnedGhostsSystem: in inline context (only allowed in block context)",
+ "Unity.NetCode.ClientPopulatePrespawnedGhostsSystem: in block context; use instead",
+ "Unity.NetCode.ClientServerBootstrap.PlayType: ClientAndServer: mixed block and inline content in ; wrap inline content in ",
+ "Unity.NetCode.ClientServerBootstrap: HasClientWorlds: in block context (only allowed in top-level context)",
+ "Unity.NetCode.ClientServerBootstrap: HasServerWorld: in block context (only allowed in top-level context)",
+ "Unity.NetCode.ClientServerBootstrap: WillServerAutoListen: mixed block and inline content in ; wrap inline content in ",
+ "Unity.NetCode.ClientServerBootstrap: World CreateClientWorld(string): empty tag",
+ "Unity.NetCode.ClientServerBootstrap: World CreateLocalWorld(string): in block context (only allowed in top-level context)",
+ "Unity.NetCode.ClientServerBootstrap: World CreateLocalWorld(string): in block context (only allowed in top-level context)",
+ "Unity.NetCode.ClientServerBootstrap: World CreateServerWorld(string): empty tag",
+ "Unity.NetCode.ClientServerBootstrap: World CreateThinClientWorld(): empty