diff --git a/src/ECS/Archetype/ComponentTypes.cs b/src/ECS/Archetype/ComponentTypes.cs index 177e333c7..a8b7bacfc 100644 --- a/src/ECS/Archetype/ComponentTypes.cs +++ b/src/ECS/Archetype/ComponentTypes.cs @@ -474,7 +474,7 @@ public struct ComponentTypesEnumerator : IEnumerator { internal BitSetEnumerator bitSetEnumerator; // 48 - private static readonly ComponentType[] Components = EntityStoreBase.Static.EntitySchema.components; + private static ComponentType[] Components => EntityStoreBase.Static.EntitySchema.components; // --- IEnumerator public void Reset() => bitSetEnumerator.Reset(); diff --git a/src/ECS/Archetype/EntityStore.cs b/src/ECS/Archetype/EntityStore.cs index 16cf8b122..ebfc14d96 100644 --- a/src/ECS/Archetype/EntityStore.cs +++ b/src/ECS/Archetype/EntityStore.cs @@ -123,9 +123,12 @@ internal struct InternBase { // use nested class to minimize noise in debugger internal static class Static { - internal static readonly EntitySchema EntitySchema = SchemaUtils.RegisterSchemaTypes(); + // Properties, not static readonly fields: a lazy initializer here would create the schema + // implicitly on first touch, and a throwing initializer would poison this class for the rest + // of the process. Both are worse than failing on the exact call that came too early. + internal static EntitySchema EntitySchema => EntitySchemaHolder.Schema; /// All items in the are always null - internal static readonly StructHeap[] DefaultHeapMap = new StructHeap[EntitySchema.maxStructIndex]; + internal static StructHeap[] DefaultHeapMap => EntitySchemaHolder.DefaultHeapMap; /// The index of the - index is always 0 internal const int DefaultArchIndex = 0; diff --git a/src/ECS/Archetype/Tags.cs b/src/ECS/Archetype/Tags.cs index 1f5488657..850eb4098 100644 --- a/src/ECS/Archetype/Tags.cs +++ b/src/ECS/Archetype/Tags.cs @@ -354,7 +354,7 @@ private readonly string GetString() public struct TagsEnumerator : IEnumerator { private BitSetEnumerator bitSetEnumerator; // 48 - private static readonly TagType[] TagTypes = EntityStoreBase.Static.EntitySchema.tags; + private static TagType[] TagTypes => EntityStoreBase.Static.EntitySchema.tags; // --- IEnumerator public void Reset() => bitSetEnumerator.Reset(); diff --git a/src/ECS/Base/EntitySchemaHolder.cs b/src/ECS/Base/EntitySchemaHolder.cs new file mode 100644 index 000000000..02222cbf4 --- /dev/null +++ b/src/ECS/Base/EntitySchemaHolder.cs @@ -0,0 +1,292 @@ +// Copyright (c) ReadyM / ReadyCode Limited. All rights reserved. +// Friflo.Engine.ECS fork addition. + +using System; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Friflo.Engine.ECS; + +/// +/// The single place the process-wide lives. +/// +/// The schema is created EXPLICITLY, never implicitly. It must be created after every mod assembly is +/// loaded and every component type is registered, and before the first exists. +/// Anything that needs the schema earlier gets rather than a silently wrong schema. +/// +/// +/// The schema is immutable and cannot be replaced once set: and the +/// archetype bit sets cache struct indices derived from it, so a second schema would alias unrelated +/// component types onto the same index. +/// +/// +/// Read the synchronization contract on the fields below before adding a lock or a volatile here. +/// +/// +internal static class EntitySchemaHolder +{ + // --------------------------------------------------------------------------------------------------- + // Synchronization contract + // --------------------------------------------------------------------------------------------------- + // The schema is written once during startup and is immutable afterwards, so there is no mutable shared + // state to protect. The read path therefore carries NO synchronization: no lock, no volatile. That is a + // deliberate choice, and it rests on an obligation the caller has to meet regardless. + // + // THE CALLER'S OBLIGATION. Creating the schema must happen-before every read of it. This cannot be the + // holder's job. A holder that synchronized every read would still not help a caller that creates a + // world on one thread while another is still registering component types: that caller does not get a + // stale schema, it gets NotCreated, nondeterministically. Ordering startup is the caller's + // responsibility, and once it is met, plain field reads are correct, because the writes were published + // by whatever established that ordering - typically starting the threads that later read them. + // + // WHAT THE LOCK IS FOR. Initialize does the check, the build and the assignment under `gate`, so every + // caller leaves with a happens-before edge to the writes whether it created the schema or found one + // already there. That is what makes "several callers each initialize on their own" sound rather than + // hopeful, and it is why Initialize is the ONLY way in. + // + // WHY THERE IS NO PUBLIC IsCreated. A published "has it been created" flag invites + // `if (!IsCreated) Create()`, and the thread that observes true and skips the call never touches `gate`, + // so it gets no edge with the writer. Rather than document that trap we removed the means to fall into + // it: callers call Initialize unconditionally. + // + // WHY NOT SIMPLY LOCK THE READS. Static.EntitySchema is read on every archetype creation and inside the + // query enumerators. A lock or a volatile there buys nothing a correct caller needs and costs something + // on a genuinely hot path. The error path is the one exception: see AlreadyCreated. + // --------------------------------------------------------------------------------------------------- + + private static readonly object gate = new object(); + + private static EntitySchema schema; + private static StructHeap[] defaultHeapMap; + private static EntitySchemaSource source = EntitySchemaSource.NotCreated; + + // Null by default, which makes a repeated initialization a hard failure. See + // AllowRepeatedInitialization for the only situation that legitimately relaxes it. + private static Action onRepeatedInitialization; + + /// Whether the schema has been created. Reading this never creates it. + internal static bool IsCreated => schema != null; + + /// How the schema was created, or if it was not. + internal static EntitySchemaSource Source => source; + + /// + /// Whether a repeated initialization through the same mechanism is tolerated in this process. Whether a + /// repeat throws depends on it, so anything asserting either behaviour should state which it expects + /// rather than assume. + /// + internal static bool RepeatsAllowed { get { lock (gate) { return onRepeatedInitialization != null; } } } + + internal static EntitySchema Schema => schema ?? throw NotCreated(); + + /// All items are always null. Sized by the schema's maxStructIndex. + internal static StructHeap[] DefaultHeapMap => schema != null ? defaultHeapMap : throw NotCreated(); + + /// + /// Downgrades a repeated initialization through the SAME mechanism from a hard failure to a report on + /// . A conflict between the two mechanisms stays a hard failure either way: + /// that is never legitimate. + /// + /// Enabling this requires supplying somewhere to report to, so it can never be turned on silently. + /// + /// + internal static void AllowRepeatedInitialization(Action onRepeat) + { + if (onRepeat == null) { + throw new ArgumentNullException(nameof(onRepeat)); + } + lock (gate) { + onRepeatedInitialization = onRepeat; + } + } + + /// Restores the default, where a repeated initialization is a hard failure. + internal static void DisallowRepeatedInitialization() + { + lock (gate) { + onRepeatedInitialization = null; + } + } + + /// + /// The only way to create the schema. Everything happens under gate: the check, the build, and + /// the assignment. So every caller leaves with a happens-before edge to the writes, and there is no way + /// to observe "not created" and then act on it unsynchronized. + /// + /// runs only if this call is the one that creates the schema, and must + /// call with . It is invoked while the lock is held, + /// which is safe because re-enters the same lock. + /// + /// + internal static EntitySchema Initialize( + EntitySchemaSource schemaSource, + Func describeCandidate, + Action sealSchema) + { + if (schemaSource == EntitySchemaSource.NotCreated) { + throw new ArgumentOutOfRangeException(nameof(schemaSource), + "A created schema must record how it was created."); + } + if (describeCandidate == null) { + throw new ArgumentNullException(nameof(describeCandidate)); + } + if (sealSchema == null) { + throw new ArgumentNullException(nameof(sealSchema)); + } + + Action report; + string message; + EntitySchema existing; + + lock (gate) { + if (schema == null) { + sealSchema(); + if (schema == null) { + throw new InvalidOperationException( + $"Initializing the EntitySchema from {schemaSource} did not create one."); + } + return schema; + } + + // A different mechanism is always a bug, flag or no flag. + if (source != schemaSource) { + throw AlreadyCreated(schemaSource); + } + // Same mechanism, and nobody opted into tolerating repeats: this is the default, and it fails. + if (onRepeatedInitialization == null) { + throw AlreadyCreated(schemaSource); + } + // Tolerating a repeat is only safe if this caller would have produced the same schema. If it + // would not, its component types are NOT the ones in the sealed schema, and quietly handing it + // the existing schema is how struct indices come to mean different things to different callers. + // That is the bug this whole holder exists to prevent, so it fails even in the tolerant mode. + var candidate = describeCandidate(); + var inPlace = DescribeShape(schema); + if (candidate != inPlace) { + throw new InvalidOperationException( + $"EntitySchema was already created from {source}, and a repeated initialization from " + + $"{schemaSource} would have produced a DIFFERENT schema. Repeated initialization is " + + "allowed for this process, but only when the shapes match: a caller whose types are not " + + "the ones in the sealed schema would read struct indices that mean something else." + + '\n' + "in place: " + inPlace + '\n' + "candidate: " + candidate); + } + report = onRepeatedInitialization; + message = $"EntitySchema was already created from {source} and has been initialized again with " + + "a matching shape. Tolerated because repeated initialization was explicitly allowed " + + "for this process."; + existing = schema; + } + + // Reported outside the lock: the sink is caller-supplied and must not run under our lock. + report(message); + return existing; + } + + /// + /// A description of what a schema is built from, comparable between a schema already created and a + /// instance that has registered types but not created one. + /// + /// It covers the registered .NET types plus the mod components, which have no .NET type and are compared + /// by struct index and size. It deliberately does NOT describe the finished component and tag tables: + /// those are only populated by SchemaTypes.CreateSchemaTypes during real creation, so producing + /// them for a candidate would mean building a second schema, and that mutates Friflo's process-global + /// type state. Registered types plus mod components is what can be compared without side effects, and it + /// is enough to catch a caller whose registrations differ from the sealed schema's. + /// + /// + internal static string DescribeShape(EntitySchema entitySchema) + { + var types = new List(); + foreach (var pair in entitySchema.ComponentTypeByType) { + types.Add(pair.Key.FullName); + } + foreach (var pair in entitySchema.TagTypeByType) { + types.Add(pair.Key.FullName); + } + var mods = new List(); + foreach (var component in entitySchema.components) { + if (component is ModComponentType) { + mods.Add($"{component.StructIndex}:{component.StructSize}"); + } + } + return Describe(types, mods); + } + + /// + internal static string DescribeShape(IEnumerable registeredTypes, List components) + { + var types = new List(); + foreach (var type in registeredTypes) { + types.Add(type.FullName); + } + var mods = new List(); + foreach (var component in components) { + if (component is ModComponentType) { + mods.Add($"{component.StructIndex}:{component.StructSize}"); + } + } + return Describe(types, mods); + } + + private static string Describe(List types, List mods) + { + types.Sort(StringComparer.Ordinal); + mods.Sort(StringComparer.Ordinal); + return "types[" + string.Join(",", types) + "] mods[" + string.Join(",", mods) + "]"; + } + + internal static void Set(EntitySchema entitySchema, EntitySchemaSource schemaSource) + { + if (entitySchema == null) { + throw new ArgumentNullException(nameof(entitySchema)); + } + if (schemaSource == EntitySchemaSource.NotCreated) { + throw new ArgumentOutOfRangeException(nameof(schemaSource), + "A created schema must record how it was created."); + } + lock (gate) { + if (schema != null) { + throw AlreadyCreated(schemaSource); + } + defaultHeapMap = new StructHeap[entitySchema.maxStructIndex]; + source = schemaSource; + // Written last, so a caller that ignored the ordering contract is more likely to fail on + // NotCreated than to read a half-published holder. Defence in depth, not a guarantee: without + // the required happens-before there is no guarantee to give. + schema = entitySchema; + } + } + + /// + /// The schema exists and something tried to create a second one. The two mechanisms are mutually + /// exclusive: a reflection scan builds its own set of schema types and ignores anything registered on a + /// instance, so they cannot be combined or merged. + /// + /// This takes gate, unlike the read path. It is an error path where the cost is irrelevant, and a + /// caller racing to create is precisely the case where an unsynchronized read of source could + /// name the wrong mechanism, or none, in the one message someone will use to work out what happened. + /// + /// + internal static InvalidOperationException AlreadyCreated(EntitySchemaSource attempted) + { + lock (gate) { + var already = source == attempted + ? "It was already created the same way, so this is a duplicate initialization." + : "The two mechanisms are mutually exclusive: exactly one of them creates the schema, and a " + + "reflection scan cannot see types registered explicitly, nor the other way round."; + return new InvalidOperationException( + $"EntitySchema already created from {source}, and {attempted} tried to create it again. " + + "The schema is immutable and there can be only one per process. " + already); + } + } + + private static InvalidOperationException NotCreated() => new InvalidOperationException( + "EntitySchema has not been created. It must be created explicitly, in this order:" + '\n' + + " 1. load every mod assembly" + '\n' + + " 2. register every component, tag and script type" + '\n' + + " 3. create the schema - SchemaBootstrap.InitializeFromRegisteredTypes(aot) for explicit " + + "registration, or SchemaBootstrap.InitializeFromLoadedAssemblies() to scan loaded assemblies. " + + "Exactly one of them." + '\n' + + " 4. only then create an EntityStore" + '\n' + + "The schema is never created implicitly, so whatever asked for it here ran too early."); +} diff --git a/src/ECS/Base/NativeAOT.cs b/src/ECS/Base/NativeAOT.cs index 202f5c2b9..46199a0de 100644 --- a/src/ECS/Base/NativeAOT.cs +++ b/src/ECS/Base/NativeAOT.cs @@ -1,4 +1,4 @@ -// Copyright (c) Ullrich Praetz - https://github.com/friflo. All rights reserved. +// Copyright (c) Ullrich Praetz - https://github.com/friflo. All rights reserved. // See LICENSE file in the project root for full license information. using System; @@ -26,18 +26,20 @@ public sealed partial class NativeAOT private static NativeAOT Instance; [ExcludeFromCodeCoverage] - internal static EntitySchema GetSchema() - { - var schema = Instance?.entitySchema; - if (schema != null) { - return schema; - } - return CreateDefaultSchema(); - } - + internal static EntitySchema GetSchema() => EntitySchemaHolder.Schema; + + /// + /// Dead: this was the silent fallback that built an engine-types-only schema when none had been + /// created, leaving every later component lookup pointing at the wrong table. The schema is now + /// created explicitly or not at all. + /// [ExcludeFromCodeCoverage] +#pragma warning disable CS0162 // unreachable code - body kept for reference private static EntitySchema CreateDefaultSchema() { + throw new InvalidOperationException( + "NativeAOT.CreateDefaultSchema is dead code. The EntitySchema is never created implicitly."); + var schema = Instance?.entitySchema; if (schema != null) { return schema; @@ -68,28 +70,56 @@ at Friflo.Engine.ECS.EntityStore..ctor(PidType) + 0x43 at Friflo.Engine.ECS.EntityStore..ctor() + 0x1a */ } - +#pragma warning restore CS0162 + private EntitySchema CreateSchemaInternal() { - InitSchema(); + if (EntitySchemaHolder.IsCreated) { + throw EntitySchemaHolder.AlreadyCreated(EntitySchemaSource.RegisteredTypes); + } + RegisterEngineTypes(); var dependants = schemaTypes.CreateSchemaTypes(assemblies); entitySchema = new EntitySchema(dependants, schemaTypes); Instance = this; + EntitySchemaHolder.Set(entitySchema, EntitySchemaSource.RegisteredTypes); return entitySchema; } + /// + /// Creates the schema from the types registered on this instance. + /// Prefer : schema creation is routed through + /// so there is a single place that documents how and when it happens. + /// public EntitySchema CreateSchema() { Console.WriteLine("NativeAOT.CreateSchema()"); return CreateSchemaInternal(); } - private void InitSchema() + /// + /// The shape of the types registered on this instance, for comparing a repeated initialization against + /// the schema already in place. Registers the engine types first, exactly as schema creation would, so + /// the description covers the same set a real creation would produce. + /// + internal string DescribeRegisteredTypes() + { + RegisterEngineTypes(); + return EntitySchemaHolder.DescribeShape(typeSet, schemaTypes.components); + } + + /// + /// Adds the engine's own types, once per instance. Called by every registration entry point so they are + /// present however the instance is used. + /// + /// Deliberately NOT guarded on "a schema already exists". Registering into an instance that will not + /// create the schema is harmless - it is a throwaway object - and it is what a process with more than one + /// container does. The guard that matters lives on creation instead: see CreateSchemaInternal, + /// EntitySchemaHolder.Initialize and EntitySchemaHolder.Set. + /// + /// + private void RegisterEngineTypes() { - if (Instance?.entitySchema != null) { - throw new InvalidOperationException("EntitySchema already created"); - } if (engineTypesRegistered) { return; } @@ -123,7 +153,7 @@ private void AddType(Type type, SchemaTypeKind kind) public void RegisterComponent() where T : struct, IComponent { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Component); SchemaUtils.CreateComponentType(0, null, null); // dummy call to prevent trimming required type info @@ -134,7 +164,7 @@ public void RegisterIndexedComponentClass() where T : struct, IIndexedComponent where TValue : class { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Component); @@ -150,7 +180,7 @@ public void RegisterIndexedComponentStruct() where T : struct, IIndexedComponent where TValue : struct { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Component); @@ -165,7 +195,7 @@ public void RegisterIndexedComponentStruct() public void RegisterIndexedComponentEntity() where T : struct, ILinkComponent { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Component); @@ -180,7 +210,7 @@ public void RegisterIndexedComponentEntity() public void RegisterRelation() where T : struct, IRelation { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Component); @@ -195,7 +225,7 @@ public void RegisterRelation() public void RegisterLinkRelation() where T : struct, ILinkRelation { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Component); @@ -209,7 +239,7 @@ public void RegisterLinkRelation() public void RegisterTag() where T : struct, ITag { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Tag); SchemaUtils.CreateTagType(0); // dummy call to prevent trimming required type info @@ -218,7 +248,7 @@ public void RegisterTag() where T : struct, ITag public void RegisterScript() where T : Script, new() { - InitSchema(); + RegisterEngineTypes(); if (typeSet.Add(typeof(T))) { AddType(typeof(T), SchemaTypeKind.Script); SchemaUtils.CreateScriptType(0); // dummy call to prevent trimming required type info diff --git a/src/ECS/Base/NativeAOT_PluginComponents.cs b/src/ECS/Base/NativeAOT_PluginComponents.cs index 3ae26cfd8..d2dddd63d 100644 --- a/src/ECS/Base/NativeAOT_PluginComponents.cs +++ b/src/ECS/Base/NativeAOT_PluginComponents.cs @@ -7,7 +7,16 @@ namespace Friflo.Engine.ECS; public sealed partial class NativeAOT { - public static bool SchemaCreated => Instance?.entitySchema != null; + /// + /// Whether the process-wide schema has been created. Reading this never creates it. + /// + /// Deliberately NOT public: outside this assembly it only enables `if (!SchemaCreated) Create()`, which + /// is unsound across threads. Use or + /// and read the result, or + /// for diagnostics. + /// + /// + internal static bool SchemaCreated => EntitySchemaHolder.IsCreated; public int RegisterModComponent(ModComponentRegistration pointers) { diff --git a/src/ECS/Base/SchemaBootstrap.cs b/src/ECS/Base/SchemaBootstrap.cs new file mode 100644 index 000000000..524e13e4f --- /dev/null +++ b/src/ECS/Base/SchemaBootstrap.cs @@ -0,0 +1,137 @@ +// Copyright (c) ReadyM / ReadyCode Limited. All rights reserved. +// Friflo.Engine.ECS fork addition. + +using System; + +// ReSharper disable once CheckNamespace +namespace Friflo.Engine.ECS; + +/// +/// How the process-wide was created. Exactly one mechanism creates it: they are +/// mutually exclusive, not combinable. +/// +public enum EntitySchemaSource +{ + /// No schema has been created yet. Creating an in this state throws. + NotCreated, + + /// Created by from types + /// registered on a instance. + RegisteredTypes, + + /// Created by by scanning the + /// assemblies that were loaded at the time. + LoadedAssemblies, +} + +/// +/// The entry point for creating the process-wide . Every path goes through here, +/// so there is one place to look for how and when the schema comes into existence. +/// +/// There are two mechanisms, both explicit, differing only in how component types are discovered: +/// +/// - every type registered by hand on a +/// instance. Required when component types are not .NET types in this runtime, +/// for example server-side mod components that cross a runtime boundary as a stride plus function +/// pointers. +/// - component, tag and script types discovered by +/// scanning the assemblies currently loaded. Only sees what is loaded when it runs, so every mod +/// assembly must already be loaded. +/// +/// Exactly one of them creates the schema. Mixing them throws: a reflection scan cannot see types +/// registered explicitly, nor the other way round, so there is no meaningful way to combine them. +/// +/// +/// Initializing twice is a hard failure by default. A process that legitimately builds many containers over +/// one schema can opt out with , and even then the repeat +/// must produce a matching schema shape. Callers do not check first, and deliberately cannot: see the +/// synchronization contract in EntitySchemaHolder for why no "has it been created" flag is published. +/// +/// +/// Whichever you use, call it after all mods are loaded and before the first +/// exists. That ordering is the caller's responsibility and no amount of locking here can supply it. +/// +/// +public static class SchemaBootstrap +{ + /// + /// How the schema was created, or if it has not been. + /// For diagnostics and assertions. Do NOT branch on this to decide whether to initialize: call + /// TryInitialize... unconditionally and read its result instead. + /// + public static EntitySchemaSource SchemaSource => EntitySchemaHolder.Source; + + /// + /// Whether is in effect for this process. Whether a + /// repeated initialization throws depends on it, so a test asserting either behaviour should state which + /// mode it expects rather than assume the process it happens to run in. + /// + public static bool RepeatedInitializationAllowed => EntitySchemaHolder.RepeatsAllowed; + + /// + /// Downgrades a repeated initialization through the same mechanism from a hard failure to a report on + /// . FOR TEST PROCESSES ONLY. + /// + /// A test process legitimately builds many containers over one schema: the schema is process-global and + /// immutable, so every container after the first has nothing to create. Production has one container and + /// must keep the strict behaviour, because a second initialization there means something is registering + /// component types that are not in the sealed schema. + /// + /// + /// This does not make repeats unconditionally safe, and is not meant to. A repeat whose schema shape + /// differs from the one in place still fails: that caller's component types are not the ones in the + /// sealed schema, and letting it continue is exactly how struct indices come to mean different things to + /// different callers. Enabling this also requires somewhere to report to, so it cannot be turned on + /// silently. + /// + /// + public static void AllowRepeatedInitializationForTests(Action onRepeat) + => EntitySchemaHolder.AllowRepeatedInitialization(onRepeat); + + /// Restores the default, where a repeated initialization is a hard failure. + public static void DisallowRepeatedInitialization() + => EntitySchemaHolder.DisallowRepeatedInitialization(); + + /// + /// Creates the schema from the types registered on , unless it already exists. + /// Register every component, tag, script, indexed component and mod component on it first: the schema is + /// immutable, so anything missing is missing for the lifetime of the process. + /// + /// A schema already exists and this call is not a permitted + /// repeat: it came from the other mechanism, repeats are not allowed, or the shapes differ. + public static EntitySchema InitializeFromRegisteredTypes(NativeAOT aot) + { + if (aot == null) { + throw new ArgumentNullException(nameof(aot)); + } + return EntitySchemaHolder.Initialize( + EntitySchemaSource.RegisteredTypes, + // Only called to validate a tolerated repeat, never on the creating path. + aot.DescribeRegisteredTypes, + // NativeAOT.CreateSchema seals through EntitySchemaHolder.Set. + () => aot.CreateSchema()); + } + + /// + /// Creates the schema by scanning the assemblies currently loaded for , + /// and types, unless it already exists. + /// + /// Call this only once every mod assembly is loaded. Types in assemblies loaded afterwards are absent + /// from the schema permanently, and using one then throws from . + /// + /// + /// A schema already exists and this call is not a permitted + /// repeat: it came from the other mechanism, repeats are not allowed, or the shapes differ. + public static EntitySchema InitializeFromLoadedAssemblies() + { + return EntitySchemaHolder.Initialize( + EntitySchemaSource.LoadedAssemblies, + // Only called to validate a tolerated repeat. It rescans, which is the price of checking that a + // repeat would have produced the same schema, and is paid only on that path. + static () => EntitySchemaHolder.DescribeShape(SchemaUtils.CreateSchemaByReflection()), + // The scan runs only when this call is the one creating the schema, so the common path does not + // pay for walking and force-loading the whole reference graph twice. + static () => EntitySchemaHolder.Set( + SchemaUtils.CreateSchemaByReflection(), EntitySchemaSource.LoadedAssemblies)); + } +} diff --git a/src/ECS/Base/SchemaUtils.cs b/src/ECS/Base/SchemaUtils.cs index 5d2871a24..a2640f0f6 100644 --- a/src/ECS/Base/SchemaUtils.cs +++ b/src/ECS/Base/SchemaUtils.cs @@ -29,9 +29,22 @@ private static bool RegisterComponentTypesByReflection() #endif } + /// Runs the reflection scan. Used only by . + internal static EntitySchema CreateSchemaByReflection() => RegisterTypes(); + + /// + /// Dead: implicit schema creation was removed. The schema is created explicitly through + /// or , + /// and read through . + /// [ExcludeFromCodeCoverage] +#pragma warning disable CS0162 // unreachable code - body kept for reference internal static EntitySchema RegisterSchemaTypes() { + throw new InvalidOperationException( + "SchemaUtils.RegisterSchemaTypes is dead code. The EntitySchema is never created implicitly; " + + "create it explicitly via NativeAOT.CreateSchema() or SchemaBootstrap.CreateFromLoadedAssemblies()."); + if (NativeAOT.SchemaCreated) { return NativeAOT.GetSchema(); @@ -44,7 +57,8 @@ internal static EntitySchema RegisterSchemaTypes() return RegisterTypes(); } - +#pragma warning restore CS0162 + private static EntitySchema RegisterTypes() { var assemblyLoader = new AssemblyLoader(); diff --git a/src/ECS/CommandBuffer/CommandBuffer.cs b/src/ECS/CommandBuffer/CommandBuffer.cs index d6fbf5723..35f19a0ee 100644 --- a/src/ECS/CommandBuffer/CommandBuffer.cs +++ b/src/ECS/CommandBuffer/CommandBuffer.cs @@ -118,7 +118,7 @@ internal void Reset(bool hasComponentChanges) // use nested class to minimize noise in debugger private static class Static { - internal static readonly ComponentType[] ComponentTypes = EntityStoreBase.Static.EntitySchema.components; + internal static ComponentType[] ComponentTypes => EntityStoreBase.Static.EntitySchema.components; } #endregion