From 3e4378a80be3571e352909557d71d2dab3933015 Mon Sep 17 00:00:00 2001 From: metalgearsloth Date: Fri, 17 Jul 2026 14:22:32 +1000 Subject: [PATCH 1/4] Only re-run dirty components on PVS re-entry Unfortunately we still need to run transform and friends so it's not a huge benefit but if there's any expensive state handling on client we can skip it at least. --- .../GameStates/ClientGameStateManager.cs | 32 +++- .../GameStates/PvsReEntryTest.cs | 144 ++++++++++++++++++ 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs index 9ef5161534e..2cedb2f0c7d 100644 --- a/Robust.Client/GameStates/ClientGameStateManager.cs +++ b/Robust.Client/GameStates/ClientGameStateManager.cs @@ -76,6 +76,7 @@ private readonly record struct StateData( private uint _metaCompNetId; private uint _xformCompNetId; + private uint _containerCompNetId; [Dependency] private IReplayRecordingManager _replayRecording = default!; [Dependency] private IComponentFactory _compFactory = default!; @@ -191,6 +192,12 @@ public void Initialize() throw new InvalidOperationException("TransformComponent does not have a NetId."); _xformCompNetId = xformId.Value; + + var containerId = _compFactory.GetRegistration(typeof(ContainerManagerComponent)).NetID; + if (!containerId.HasValue) + throw new InvalidOperationException("ContainerManagerComponent does not have a NetId."); + + _containerCompNetId = containerId.Value; } private void OnComponentAdded(AddedComponentEventArgs args) @@ -1386,20 +1393,37 @@ private void HandleEntityState(in StateData data, IEventBus bus, GameTick toTick if (data.EnteringPvs) { // last-server state has already been updated with new information from curState - // --> simply reset to the most recent server state. + // --> simply reset dirtied components to the most recent server state. // // as to why we need to reset: because in the process of detaching to null-space, we will have dirtied // the entity. most notably, all entities will have been ejected from their containers. - foreach (var (id, state) in _processor.GetLastServerStates(data.NetEntity)) + var lastState = _processor.GetLastServerStates(data.NetEntity); + var netComponents = data.Meta.NetComponents; + var uid = data.Uid; + var meta = data.Meta; + + void AddCompState(ushort id) { - if (!data.Meta.NetComponents.TryGetValue(id, out var comp)) + if (!lastState.TryGetValue(id, out var state)) + return; + + if (!netComponents.TryGetValue(id, out var comp)) { comp = _compFactory.GetComponent(id); - _entities.AddComponent(data.Uid, comp, true, metadata: data.Meta); + _entities.AddComponent(uid, comp, true, metadata: meta); } _compStateWork[id] = (comp, state, null); } + + AddCompState((ushort) _metaCompNetId); + AddCompState((ushort) _xformCompNetId); + AddCompState((ushort) _containerCompNetId); + + foreach (var compChange in data.CurState!.ComponentChanges.Span) + { + AddCompState(compChange.NetID); + } } else if (data.CurState != null) { diff --git a/Robust.Server.IntegrationTests/GameStates/PvsReEntryTest.cs b/Robust.Server.IntegrationTests/GameStates/PvsReEntryTest.cs index 6344a734db0..c033b2307f4 100644 --- a/Robust.Server.IntegrationTests/GameStates/PvsReEntryTest.cs +++ b/Robust.Server.IntegrationTests/GameStates/PvsReEntryTest.cs @@ -7,6 +7,7 @@ using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Timing; @@ -167,5 +168,148 @@ await client.WaitPost(() => } #endif + + /// + /// Checks that PVS re-entry only reapplies component states for components dirtied while the entity was out of PVS. + /// + [Test] + public async Task TestReEntryOnlyReplaysDirtyComponents() + { + await using var pair = await StartConnectedPair( + new ServerIntegrationOptions { Pool = false }, + new ClientIntegrationOptions { Pool = false }); + + var (client, server) = pair; + + var sEntMan = server.ResolveDependency(); + var confMan = server.ResolveDependency(); + var sPlayerMan = server.ResolveDependency(); + var xforms = sEntMan.System(); + + var cEntMan = client.ResolveDependency(); + PvsReEntryReplayTestSystem? cSystem = null; + + server.Post(() => confMan.SetCVar(CVars.NetPVS, true)); + + await RunTicksSync(server, client, 10); + + EntityUid map = default; + NetEntity entity = default; + NetEntity dirtyEntity = default; + NetEntity player = default; + EntityCoordinates coords = default; + + await server.WaitPost(() => + { + map = server.System().CreateMap(); + coords = new EntityCoordinates(map, default); + + var playerUid = sEntMan.SpawnEntity(null, coords); + var entUid = sEntMan.SpawnEntity(null, coords); + var comp = sEntMan.EnsureComponent(entUid); + comp.Value = 1; + sEntMan.Dirty(entUid, comp); + + var dirtyEntUid = sEntMan.SpawnEntity(null, coords); + comp = sEntMan.EnsureComponent(dirtyEntUid); + comp.Value = 1; + sEntMan.Dirty(dirtyEntUid, comp); + + entity = sEntMan.GetNetEntity(entUid); + dirtyEntity = sEntMan.GetNetEntity(dirtyEntUid); + player = sEntMan.GetNetEntity(playerUid); + + var session = sPlayerMan.Sessions.First(); + server.PlayerMan.SetAttachedEntity(session, playerUid); + sPlayerMan.JoinGame(session); + }); + + await RunTicksSync(server, client, 10); + + MetaDataComponent? meta = null; + MetaDataComponent? dirtyMeta = null; + var initialHandleCount = 0; + await client.WaitPost(() => + { + cSystem = client.System(); + Assert.That(cEntMan.TryGetEntityData(entity, out _, out meta), Is.True); + Assert.That(cEntMan.TryGetEntityData(dirtyEntity, out _, out dirtyMeta), Is.True); + Assert.That(meta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.None)); + Assert.That(dirtyMeta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.None)); + initialHandleCount = cSystem.HandleStateCount; + Assert.That(initialHandleCount, Is.GreaterThanOrEqualTo(2)); + }); + + var farAway = new EntityCoordinates(map, new Vector2(100, 100)); + await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway)); + await RunTicksSync(server, client, 10); + + await client.WaitPost(() => + { + Assert.That(meta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.Detached)); + Assert.That(dirtyMeta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.Detached)); + Assert.That(cSystem!.HandleStateCount, Is.EqualTo(initialHandleCount)); + }); + + await server.WaitPost(() => + { + var comp = sEntMan.GetComponent(sEntMan.GetEntity(dirtyEntity)); + comp.Value = 2; + sEntMan.Dirty(sEntMan.GetEntity(dirtyEntity), comp); + }); + + await RunTicksSync(server, client, 5); + + await client.WaitPost(() => + { + Assert.That(cSystem!.HandleStateCount, Is.EqualTo(initialHandleCount)); + }); + + // Re-enter after dirtying one of the test components. Only the dirtied component should get replayed. + await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords)); + await RunTicksSync(server, client, 10); + + await client.WaitPost(() => + { + var uid = cEntMan.GetEntity(entity); + var dirtyUid = cEntMan.GetEntity(dirtyEntity); + Assert.That(meta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.None)); + Assert.That(dirtyMeta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.None)); + Assert.That(cSystem!.HandleStateCount, Is.EqualTo(initialHandleCount + 1)); + Assert.That(cEntMan.GetComponent(uid).Value, Is.EqualTo(1)); + Assert.That(cEntMan.GetComponent(dirtyUid).Value, Is.EqualTo(2)); + }); + } +} + +[RegisterComponent, NetworkedComponent] +public sealed partial class PvsReEntryReplayTestComponent : Component +{ + public int Value; +} + +public sealed partial class PvsReEntryReplayTestSystem : EntitySystem +{ + public int HandleStateCount; + + public override void Initialize() + { + SubscribeLocalEvent(OnGetState); + SubscribeLocalEvent(OnHandleState); + } + + private void OnGetState(Entity ent, ref ComponentGetState args) + { + args.State = new MetaDataComponentState(null, ent.Comp.Value.ToString(), null, null); + } + + private void OnHandleState(Entity ent, ref ComponentHandleState args) + { + if (args.Current is not MetaDataComponentState state) + return; + + ent.Comp.Value = int.Parse(state.Description!); + HandleStateCount++; + } } From e09730ed8509bb5eeb3ec2f6af5e3bfee0a315f5 Mon Sep 17 00:00:00 2001 From: metalgearsloth Date: Fri, 17 Jul 2026 17:09:43 +1000 Subject: [PATCH 2/4] Replay detached predicted dirties on PVS reentry --- .../GameStates/ClientGameStateManager.cs | 23 ++- .../GameState/ComponentStateTests.cs | 188 ++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs index 2cedb2f0c7d..cf7d87ad1ef 100644 --- a/Robust.Client/GameStates/ClientGameStateManager.cs +++ b/Robust.Client/GameStates/ClientGameStateManager.cs @@ -51,6 +51,7 @@ public sealed partial class ClientGameStateManager : IClientGameStateManager private StateData[] _toApplySorted = default!; private readonly Dictionary _compStateWork = new(); private readonly Dictionary> _pendingReapplyNetStates = new(); + private readonly Dictionary> _detachedDirtyComponents = new(); private readonly HashSet _stateEnts = new(); private readonly List _toDelete = new(); private readonly List _toRemove = new(); @@ -223,6 +224,7 @@ private void OnComponentAdded(AddedComponentEventArgs args) public void Reset() { _processor.Reset(); + _detachedDirtyComponents.Clear(); _timing.CurTick = GameTick.Zero; _timing.LastRealTick = GameTick.Zero; _lastProcessedInput = 0; @@ -648,7 +650,13 @@ public void ResetPredictedEntities() if (_sawmill.Level <= LogLevel.Debug) _sawmill.Debug($" A component was dirtied: {comp.GetType()}"); - if ((meta.Flags & MetaDataFlags.Detached) == 0 && compState != null) + // If we dirty a component outside of PVS range (god forbid) then we need to ensure + // it gets states re-run for these components if it comes back into PVS range. + if ((meta.Flags & MetaDataFlags.Detached) != 0) + { + _detachedDirtyComponents.GetOrNew(meta.NetEntity).Add(netId); + } + else if (compState != null) { var handleState = new ComponentHandleState(compState, null); _entities.EventBus.RaiseComponentEvent(entity, comp, ref handleState); @@ -1153,6 +1161,7 @@ public void PartialStateReset( } _sawmill.Info($"Resetting all entity states to tick {state.ToSequence}."); + _detachedDirtyComponents.Clear(); // Construct hashset for set.Contains() checks. _stateEnts.Clear(); @@ -1237,6 +1246,7 @@ private void ProcessDeletions(ReadOnlySpan delSpan, { // Don't worry about this for later. _entities.PendingNetEntityStates.Remove(netEntity); + _detachedDirtyComponents.Remove(netEntity); if (!_entities.TryGetEntity(netEntity, out var id)) continue; @@ -1393,7 +1403,8 @@ private void HandleEntityState(in StateData data, IEventBus bus, GameTick toTick if (data.EnteringPvs) { // last-server state has already been updated with new information from curState - // --> simply reset dirtied components to the most recent server state. + // --> simply reset required structural components and any components dirtied during prediction while + // detached to the most recent server state. // // as to why we need to reset: because in the process of detaching to null-space, we will have dirtied // the entity. most notably, all entities will have been ejected from their containers. @@ -1420,6 +1431,14 @@ void AddCompState(ushort id) AddCompState((ushort) _xformCompNetId); AddCompState((ushort) _containerCompNetId); + if (_detachedDirtyComponents.Remove(data.NetEntity, out var dirtyComponents)) + { + foreach (var netId in dirtyComponents) + { + AddCompState(netId); + } + } + foreach (var compChange in data.CurState!.ComponentChanges.Span) { AddCompState(compChange.NetID); diff --git a/Robust.Shared.IntegrationTests/GameState/ComponentStateTests.cs b/Robust.Shared.IntegrationTests/GameState/ComponentStateTests.cs index 8e2e639481c..ca76ee53511 100644 --- a/Robust.Shared.IntegrationTests/GameState/ComponentStateTests.cs +++ b/Robust.Shared.IntegrationTests/GameState/ComponentStateTests.cs @@ -10,7 +10,9 @@ using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Reflection; +using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.Timing; namespace Robust.UnitTesting.Shared.GameState; @@ -286,6 +288,116 @@ async Task RunTicks() await server.WaitRunTicks(5); await client.WaitRunTicks(5); } + + [Test] + public async Task DetachedPredictedDirtyReplaysOnlyDirtyComponentOnPvsReentry() + { + await using var pair = await StartConnectedPair(); + var server = pair.Server; + var client = pair.Client; + + var xforms = server.System(); + + await server.WaitPost(() => + { + server.CfgMan.SetCVar(CVars.NetPVS, true); + }); + await client.WaitPost(() => client.CfgMan.SetCVar(CVars.NetPredict, true)); + + EntityUid map = default; + server.Post(() => + { + map = server.System().CreateMap(); + }); + + await RunTicksSync(server, client, 10); + + var inRange = new EntityCoordinates(map, default); + var outOfRange = new EntityCoordinates(map, new Vector2(100, 100)); + EntityUid player = default; + EntityUid serverEnt = default; + NetEntity serverNet = default; + + await server.WaitPost(() => + { + player = server.EntMan.SpawnAttachedTo(null, inRange); + var session = server.PlayerMan.Sessions.First(); + server.PlayerMan.SetAttachedEntity(session, player); + server.PlayerMan.JoinGame(session); + + serverEnt = server.EntMan.SpawnAttachedTo(null, inRange); + serverNet = server.EntMan.GetNetEntity(serverEnt); + + var dirty = server.EntMan.EnsureComponent(serverEnt); + dirty.Value = 1; + server.EntMan.Dirty(serverEnt, dirty); + + var clean = server.EntMan.EnsureComponent(serverEnt); + clean.Value = 2; + server.EntMan.Dirty(serverEnt, clean); + }); + + await RunTicksSync(server, client, 10); + + EntityUid clientEnt = default; + await client.WaitPost(() => + { + clientEnt = client.EntMan.GetEntity(serverNet); + Assert.That(client.EntMan.EntityExists(clientEnt), Is.True); + Assert.That(client.EntMan.GetComponent(clientEnt).Value, Is.EqualTo(1)); + Assert.That(client.EntMan.GetComponent(clientEnt).Value, Is.EqualTo(2)); + }); + + await server.WaitPost(() => xforms.SetCoordinates(player, outOfRange)); + await RunTicksSync(server, client, 10); + + await client.WaitPost(() => + { + var meta = client.EntMan.GetComponent(clientEnt); + Assert.That((meta.Flags & MetaDataFlags.Detached), Is.EqualTo(MetaDataFlags.Detached)); + + var dirty = client.EntMan.GetComponent(clientEnt); + var clean = client.EntMan.GetComponent(clientEnt); + dirty.HandleStates = 0; + clean.HandleStates = 0; + + var sys = client.System(); + sys.Target = clientEnt; + sys.PredictedValue = 99; + }); + + await RunTicksSync(server, client, 10); + + await client.WaitPost(() => + { + var dirty = client.EntMan.GetComponent(clientEnt); + Assert.That(dirty.Value, Is.EqualTo(99)); + Assert.That(dirty.HandleStates, Is.EqualTo(0)); + + var sys = client.System(); + sys.Target = null; + }); + + await server.WaitPost(() => xforms.SetCoordinates(player, inRange)); + await RunTicksSync(server, client, 10); + + await client.WaitPost(() => + { + var meta = client.EntMan.GetComponent(clientEnt); + Assert.That((meta.Flags & MetaDataFlags.Detached), Is.EqualTo(default(MetaDataFlags))); + + var dirty = client.EntMan.GetComponent(clientEnt); + var clean = client.EntMan.GetComponent(clientEnt); + + Assert.That(dirty.Value, Is.EqualTo(1)); + Assert.That(dirty.HandleStates, Is.EqualTo(1)); + Assert.That(clean.Value, Is.EqualTo(2)); + Assert.That(clean.HandleStates, Is.EqualTo(0)); + }); + + await server.WaitPost(() => server.CfgMan.SetCVar(CVars.NetPVS, false)); + await RunTicksSync(server, client, 10); + } } [RegisterComponent, NetworkedComponent, AutoGenerateComponentState] @@ -294,3 +406,79 @@ public sealed partial class UnknownEntityTestComponent : Component [DataField, AutoNetworkedField] public EntityUid? Other; } + +[RegisterComponent, NetworkedComponent] +public sealed partial class DetachedPredictionDirtyReplayComponent : Component +{ + public int Value; + public int HandleStates; +} + +[RegisterComponent, NetworkedComponent] +public sealed partial class DetachedPredictionCleanReplayComponent : Component +{ + public int Value; + public int HandleStates; +} + +[Serializable, NetSerializable] +public sealed class DetachedPredictionReplayState(int value) : ComponentState +{ + public readonly int Value = value; +} + +public sealed partial class DetachedPredictionDirtyReplaySystem : EntitySystem +{ + [Dependency] private IGameTiming _timing = default!; + + public EntityUid? Target; + public int PredictedValue; + + public override void Initialize() + { + SubscribeLocalEvent(OnDirtyGetState); + SubscribeLocalEvent(OnDirtyHandleState); + SubscribeLocalEvent(OnCleanGetState); + SubscribeLocalEvent(OnCleanHandleState); + } + + public override void Update(float frameTime) + { + if (!_timing.InPrediction || !_timing.IsFirstTimePredicted || Target is not { } target) + return; + + if (!TryComp(target, out DetachedPredictionDirtyReplayComponent? comp)) + return; + + comp.Value = PredictedValue; + Dirty(target, comp); + } + + private void OnDirtyGetState(Entity ent, ref ComponentGetState args) + { + args.State = new DetachedPredictionReplayState(ent.Comp.Value); + } + + private void OnDirtyHandleState(Entity ent, ref ComponentHandleState args) + { + if (args.Current is not DetachedPredictionReplayState state) + return; + + ent.Comp.Value = state.Value; + ent.Comp.HandleStates++; + } + + private void OnCleanGetState(Entity ent, ref ComponentGetState args) + { + args.State = new DetachedPredictionReplayState(ent.Comp.Value); + } + + private void OnCleanHandleState(Entity ent, ref ComponentHandleState args) + { + if (args.Current is not DetachedPredictionReplayState state) + return; + + ent.Comp.Value = state.Value; + ent.Comp.HandleStates++; + } +} From 06f874cefd5e58310043d4873aadb25cf2b6fc92 Mon Sep 17 00:00:00 2001 From: metalgearsloth Date: Fri, 17 Jul 2026 15:23:31 +1000 Subject: [PATCH 3/4] Don't dirty initial prototype loads If the client can reasonably derive the state itself we won't send it in the initial server states. --- Robust.Client/Physics/JointSystem.cs | 85 --------- Robust.Server/Physics/JointSystem.cs | 22 --- .../EntityManager_Components_Tests.cs | 79 ++++++++ .../EntitySerialization/EntityDeserializer.cs | 2 + .../GameObjects/EntityManager.Components.cs | 53 ++++++ Robust.Shared/GameObjects/EntityManager.cs | 3 +- .../GameObjects/IEntityManager.Components.cs | 6 + .../Physics/Systems/SharedJointSystem.cs | 89 +++++++++ Robust.Shared/Prototypes/EntityPrototype.cs | 84 --------- Robust.Shared/Prototypes/IPrototypeManager.cs | 6 + .../Prototypes/PrototypeManager.EntityLoad.cs | 178 ++++++++++++++++++ Robust.Shared/Prototypes/PrototypeManager.cs | 2 + 12 files changed, 417 insertions(+), 192 deletions(-) create mode 100644 Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs diff --git a/Robust.Client/Physics/JointSystem.cs b/Robust.Client/Physics/JointSystem.cs index 948b55acc5e..4f442e1222b 100644 --- a/Robust.Client/Physics/JointSystem.cs +++ b/Robust.Client/Physics/JointSystem.cs @@ -1,93 +1,8 @@ -using System.Collections.Generic; -using Robust.Shared.GameObjects; -using Robust.Shared.GameStates; -using Robust.Shared.Map; -using Robust.Shared.Physics; -using Robust.Shared.Physics.Dynamics.Joints; using Robust.Shared.Physics.Systems; namespace Robust.Client.Physics { public sealed class JointSystem : SharedJointSystem { - public override void Initialize() - { - base.Initialize(); - SubscribeLocalEvent(HandleComponentState); - } - - private void HandleComponentState(EntityUid uid, JointComponent component, ref ComponentHandleState args) - { - if (args.Current is not JointComponentState jointState) return; - - component.Relay = EnsureEntity(jointState.Relay, uid); - - // Initial state gets applied before the entity (& entity's transform) have been initialized. - // So just let joint init code handle that. - if (!component.Initialized) - { - component.Joints.Clear(); - foreach (var (id, state) in jointState.Joints) - { - component.Joints[id] = state.GetJoint(EntityManager, uid); - } - return; - } - - foreach (var j in AddedJoints) - { - if ((j.BodyAUid == uid || j.BodyBUid == uid) && !jointState.Joints.ContainsKey(j.ID)) - ToRemove.Add(j); - } - AddedJoints.ExceptWith(ToRemove); - ToRemove.Clear(); - - var removed = new List(); - foreach (var (existing, j) in component.Joints) - { - if (!jointState.Joints.ContainsKey(existing)) - removed.Add(j); - } - - foreach (var j in removed) - { - RemoveJoint(j); - } - - foreach (var (id, state) in jointState.Joints) - { - if (component.Joints.TryGetValue(id, out var joint)) - { - joint.ApplyState(state); - continue; - } - - var uidA = GetEntity(state.UidA); - var other = uidA == uid ? GetEntity(state.UidB) : uidA; - - // Add new joint (if possible). - // Need to wait for BOTH joint components to come in first before we can add it. Yay dependencies! - if (!HasComp(other)) - continue; - - // TODO: if (other entity is outside of PVS range) continue; - // for now, half-assed check until something like PR #3000 gets merged. - if (Transform(other).MapID == MapId.Nullspace) - continue; - - // oh jolly what good fun: the joint component state can get handled prior to the transform component state. - // so if our current transform is on another map, this would throw an error. - // so lets just... assume the server state isn't messed up, and defer the joint processing. - // alternatively: - // TODO: component state handling ordering. - if (Transform(uid).MapID == MapId.Nullspace) - { - AddedJoints.Add(state.GetJoint(EntityManager, uid)); - continue; - } - - AddJoint(state.GetJoint(EntityManager, uid)); - } - } } } diff --git a/Robust.Server/Physics/JointSystem.cs b/Robust.Server/Physics/JointSystem.cs index ef05188c243..c45ec04e553 100644 --- a/Robust.Server/Physics/JointSystem.cs +++ b/Robust.Server/Physics/JointSystem.cs @@ -1,29 +1,7 @@ -using System.Collections.Generic; -using Robust.Shared.GameObjects; -using Robust.Shared.GameStates; -using Robust.Shared.Physics; -using Robust.Shared.Physics.Dynamics.Joints; using Robust.Shared.Physics.Systems; namespace Robust.Server.Physics; public sealed class JointSystem : SharedJointSystem { - public override void Initialize() - { - base.Initialize(); - SubscribeLocalEvent(GetCompState); - } - - private void GetCompState(EntityUid uid, JointComponent component, ref ComponentGetState args) - { - var states = new Dictionary(component.Joints.Count); - - foreach (var (id, joint) in component.Joints) - { - states.Add(id, joint.GetState(EntityManager)); - } - - args.State = new JointComponentState(GetNetEntity(component.Relay), states); - } } diff --git a/Robust.Shared.IntegrationTests/GameObjects/EntityManager_Components_Tests.cs b/Robust.Shared.IntegrationTests/GameObjects/EntityManager_Components_Tests.cs index 3ec3301ae1e..55307cb95ac 100644 --- a/Robust.Shared.IntegrationTests/GameObjects/EntityManager_Components_Tests.cs +++ b/Robust.Shared.IntegrationTests/GameObjects/EntityManager_Components_Tests.cs @@ -9,6 +9,7 @@ using Robust.Shared.Physics.Components; using Robust.Shared.Prototypes; using Robust.Shared.Serialization.Manager; +using Robust.Shared.Timing; using Robust.UnitTesting.Server; namespace Robust.UnitTesting.Shared.GameObjects @@ -26,6 +27,16 @@ internal sealed partial class EntityManager_Components_Tests - type: Physics "; + private const string PrototypeLoadDirtyId = "PrototypeLoadDirty"; + private const string PrototypeLoadDirty = $@" + - type: entity + id: {PrototypeLoadDirtyId} + components: + - type: Occluder + - type: Joint + - type: CollisionWake +"; + [Test] public void AddRegistryComponentTest() { @@ -75,6 +86,63 @@ public void RemoveRegistryComponentTest() }); } + [Test] + public void PrototypeLoadWithHandleStateClearsComponentTicks() + { + var sim = DirtyPrototypeSimulation(); + var entMan = sim.Resolve(); + + var entity = entMan.SpawnEntity(PrototypeLoadDirtyId, MapCoordinates.Nullspace); + var comp = entMan.GetComponent(entity); + + Assert.That(comp.LastModifiedTick, Is.EqualTo(GameTick.Zero)); + Assert.That(comp.CreationTick, Is.EqualTo(GameTick.Zero)); + } + + [Test] + public void PrototypeLoadWithSharedHandleStateClearsComponentTicks() + { + var sim = DirtyPrototypeSimulation(); + var entMan = sim.Resolve(); + + var entity = entMan.SpawnEntity(PrototypeLoadDirtyId, MapCoordinates.Nullspace); + var comp = entMan.GetComponent(entity); + + Assert.That(comp.LastModifiedTick, Is.EqualTo(GameTick.Zero)); + Assert.That(comp.CreationTick, Is.EqualTo(GameTick.Zero)); + } + + [Test] + public void PrototypeLoadWithAutoStateClearsComponentTicks() + { + var sim = DirtyPrototypeSimulation(); + var entMan = sim.Resolve(); + + var entity = entMan.SpawnEntity(PrototypeLoadDirtyId, MapCoordinates.Nullspace); + var comp = entMan.GetComponent(entity); + + Assert.That(comp.LastModifiedTick, Is.EqualTo(GameTick.Zero)); + Assert.That(comp.CreationTick, Is.EqualTo(GameTick.Zero)); + } + + [Test] + public void PrototypeLoadOverrideKeepsComponentDirty() + { + var sim = DirtyPrototypeSimulation(); + var entMan = sim.Resolve(); + + var overrides = new ComponentRegistry + { + ["Occluder"] = new EntityPrototype.ComponentRegistryEntry(new OccluderComponent()) + }; + + var entity = entMan.SpawnEntity(PrototypeLoadDirtyId, MapCoordinates.Nullspace, overrides); + var comp = entMan.GetComponent(entity); + + Assert.That(comp.LastModifiedTick, Is.EqualTo(sim.Resolve().CurTick)); + Assert.That(comp.CreationTick, Is.EqualTo(sim.Resolve().CurTick)); + } + [Test] public void AddComponentTest() { @@ -361,6 +429,17 @@ private static (ISimulation, EntityCoordinates) SimulationFactory() return (sim, coords); } + private static ISimulation DirtyPrototypeSimulation() + { + var sim = RobustServerSimulation + .NewSimulation() + .RegisterPrototypes(fac => fac.LoadString(PrototypeLoadDirty)) + .InitializeInstance(); + + sim.Resolve().CurTick = new GameTick(42); + return sim; + } + [NetworkedComponent()] private sealed partial class DummyComponent : Component, ICompType1, ICompType2 { diff --git a/Robust.Shared/EntitySerialization/EntityDeserializer.cs b/Robust.Shared/EntitySerialization/EntityDeserializer.cs index 66221b13e6c..60e168ce406 100644 --- a/Robust.Shared/EntitySerialization/EntityDeserializer.cs +++ b/Robust.Shared/EntitySerialization/EntityDeserializer.cs @@ -651,6 +651,8 @@ private void LoadEntity( if (!entry.Component.NetSyncEnabled && compReg.NetID is { } netId) meta.NetComponents.Remove(netId); + + EntMan.ClearPrototypeLoadTicks(component, compReg); } } diff --git a/Robust.Shared/GameObjects/EntityManager.Components.cs b/Robust.Shared/GameObjects/EntityManager.Components.cs index 4facdde86f4..c2787cb82fe 100644 --- a/Robust.Shared/GameObjects/EntityManager.Components.cs +++ b/Robust.Shared/GameObjects/EntityManager.Components.cs @@ -8,6 +8,7 @@ using System.Runtime.InteropServices; using System.Threading; using JetBrains.Annotations; +using Robust.Shared.Analyzers; using Robust.Shared.GameStates; using Robust.Shared.Log; using Robust.Shared.Physics.Components; @@ -47,6 +48,10 @@ private Dictionary[] _entTraitArray private UniqueIndexHkm _entCompIndex = new(ComponentCollectionCapacity); + private bool[]? _prototypeLoadClearableNetComponents; + + internal bool EnablePrototypeLoadTickClear = true; + /// public event Action? ComponentAdded; @@ -91,6 +96,54 @@ private void RegisterComponents(IEnumerable components) private void OnComponentsAdded(ComponentRegistration[] components) { RegisterComponents(components); + _prototypeLoadClearableNetComponents = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ClearPrototypeLoadTicks(IComponent component, ComponentRegistration reg) + { + if (!EnablePrototypeLoadTickClear + || !Started + || !component.NetSyncEnabled + || component.SessionSpecific + || !IsPrototypeLoadClearable(reg)) + { + return; + } + + component.ClearTicks(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsPrototypeLoadClearable(ComponentRegistration reg) + { + return reg.NetID is { } netId + && netId < PrototypeLoadClearableNetComponents.Length + && PrototypeLoadClearableNetComponents[netId]; + } + + private bool[] PrototypeLoadClearableNetComponents + { + get + { + if (_prototypeLoadClearableNetComponents is { } clearable) + return clearable; + + var handleStateHandlers = EventBusInternal.GetNetCompEventHandlers(); + clearable = new bool[handleStateHandlers.Length]; + var netComps = _componentFactory.NetworkedComponents!; + + for (var i = 0; i < clearable.Length; i++) + { + var reg = netComps[i]; + clearable[i] = reg.Type != typeof(MetaDataComponent) + && reg.Type != typeof(TransformComponent) + && (handleStateHandlers[i] != null + || reg.Type.HasCustomAttribute()); + } + + return _prototypeLoadClearableNetComponents = clearable; + } } #region Component Management diff --git a/Robust.Shared/GameObjects/EntityManager.cs b/Robust.Shared/GameObjects/EntityManager.cs index de734da9fce..30b6bb3f1d8 100644 --- a/Robust.Shared/GameObjects/EntityManager.cs +++ b/Robust.Shared/GameObjects/EntityManager.cs @@ -35,6 +35,7 @@ public abstract partial class EntityManager : IEntityManager #region Dependencies [IoC.Dependency] protected IPrototypeManager PrototypeManager = default!; + [IoC.Dependency] private IPrototypeManagerInternal _prototypeManagerInternal = default!; [IoC.Dependency] protected ILogManager LogManager = default!; [IoC.Dependency] private IEntitySystemManager _entitySystemManager = default!; [IoC.Dependency] private IGameTiming _gameTiming = default!; @@ -1011,7 +1012,7 @@ private protected EntityUid CreateEntity(EntityPrototype prototype, out MetaData var entity = AllocEntity(prototype, out metadata); try { - EntityPrototype.LoadEntity((entity, metadata), ComponentFactory, this, _serManager, context); + _prototypeManagerInternal.LoadEntity((entity, metadata), context); return entity; } catch (Exception e) diff --git a/Robust.Shared/GameObjects/IEntityManager.Components.cs b/Robust.Shared/GameObjects/IEntityManager.Components.cs index 3b71a269df8..6c350a995d0 100644 --- a/Robust.Shared/GameObjects/IEntityManager.Components.cs +++ b/Robust.Shared/GameObjects/IEntityManager.Components.cs @@ -62,6 +62,12 @@ public partial interface IEntityManager /// void RemoveComponents(EntityUid target, ComponentRegistry registry); + /// + /// Clears initial dirty ticks from a component loaded directly from prototype data if the component state can be + /// reconstructed by the client from the same prototype data. + /// + void ClearPrototypeLoadTicks(IComponent component, ComponentRegistration reg); + /// /// Adds a Component type to an entity. If the entity is already Initialized, the component will /// automatically be Initialized and Started. diff --git a/Robust.Shared/Physics/Systems/SharedJointSystem.cs b/Robust.Shared/Physics/Systems/SharedJointSystem.cs index 78a648afb5a..9d282fa89c0 100644 --- a/Robust.Shared/Physics/Systems/SharedJointSystem.cs +++ b/Robust.Shared/Physics/Systems/SharedJointSystem.cs @@ -4,6 +4,7 @@ using System.Numerics; using Robust.Shared.Containers; using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; using Robust.Shared.IoC; using Robust.Shared.Map; using Robust.Shared.Physics.Components; @@ -41,12 +42,100 @@ public override void Initialize() UpdatesOutsidePrediction = true; UpdatesBefore.Add(typeof(SharedPhysicsSystem)); + SubscribeLocalEvent(GetCompState); + SubscribeLocalEvent(HandleComponentState); SubscribeLocalEvent(OnJointShutdown); SubscribeLocalEvent(OnJointInit); InitializeRelay(); } + private void GetCompState(EntityUid uid, JointComponent component, ref ComponentGetState args) + { + var states = new Dictionary(component.Joints.Count); + + foreach (var (id, joint) in component.Joints) + { + states.Add(id, joint.GetState(EntityManager)); + } + + args.State = new JointComponentState(GetNetEntity(component.Relay), states); + } + + private void HandleComponentState(EntityUid uid, JointComponent component, ref ComponentHandleState args) + { + if (args.Current is not JointComponentState jointState) return; + + component.Relay = EnsureEntity(jointState.Relay, uid); + + // Initial state gets applied before the entity (& entity's transform) have been initialized. + // So just let joint init code handle that. + if (!component.Initialized) + { + component.Joints.Clear(); + foreach (var (id, state) in jointState.Joints) + { + component.Joints[id] = state.GetJoint(EntityManager, uid); + } + return; + } + + foreach (var j in AddedJoints) + { + if ((j.BodyAUid == uid || j.BodyBUid == uid) && !jointState.Joints.ContainsKey(j.ID)) + ToRemove.Add(j); + } + AddedJoints.ExceptWith(ToRemove); + ToRemove.Clear(); + + var removed = new List(); + foreach (var (existing, j) in component.Joints) + { + if (!jointState.Joints.ContainsKey(existing)) + removed.Add(j); + } + + foreach (var j in removed) + { + RemoveJoint(j); + } + + foreach (var (id, state) in jointState.Joints) + { + if (component.Joints.TryGetValue(id, out var joint)) + { + joint.ApplyState(state); + continue; + } + + var uidA = GetEntity(state.UidA); + var other = uidA == uid ? GetEntity(state.UidB) : uidA; + + // Add new joint (if possible). + // Need to wait for BOTH joint components to come in first before we can add it. Yay dependencies! + if (!HasComp(other)) + continue; + + // TODO: if (other entity is outside of PVS range) continue; + // for now, half-assed check until something like PR #3000 gets merged. + if (Transform(other).MapID == MapId.Nullspace) + continue; + + // oh jolly what good fun: the joint component state can get handled prior to the transform component state. + // so if our current transform is on another map, this would throw an error. + // so lets just... assume the server state isn't messed up, and defer the joint processing. + // alternatively: + // TODO: component state handling ordering. + if (Transform(uid).MapID == MapId.Nullspace) + { + AddedJoints.Add(state.GetJoint(EntityManager, uid)); + continue; + } + + AddJoint(state.GetJoint(EntityManager, uid)); + } + } + #region Lifetime private void OnJointInit(EntityUid uid, JointComponent component, ComponentInit args) diff --git a/Robust.Shared/Prototypes/EntityPrototype.cs b/Robust.Shared/Prototypes/EntityPrototype.cs index 45309ff97ce..fd3ffd3f613 100644 --- a/Robust.Shared/Prototypes/EntityPrototype.cs +++ b/Robust.Shared/Prototypes/EntityPrototype.cs @@ -247,90 +247,6 @@ public bool HasComp(Type type, IComponentFactory factory) public bool HasComp(CompName name) => Components.ContainsKey(name.Name); - internal static void LoadEntity( - Entity ent, - IComponentFactory factory, - IEntityManager entityManager, - ISerializationManager serManager, - IEntityLoadContext? context) //yeah officer this method right here - { - var (entity, meta) = ent; - var prototype = meta.EntityPrototype; - var ctx = context as ISerializationContext; - - if (prototype != null) - { - foreach (var (name, entry) in prototype.Components) - { - if (context != null && context.ShouldSkipComponent(name)) - continue; - - var fullData = context != null && context.TryGetComponent(name, out var data) ? data : entry.Component; - var compReg = factory.GetRegistration(name); - EnsureCompExistsAndDeserialize(entity, compReg, factory, entityManager, serManager, name, fullData, ctx); - - if (!entry.Component.NetSyncEnabled && compReg.NetID is {} netId) - meta.NetComponents.Remove(netId); - } - } - - if (context != null) - { - foreach (var name in context.GetExtraComponentTypes()) - { - if (prototype != null && prototype.Components.ContainsKey(name)) - { - // This component also exists in the prototype. - // This means that the previous step already caught both the prototype data AND map data. - // Meaning that re-running EnsureCompExistsAndDeserialize would wipe prototype data. - continue; - } - - if (!context.TryGetComponent(name, out var data)) - { - throw new InvalidOperationException( - $"{nameof(IEntityLoadContext)} provided component name {name} but refused to provide data"); - } - - var compReg = factory.GetRegistration(name); - EnsureCompExistsAndDeserialize(entity, compReg, factory, entityManager, serManager, name, data, ctx); - } - } - } - - public static void EnsureCompExistsAndDeserialize(EntityUid entity, - ComponentRegistration compReg, - IComponentFactory factory, - IEntityManager entityManager, - ISerializationManager serManager, - string compName, - IComponent data, - ISerializationContext? context) - { - var existed = true; - if (!entityManager.TryGetComponent(entity, compReg.Idx, out var component)) - { - existed = false; - var newComponent = factory.GetComponent(compName); - newComponent.Owner = entity; - component = newComponent; - } - - if (context is not EntityDeserializer map) - { - serManager.CopyTo(data, ref component, context, notNullableOverride: true); - } - else - { - map.CurrentComponent = compName; - serManager.CopyTo(data, ref component, context, notNullableOverride: true); - map.CurrentComponent = null; - } - - if (!existed) - entityManager.AddComponent(entity, component); - } - public override string ToString() { return $"EntityPrototype({ID})"; diff --git a/Robust.Shared/Prototypes/IPrototypeManager.cs b/Robust.Shared/Prototypes/IPrototypeManager.cs index e2741e70c02..53d66001b67 100644 --- a/Robust.Shared/Prototypes/IPrototypeManager.cs +++ b/Robust.Shared/Prototypes/IPrototypeManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using Robust.Shared.GameObjects; using Robust.Shared.Random; using Robust.Shared.Reflection; using Robust.Shared.Serialization.Manager; @@ -569,6 +570,11 @@ void ReloadPrototypes( internal interface IPrototypeManagerInternal : IPrototypeManager { event Action? LoadedData; + + /// + /// Loads the prototype and context component data for an entity. + /// + void LoadEntity(Entity ent, IEntityLoadContext? context); } /// diff --git a/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs b/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs new file mode 100644 index 00000000000..91ccee796e1 --- /dev/null +++ b/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.EntitySerialization; +using Robust.Shared.GameObjects; +using Robust.Shared.Serialization.Manager; + +namespace Robust.Shared.Prototypes; + +public abstract partial class PrototypeManager +{ + private readonly Dictionary _entityComponentRegistrations = new(); + + void IPrototypeManagerInternal.LoadEntity(Entity ent, IEntityLoadContext? context) + { + var (entity, meta) = ent; + var prototype = meta.EntityPrototype; + var ctx = context as ISerializationContext; + + if (prototype != null && context == null) + { + LoadPrototypeComponents(entity, meta, prototype); + return; + } + + if (prototype != null && context != null) + LoadPrototypeComponentsWithContext(entity, meta, prototype, context, ctx); + + if (context != null) + LoadExtraContextComponents(entity, prototype, context, ctx); + } + + private PrototypeComponentRegistration[] GetComponentRegistrations(EntityPrototype prototype) + { + if (_entityComponentRegistrations.TryGetValue(prototype.ID, out var cached)) + return cached; + + var registrations = new PrototypeComponentRegistration[prototype.Components.Count]; + var i = 0; + + foreach (var (name, entry) in prototype.Components) + { + registrations[i++] = new PrototypeComponentRegistration(name, entry, _factory.GetRegistration(name)); + } + + _entityComponentRegistrations[prototype.ID] = registrations; + return registrations; + } + + private void LoadPrototypeComponents( + EntityUid entity, + MetaDataComponent meta, + EntityPrototype prototype) + { + foreach (var protoComponent in GetComponentRegistrations(prototype)) + { + var component = EnsureCompExistsAndDeserialize( + entity, + protoComponent.Registration, + protoComponent.Name, + protoComponent.Entry.Component, + null); + + RemoveUnsyncedNetComponent(meta, protoComponent.Entry.Component, protoComponent.Registration); + _entMan.ClearPrototypeLoadTicks(component, protoComponent.Registration); + } + } + + private void LoadPrototypeComponentsWithContext( + EntityUid entity, + MetaDataComponent meta, + EntityPrototype prototype, + IEntityLoadContext context, + ISerializationContext? serializationContext) + { + foreach (var protoComponent in GetComponentRegistrations(prototype)) + { + var name = protoComponent.Name; + var entry = protoComponent.Entry; + + if (context.ShouldSkipComponent(name)) + continue; + + var contextComponent = false; + var fullData = entry.Component; + if (context.TryGetComponent(name, out var data)) + { + contextComponent = true; + fullData = data; + } + + var component = EnsureCompExistsAndDeserialize( + entity, + protoComponent.Registration, + name, + fullData, + serializationContext); + + RemoveUnsyncedNetComponent(meta, entry.Component, protoComponent.Registration); + + if (!contextComponent) + _entMan.ClearPrototypeLoadTicks(component, protoComponent.Registration); + } + } + + private void LoadExtraContextComponents( + EntityUid entity, + EntityPrototype? prototype, + IEntityLoadContext context, + ISerializationContext? serializationContext) + { + foreach (var name in context.GetExtraComponentTypes()) + { + if (prototype != null && prototype.Components.ContainsKey(name)) + { + // This component also exists in the prototype. + // This means that the previous step already caught both the prototype data AND map data. + // Meaning that re-running EnsureCompExistsAndDeserialize would wipe prototype data. + continue; + } + + if (!context.TryGetComponent(name, out var data)) + { + throw new InvalidOperationException( + $"{nameof(IEntityLoadContext)} provided component name {name} but refused to provide data"); + } + + var compReg = _factory.GetRegistration(name); + EnsureCompExistsAndDeserialize(entity, compReg, name, data, serializationContext); + } + } + + private static void RemoveUnsyncedNetComponent( + MetaDataComponent meta, + IComponent component, + ComponentRegistration compReg) + { + if (!component.NetSyncEnabled && compReg.NetID is { } netId) + meta.NetComponents.Remove(netId); + } + + private IComponent EnsureCompExistsAndDeserialize( + EntityUid entity, + ComponentRegistration compReg, + string compName, + IComponent data, + ISerializationContext? context) + { + var existed = true; + if (!_entMan.TryGetComponent(entity, compReg.Idx, out var component)) + { + existed = false; + var newComponent = _factory.GetComponent(compReg); + newComponent.Owner = entity; + component = newComponent; + } + + if (context is not EntityDeserializer map) + { + _serializationManager.CopyTo(data, ref component, context, notNullableOverride: true); + } + else + { + map.CurrentComponent = compName; + _serializationManager.CopyTo(data, ref component, context, notNullableOverride: true); + map.CurrentComponent = null; + } + + if (!existed) + _entMan.AddComponent(entity, component); + + return component; + } + + private readonly record struct PrototypeComponentRegistration( + string Name, + EntityPrototype.ComponentRegistryEntry Entry, + ComponentRegistration Registration); +} diff --git a/Robust.Shared/Prototypes/PrototypeManager.cs b/Robust.Shared/Prototypes/PrototypeManager.cs index 7b0ff387ad1..a2af7a83ddb 100644 --- a/Robust.Shared/Prototypes/PrototypeManager.cs +++ b/Robust.Shared/Prototypes/PrototypeManager.cs @@ -1170,6 +1170,7 @@ private void OnReload(PrototypesReloadedEventArgs args) foreach (var id in modified.Modified.Keys) { _prototypeDataCache.Remove(id); + _entityComponentRegistrations.Remove(id); } } @@ -1179,6 +1180,7 @@ private void OnReload(PrototypesReloadedEventArgs args) foreach (var id in removed) { _prototypeDataCache.Remove(id); + _entityComponentRegistrations.Remove(id); } } From 91bed81f45b93e00692076393c5f439cad68c504 Mon Sep 17 00:00:00 2001 From: metalgearsloth Date: Fri, 17 Jul 2026 15:38:49 +1000 Subject: [PATCH 4/4] frozone --- .../RobustServerSimulation.cs | 1 + .../Prototypes/PrototypeManager.EntityLoad.cs | 33 +++++++++++++++++-- Robust.Shared/Prototypes/PrototypeManager.cs | 8 +++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Robust.Server.Testing/RobustServerSimulation.cs b/Robust.Server.Testing/RobustServerSimulation.cs index 5b6591eabd4..862704b056e 100644 --- a/Robust.Server.Testing/RobustServerSimulation.cs +++ b/Robust.Server.Testing/RobustServerSimulation.cs @@ -251,6 +251,7 @@ public ISimulation InitializeInstance() container.Register(); container.Register(); container.Register(); + container.Register(); container.Register(); container.Register(); container.Register(); diff --git a/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs b/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs index 91ccee796e1..6c3f5ddaf45 100644 --- a/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs +++ b/Robust.Shared/Prototypes/PrototypeManager.EntityLoad.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Frozen; using System.Collections.Generic; using Robust.Shared.EntitySerialization; using Robust.Shared.GameObjects; @@ -8,7 +9,8 @@ namespace Robust.Shared.Prototypes; public abstract partial class PrototypeManager { - private readonly Dictionary _entityComponentRegistrations = new(); + private FrozenDictionary _entityComponentRegistrations + = FrozenDictionary.Empty; void IPrototypeManagerInternal.LoadEntity(Entity ent, IEntityLoadContext? context) { @@ -34,6 +36,34 @@ private PrototypeComponentRegistration[] GetComponentRegistrations(EntityPrototy if (_entityComponentRegistrations.TryGetValue(prototype.ID, out var cached)) return cached; + // This should only happen if an entity is being loaded before prototype resolution has rebuilt the cache. + return BuildComponentRegistrations(prototype); + } + + private void RebuildEntityComponentRegistrationCache() + { + if (!TryGetInstances(out var prototypes)) + { + ClearEntityComponentRegistrationCache(); + return; + } + + var registrations = new Dictionary(prototypes.Count); + foreach (var (id, prototype) in prototypes) + { + registrations[id] = BuildComponentRegistrations(prototype); + } + + _entityComponentRegistrations = registrations.ToFrozenDictionary(); + } + + private void ClearEntityComponentRegistrationCache() + { + _entityComponentRegistrations = FrozenDictionary.Empty; + } + + private PrototypeComponentRegistration[] BuildComponentRegistrations(EntityPrototype prototype) + { var registrations = new PrototypeComponentRegistration[prototype.Components.Count]; var i = 0; @@ -42,7 +72,6 @@ private PrototypeComponentRegistration[] GetComponentRegistrations(EntityPrototy registrations[i++] = new PrototypeComponentRegistration(name, entry, _factory.GetRegistration(name)); } - _entityComponentRegistrations[prototype.ID] = registrations; return registrations; } diff --git a/Robust.Shared/Prototypes/PrototypeManager.cs b/Robust.Shared/Prototypes/PrototypeManager.cs index a2af7a83ddb..6e28867d9f6 100644 --- a/Robust.Shared/Prototypes/PrototypeManager.cs +++ b/Robust.Shared/Prototypes/PrototypeManager.cs @@ -295,6 +295,7 @@ public void Clear() { _kindNames.Clear(); _kinds = FrozenDictionary.Empty; + ClearEntityComponentRegistrationCache(); } /// @@ -478,11 +479,16 @@ void AddToQueue(string id) private void Freeze(IEnumerable kinds) { var st = RStopwatch.StartNew(); + var frozeEntityPrototypes = false; foreach (var kind in kinds) { kind.Freeze(); + frozeEntityPrototypes |= kind.Type == typeof(EntityPrototype); } + if (frozeEntityPrototypes) + RebuildEntityComponentRegistrationCache(); + // fun fact: Sawmill can be null in tests???? Sawmill?.Verbose($"Freezing prototype instances took {st.Elapsed.TotalMilliseconds:f2}ms"); } @@ -1170,7 +1176,6 @@ private void OnReload(PrototypesReloadedEventArgs args) foreach (var id in modified.Modified.Keys) { _prototypeDataCache.Remove(id); - _entityComponentRegistrations.Remove(id); } } @@ -1180,7 +1185,6 @@ private void OnReload(PrototypesReloadedEventArgs args) foreach (var id in removed) { _prototypeDataCache.Remove(id); - _entityComponentRegistrations.Remove(id); } }