diff --git a/doc/docs/Advanced/Expressions/index.md b/doc/docs/Advanced/Expressions/index.md index 835f938e..ce991194 100644 --- a/doc/docs/Advanced/Expressions/index.md +++ b/doc/docs/Advanced/Expressions/index.md @@ -79,6 +79,7 @@ var textureLinks = Comp.Matching(Match.Object); | `Match.Entity` | Entity-to-entity relations | | `Match.Object` | Object links | | `Match.Target` | Any relation (Entity or Object, not Plain) | +| `Match.Family` | Plain components of the type or any derived type (read-only as a Stream Type: `ForRead`/enumeration) | ## Boxed Components :neofox_box: diff --git a/doc/docs/Basic/Queries/Matching.md b/doc/docs/Basic/Queries/Matching.md index 2b71fb9b..3be7cb06 100644 --- a/doc/docs/Basic/Queries/Matching.md +++ b/doc/docs/Basic/Queries/Matching.md @@ -35,6 +35,7 @@ Match Expressions define which Entities your Query contains – by the Compone | `Match.Target` | Match any actual target (Object or Entity) | | `Match.Object` | Match only Object Links | | `Match.Entity` | Match only Entity Relations | +| `Match.Family` | Match plain components of the type *or any type derived from it* | ::: ## Two Intertwined Concepts @@ -145,6 +146,7 @@ In ECS, the presence of a component often carries meaning in itself. Queries exp | `Match.Target` | Any actual target (Object or Entity, not Plain) | | `Match.Object` | Only Object Links | | `Match.Entity` | Only Entity Relations | +| `Match.Family` | Plain components of the type *or any derived type* (see below) | ```cs // Match entities with any Damage relation (to any entity) @@ -162,6 +164,51 @@ var followersOfBob = world.Query() Wildcards aren't just for matching – [`Remove(Match)`](/docs/Basic/Entities/ComponentRemove.md#removing-with-wildcards) accepts them on Entities, EntityRefs (inside runners), Batches, and Templates to strip all matching components at once. ::: +### Inheritance-Aware Matching (Family) + +`Match.Family` matches plain components of the given type **or any type derived from it** — the type +itself included. Base classes are discovered automatically from each component type's inheritance +chain (base classes only; interfaces do not participate), without consuming component TypeIDs. + +```cs +class Animal { public int Age; } +class Fox : Animal { } +class Fennec : Fox { } +record struct Active; + +// Matches entities carrying Animal, Fox, or Fennec as a plain component. +var animals = world.Query() + .Has(Match.Family) + .Compile(); + +// Works in Not/Any clauses and stream filters, too. +var noAnimals = world.Query().Stream() + .Not(Comp.Matching(Match.Family)); +``` + +As a **Stream Type**, `Match.Family` delivers derived components *viewed as their base type* — +read-only. Use the `ForRead` runners (components arrive as `in` parameters) or enumeration: + +```cs +var stream = world.Query(Match.Family).Stream(); + +// Fox and Fennec components arrive as Animal references; mutate them through their members. +stream.ForRead((in Animal animal) => animal.Age++); + +foreach (var (entity, animal) in stream) { /* animal may be a Fennec */ } +``` + +::: warning :neofox_think: READ-ONLY REFERENCES +A `Storage` holds only Fennecs, so a Family Stream Type can never hand out a *writable* +`ref Animal` — writing a plain `Animal` into that slot would corrupt the storage. Family streams +therefore offer only `ForRead` (mutate class components in place through `in` references; the +component slot itself cannot be reassigned) and enumeration. The `ref`-based `For`, `Job`, `Raw`, +`Blit`, and `FilteredStream` views throw on Family streams. + +`Match.Family` also matches **plain components only** — never Entity Relations or Object Links. On +structs (which have no user base classes) it degrades to matching the type itself. +::: + ## Conflicting Match Expressions Some expressions can cause a Query to always be empty: @@ -225,4 +272,4 @@ owingBobNotMe.For(PayOffDebt); | Target entity may despawn | Use Filters | | Many entities share same relation | Use Filters (faster iteration) | | Relations change frequently | Use broader Query + Filters | -| Static, unchanging criteria | Hard-baked Query is fine | \ No newline at end of file +| Static, unchanging criteria | Hard-baked Query is fine | diff --git a/doc/misc/Changelog.md b/doc/misc/Changelog.md index ef7fe1c0..668b7b19 100644 --- a/doc/misc/Changelog.md +++ b/doc/misc/Changelog.md @@ -53,6 +53,15 @@ Here be ~~dragons~~ more foxes. *What did you expect?* - A `FilteredStream` honors all its filters in `For`, `Job`, `Count`, enumeration, and `Despawn`. `Raw`/`Blit` remain on the unfiltered `Stream` (reachable via the `.Stream` property). - ✨ Two-delegate runners: every `For`/`Job` variant gains an `(included, excluded)` overload that partitions the Query in a single pass – `excluded` visits pruned Archetypes and predicate-rejected Entities. - Jobs no longer share `CountdownEvent` state between Stream copies; each invocation synchronizes independently. +- **Bitwise Query Matching** (0.7.0): Archetype/Query matching now runs on dense TypeId bit planes plus a + 256-bit bloom filter for specific relation/link keys, replacing the wildcard-expanded set operations. + Matching is allocation-free and several times faster in Archetype-rich Worlds; wildcard semantics are + unchanged. +- ✨ **`Match.Family`** (0.7.0): inheritance-aware matching – `Has(Match.Family)` matches plain + Components of a type *or any type derived from it* (base classes only). As a Stream Type, derived + Components arrive read-only, viewed as their base type, via the new `ForRead` runners (`in` + parameters) and enumeration; writable surfaces (`For`/`Job`/`Raw`/`Blit`/filter views) throw. + ([documentation](/docs/Basic/Queries/Matching.md#inheritance-aware-matching-family)) - Stream runners return their own Stream, allowing chaining operations. - Chunked Component Storage (global, or maybe each World may have its own chunk size) - `Match.Object` becomes internal / deprecated, use `Link.Any` instead. diff --git a/doc/skills/fennecs/SKILL.md b/doc/skills/fennecs/SKILL.md index 5f3528c0..cc556e81 100644 --- a/doc/skills/fennecs/SKILL.md +++ b/doc/skills/fennecs/SKILL.md @@ -100,6 +100,7 @@ otherwise: | `Match.Target` | any relation or link (excludes plain) | | `Match.Entity` | any entity-entity relation | | `Match.Object` | any object link | +| `Match.Family` | plain components of the type OR any derived type (self included; base classes only, no interfaces). As a stream type: read-only — use `ForRead` (`in` params) or enumeration; ref runners/Job/Raw/Blit/filter views throw | | an `Entity` (implicit) / `Match.Relation(e)` | relation to that specific entity | | `Link.With(obj)` / `Match.Link(obj)` | link to that specific object | diff --git a/doc/skills/fennecs/references/queries-and-streams.md b/doc/skills/fennecs/references/queries-and-streams.md index d5ea9326..b50c8235 100644 --- a/doc/skills/fennecs/references/queries-and-streams.md +++ b/doc/skills/fennecs/references/queries-and-streams.md @@ -26,10 +26,29 @@ Every `Has/Not/Any` accepts an optional `Match` (see SKILL.md table) or a .Has(Match.Entity) // any entity relation .Has(Link.With(sharedTexture)) // specific object link .Not(Entity.Any) // no entity relation of this type at all +.Has(Match.Family) // plain Animal OR any type derived from it ``` `Entity.Any` ≡ `Match.Entity`; `Link.Any` ≡ `Match.Object`. +`Match.Family` is inheritance-aware: it matches plain components of the named +type or any type derived from it (self included; base-class chains only, +interfaces don't participate; structs degrade to self-only). Legal in +`Has/Not/Any` and stream filters (`Comp.Matching(Match.Family)`), and as a +*stream type* with **read-only** data access: derived components arrive viewed +as their base type via the `ForRead` runners (`in` parameters — mutate class +components through their members; the slot can't be reassigned) or enumeration. +The writable surfaces (`ref`-based `For`, `Job`, `Raw`, `Blit`, and +`Has/Not/Where` filter views) throw on Family streams, because a writable +`ref Base` over a `Storage` could store a non-derived instance: + +```csharp +var animals = world.Query(Match.Family).Stream(); +animals.ForRead((in Animal a) => a.Tick()); // Fox, Fennec arrive as Animal +animals.ForRead(dt, (float dt, in Animal a) => …); // uniform variant +animals.ForRead((in EntityRef e, in Animal a) => …); // entity variant (4 overloads total) +``` + - Queries are cached per world: compiling an identical expression returns the cached instance. Hold streams/queries in fields; don't rebuild per frame (rebuilding works, it's just pointless allocation). diff --git a/src/fennecs.benchmarks/ECS/ArchetypeMatchingBenchmarks.cs b/src/fennecs.benchmarks/ECS/ArchetypeMatchingBenchmarks.cs new file mode 100644 index 00000000..d8a0d40f --- /dev/null +++ b/src/fennecs.benchmarks/ECS/ArchetypeMatchingBenchmarks.cs @@ -0,0 +1,197 @@ +using System.Collections.Immutable; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; + +using fennecs; + +namespace Benchmark.ECS; + +/// +/// Archetype/query matching cost: the bit-plane + bloom path (ArchetypeBits/MaskBits) against a +/// reference reproducing the retired approach (wildcard-expanded ImmutableSortedSet set operations). +/// +/// The match loop is the per-call cost of FilteredStream.Includes and the per-creation cost of +/// archetype/query matching, scaled by distinct Archetypes. +/// +[ShortRunJob] +[MemoryDiagnoser] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +[HideColumns("Job", "Error", "RatioSD")] +[MedianColumn] +public class ArchetypeMatchingBenchmarks +{ + private record struct Position(int Value); + + // Cold relation, splinters one Archetype per distinct target. + private record struct Grouping; + + // Present on every 2nd Archetype: selectivity for the Has/Not filters. + private record struct Tagged; + + private class BaseThing; + + private class DerivedThing : BaseThing; + + + // ReSharper disable once UnusedAutoPropertyAccessor.Global + [Params(16, 256, 4096)] + public int Fragments { get; set; } + + private World _world = null!; + private Archetype[] _archetypes = null!; + private ImmutableSortedSet[] _expanded = null!; + + private FilteredStream _filtered; + + private MaskBits _compositeBits; + private MaskBits _specificBits; + private MaskBits _familyBits; + + private SortedSet _referenceHas = null!; + private SortedSet _referenceNot = null!; + private SortedSet _referenceSpecificHas = null!; + + + [GlobalSetup] + public void Setup() + { + _world = new World(Fragments * 2); + + var targets = new Entity[Fragments]; + for (var i = 0; i < Fragments; i++) targets[i] = _world.Spawn(); + + for (var i = 0; i < Fragments; i++) + { + var entity = _world.Spawn() + .Add(new Position(i)) + .Add(new Grouping(), targets[i]); + + if (i % 2 == 0) entity.Add(new Tagged()); + if (i % 3 == 0) entity.Add(new DerivedThing()); + } + + _filtered = _world.Query().Stream() + .Has(Comp.Plain) + .Not(Comp.Matching(Match.Any)); + + _archetypes = _filtered.Stream.Query.Archetypes.ToArray(); + _expanded = _archetypes.Select(archetype => Expand(archetype.Signature)).ToArray(); + + // Composite: plain Has + plain Not + Entity-wildcard Has. + var composite = new Mask() + .Has(TypeExpression.Of(Match.Plain)) + .Has(TypeExpression.Of(Match.Entity)) + .Not(TypeExpression.Of(Match.Plain)); + _compositeBits = MaskBits.Of(composite); + _referenceHas = [TypeExpression.Of(Match.Plain), TypeExpression.Of(Match.Entity)]; + _referenceNot = [TypeExpression.Of(Match.Plain)]; + + // Specific relation: exercises the keyed bloom (mass rejection) + precise confirm (one hit). + var specific = new Mask() + .Has(TypeExpression.Of(Match.Plain)) + .Has(TypeExpression.Of(Match.Relation(targets[0]))); + _specificBits = MaskBits.Of(specific); + _referenceSpecificHas = + [TypeExpression.Of(Match.Plain), TypeExpression.Of(Match.Relation(targets[0]))]; + + // Inheritance-aware: no reference exists, the retired machinery could not express this. + var family = new Mask().Has(TypeExpression.Of(Match.Family)); + _familyBits = MaskBits.Of(family); + } + + + [GlobalCleanup] + public void Cleanup() => _world.Dispose(); + + + // The retired matching structure: each concrete expression plus its wildcard-equivalent forms. + private static ImmutableSortedSet Expand(Signature signature) + { + var builder = ImmutableSortedSet.CreateBuilder(); + + foreach (var expression in signature) + { + builder.Add(expression); + var key = expression.Key; + + if (key == default) + { + builder.Add(expression.WithKey(Key.Any)); + } + else if (key.IsObject) + { + builder.Add(expression.WithKey(Key.Any)); + builder.Add(expression.WithKey(Key.Target)); + builder.Add(expression.WithKey(Key.AnyObject)); + } + else if (key.IsEntity) + { + builder.Add(expression.WithKey(Key.Any)); + builder.Add(expression.WithKey(Key.Target)); + builder.Add(expression.WithKey(Key.AnyEntity)); + } + } + + return builder.ToImmutable(); + } + + + private static bool ReferenceMatches(ImmutableSortedSet expanded, + SortedSet has, SortedSet not) => + !expanded.Overlaps(not) && expanded.IsSupersetOf(has); + + + [Benchmark(Baseline = true)] + public int Composite_Reference() + { + var count = 0; + foreach (var expanded in _expanded) + if (ReferenceMatches(expanded, _referenceHas, _referenceNot)) count++; + return count; + } + + + [Benchmark] + public int Composite_Bits() + { + var count = 0; + foreach (var archetype in _archetypes) + if (archetype.Matches(_compositeBits)) count++; + return count; + } + + + [Benchmark] + public int SpecificRelation_Reference() + { + var count = 0; + foreach (var expanded in _expanded) + if (expanded.IsSupersetOf(_referenceSpecificHas)) count++; + return count; + } + + + [Benchmark] + public int SpecificRelation_Bits() + { + var count = 0; + foreach (var archetype in _archetypes) + if (archetype.Matches(_specificBits)) count++; + return count; + } + + + [Benchmark] + public int Family_Bits() + { + var count = 0; + foreach (var archetype in _archetypes) + if (archetype.Matches(_familyBits)) count++; + return count; + } + + + [Benchmark] + public int FilteredStream_Count() => _filtered.Count; +} diff --git a/src/fennecs.benchmarks/Program.cs b/src/fennecs.benchmarks/Program.cs index 34d06217..6d5f5e8e 100644 --- a/src/fennecs.benchmarks/Program.cs +++ b/src/fennecs.benchmarks/Program.cs @@ -24,6 +24,7 @@ [ //typeof(DorakuBenchmarks), //typeof(FilterBenchmarks), + typeof(ArchetypeMatchingBenchmarks), typeof(AspectFragmentationBenchmarks), typeof(MicrocodeBenchmarks), typeof(SimpleEntityBenchmarks), diff --git a/src/fennecs.tests/ArchetypeTests.cs b/src/fennecs.tests/ArchetypeTests.cs index 7613980a..5aa6e16f 100644 --- a/src/fennecs.tests/ArchetypeTests.cs +++ b/src/fennecs.tests/ArchetypeTests.cs @@ -32,8 +32,8 @@ public void Matches_Requires_At_Least_One_Any_Type() mask.Any(TypeExpression.Of(Match.Plain)); mask.Any(TypeExpression.Of(Match.Plain)); - Assert.False(world.GetEntityMeta(plain).Archetype.Matches(mask)); - Assert.True(world.GetEntityMeta(tagged).Archetype.Matches(mask)); + Assert.False(world.GetEntityMeta(plain).Archetype.Matches(MaskBits.Of(mask))); + Assert.True(world.GetEntityMeta(tagged).Archetype.Matches(MaskBits.Of(mask))); } diff --git a/src/fennecs.tests/ExhaustionTests.cs b/src/fennecs.tests/ExhaustionTests.cs index 0d643d10..0e449649 100644 --- a/src/fennecs.tests/ExhaustionTests.cs +++ b/src/fennecs.tests/ExhaustionTests.cs @@ -38,6 +38,12 @@ public void Too_Many_Concurrent_Worlds_Throws() public class TypeIdExhaustionTests { + private class RegistrationRoot; + + private class RegistrationMiddle : RegistrationRoot; + + private class RegistrationLeaf : RegistrationMiddle; + [Fact] public void TypeId_Space_Exhaustion_Throws() { @@ -47,9 +53,26 @@ public void TypeId_Space_Exhaustion_Throws() } + [Fact] + public void Registering_Derived_Component_Consumes_One_TypeId() + { + Assert.Equal(1, RegistryProbe.RegisterAndCount()); + } + + // Grants test access to the protected registry internals of LanguageType. private class RegistryProbe : LanguageType { + internal static int RegisterAndCount() + { + lock (RegistryLock) + { + var before = Counter; + _ = LanguageType.Id; + return Counter - before; + } + } + internal static TypeInitializationException ProvokeExhaustion() { lock (RegistryLock) diff --git a/src/fennecs.tests/ExpressionTests.cs b/src/fennecs.tests/ExpressionTests.cs index 645bf53b..ea387c8a 100644 --- a/src/fennecs.tests/ExpressionTests.cs +++ b/src/fennecs.tests/ExpressionTests.cs @@ -142,107 +142,8 @@ public static void Specific_Object_Matches_only_Specific() Assert.False(matchObject.Matches(compObjectWrong)); } - [Fact] - public static void Can_Expand_Plain() - { - var type = TypeExpression.Of(Match.Plain); - - var expanded = type.Expand(); - var anyInt = TypeExpression.Of(Match.Any); - Assert.Contains(anyInt, expanded); - Assert.Single(expanded); - } - - [Fact] - public static void Can_Expand_EntityWildcard() - { - var type = TypeExpression.Of(Match.Entity); - - var expanded = type.Expand(); - var anyInt = TypeExpression.Of(Match.Any); - var targetInt = TypeExpression.Of(Match.Target); - Assert.Contains(anyInt, expanded); - Assert.Contains(targetInt, expanded); - Assert.Equal(2, expanded.Count); - } - - [Fact] - public static void Can_Expand_ObjectWildcard() - { - var type = TypeExpression.Of(Match.Object); - - var expanded = type.Expand(); - var anyInt = TypeExpression.Of(Match.Any); - var targetInt = TypeExpression.Of(Match.Target); - Assert.Contains(anyInt, expanded); - Assert.Contains(targetInt, expanded); - Assert.Equal(2, expanded.Count); - } - - [Fact] - public static void Can_Expand_AnyWildcard() - { - var type = TypeExpression.Of(Match.Any); - - var expanded = type.Expand(); - var targetInt = TypeExpression.Of(Match.Target); - var entityInt = TypeExpression.Of(Match.Entity); - var objectInt = TypeExpression.Of(Match.Object); - var plainInt = TypeExpression.Of(Match.Plain); - Assert.Contains(targetInt, expanded); - Assert.Contains(objectInt, expanded); - Assert.Contains(entityInt, expanded); - Assert.Contains(plainInt, expanded); - Assert.Equal(4, expanded.Count); - } - - [Fact] - public static void Can_Expand_Entity() - { - var world = new World(); - var entity = world.Spawn(); - var type = TypeExpression.Of(entity); - - var expanded = type.Expand(); - var anyInt = TypeExpression.Of(Match.Any); - var targetInt = TypeExpression.Of(Match.Target); - var entityInt = TypeExpression.Of(Match.Entity); - Assert.Contains(anyInt, expanded); - Assert.Contains(targetInt, expanded); - Assert.Contains(entityInt, expanded); - Assert.Equal(3, expanded.Count); - } - - [Fact] - public static void Can_Expand_Object() - { - var type = TypeExpression.Of(Link.With("dieter")); - - var expanded = type.Expand(); - var wildAny = TypeExpression.Of(Match.Any); - var wildTarget = TypeExpression.Of(Match.Target); - var wildObject = TypeExpression.Of(Match.Object); - Assert.Contains(wildAny, expanded); - Assert.Contains(wildTarget, expanded); - Assert.Contains(wildObject, expanded); - Assert.Equal(3, expanded.Count); - } - - [Fact] - public static void Can_Expand_Target() - { - var type = TypeExpression.Of(Match.Target); - - var expanded = type.Expand(); - var wildAny = TypeExpression.Of(Match.Any); - var wildEntity = TypeExpression.Of(Match.Entity); - var wildObject = TypeExpression.Of(Match.Object); - Assert.Contains(wildAny, expanded); - Assert.Contains(wildEntity, expanded); - Assert.Contains(wildObject, expanded); - Assert.Equal(3, expanded.Count); - } - + // The wildcard-equivalence semantics formerly tested via TypeExpression.Expand() are now + // covered pairwise by MatchBitsTests' truth-table and agreement tests. [Fact] public static void ManagedFlagIsAccurate() diff --git a/src/fennecs.tests/FamilyMatchTests.cs b/src/fennecs.tests/FamilyMatchTests.cs new file mode 100644 index 00000000..e6e2451d --- /dev/null +++ b/src/fennecs.tests/FamilyMatchTests.cs @@ -0,0 +1,317 @@ +namespace fennecs.tests; + +public class FamilyMatchTests +{ + private class Animal; + + private class Fox : Animal; + + private class Fennec : Fox; + + private class Rock; + + private struct Pebble; + + + [Fact] + public void Family_Matches_Self_And_Derived_Plain_Components() + { + using var world = new World(); + var animal = world.Spawn().Add(new Animal()); + var fox = world.Spawn().Add(new Fox()); + var fennec = world.Spawn().Add(new Fennec()); + var rock = world.Spawn().Add(new Rock()); + + var query = world.Query().Has(Match.Family).Compile(); + + Assert.True(query.Contains(animal)); + Assert.True(query.Contains(fox)); + Assert.True(query.Contains(fennec)); + Assert.False(query.Contains(rock)); + Assert.Equal(3, query.Count); + } + + + [Fact] + public void Family_Matches_Grandchild_Through_Intermediate_Base() + { + using var world = new World(); + var fennec = world.Spawn().Add(new Fennec()); + + var foxes = world.Query().Has(Match.Family).Compile(); + var animals = world.Query().Has(Match.Family).Compile(); + + Assert.True(foxes.Contains(fennec)); + Assert.True(animals.Contains(fennec)); + } + + + [Fact] + public void Family_Not_Clause_Excludes_Derived() + { + using var world = new World(); + world.Spawn().Add(new Fennec()).Add(42); + var rocky = world.Spawn().Add(new Rock()).Add(43); + + var query = world.Query().Has().Not(Match.Family).Compile(); + + Assert.Single(query); + Assert.True(query.Contains(rocky)); + } + + + [Fact] + public void Family_Any_Clause_Participates() + { + using var world = new World(); + var fox = world.Spawn().Add(new Fox()).Add(1); + var rock = world.Spawn().Add(new Rock()).Add(2); + world.Spawn().Add(3); + + var query = world.Query().Has() + .Any(Match.Family) + .Any() + .Compile(); + + Assert.Equal(2, query.Count); + Assert.True(query.Contains(fox)); + Assert.True(query.Contains(rock)); + } + + + [Fact] + public void Family_On_Struct_Matches_Only_Itself() + { + using var world = new World(); + var pebble = world.Spawn().Add(new Pebble()); + world.Spawn().Add(new Rock()); + + var query = world.Query().Has(Match.Family).Compile(); + + Assert.Single(query); + Assert.True(query.Contains(pebble)); + } + + + [Fact] + public void Family_Does_Not_Match_Relations_Or_Links() + { + using var world = new World(); + var target = world.Spawn(); + world.Spawn().Add(new Fox(), target); // Entity relation backed by Fox + world.Spawn().Add(Link.With("linked")); + + var query = world.Query().Has(Match.Family).Compile(); + + Assert.Empty(query); + } + + + [Fact] + public void Family_Works_In_FilteredStream() + { + using var world = new World(); + world.Spawn().Add(new Fennec()).Add(1); + world.Spawn().Add(new Rock()).Add(2); + world.Spawn().Add(3); + + var stream = world.Query().Stream(); + + Assert.Equal(1, stream.Has(Comp.Matching(Match.Family)).Count); + Assert.Equal(2, stream.Not(Comp.Matching(Match.Family)).Count); + } + + + private class Counter + { + public int Value; + } + + + [Fact] + public void Family_Stream_Readonly_For_Visits_Self_And_Derived() + { + using var world = new World(); + world.Spawn().Add(new Animal()); + world.Spawn().Add(new Fox()); + world.Spawn().Add(new Fennec()); + world.Spawn().Add(new Rock()); + + var stream = world.Query(Match.Family).Stream(); + + var visited = 0; + var fennecs = 0; + stream.ForRead((in Animal animal) => + { + visited++; + if (animal is Fennec) fennecs++; + }); + + Assert.Equal(3, visited); + Assert.Equal(1, fennecs); + } + + + [Fact] + public void Family_Stream_Readonly_For_Mutates_Through_Base_Reference() + { + using var world = new World(); + world.Spawn().Add(new Counter()); + + var derived = new DerivedCounter(); + world.Spawn().Add(derived); + + var stream = world.Query(Match.Family).Stream(); + stream.ForRead((in Counter counter) => counter.Value++); + + Assert.Equal(1, derived.Value); + } + + private class DerivedCounter : Counter; + + + [Fact] + public void Family_Stream_Readonly_For_Uniform_And_Entity_Variants() + { + using var world = new World(); + world.Spawn().Add(new Fox()); + world.Spawn().Add(new Fennec()); + + var stream = world.Query(Match.Family).Stream(); + + var sum = new Counter(); + stream.ForRead(sum, (Counter uniform, in Fox _) => uniform.Value++); + Assert.Equal(2, sum.Value); + + var entities = new List(); + stream.ForRead((in EntityRef entity, in Fox _) => entities.Add(entity)); + Assert.Equal(2, entities.Count); + Assert.Equal(2, entities.Distinct().Count()); + } + + + [Fact] + public void Family_Stream_Enumeration_Yields_Derived_As_Base() + { + using var world = new World(); + world.Spawn().Add(new Fox()); + world.Spawn().Add(new Fennec()); + + var stream = world.Query(Match.Family).Stream(); + + var animals = stream.Select(tuple => tuple.Item2).ToList(); + Assert.Equal(2, animals.Count); + Assert.Contains(animals, animal => animal is Fennec); + Assert.Contains(animals, animal => animal is Fox and not Fennec); + } + + + [Fact] + public void Family_Struct_Stream_Behaves_As_Plain() + { + using var world = new World(); + world.Spawn().Add(new Pebble()); + + var stream = world.Query(Match.Family).Stream(); + + var visited = 0; + stream.ForRead((in Pebble _) => visited++); + Assert.Equal(1, visited); + } + + + [Fact] + public void Family_Stream_Guards_Writable_Surfaces() + { + using var world = new World(); + world.Spawn().Add(new Animal()); + + var stream = world.Query(Match.Family).Stream(); + + Assert.Throws(() => stream.For((ref Animal _) => { })); + Assert.Throws(() => stream.For(0, (int _, ref Animal _) => { })); + Assert.Throws(() => stream.Raw(_ => { })); + Assert.Throws(() => stream.Blit(new Animal())); + Assert.Throws(() => stream.Has(Comp.Plain)); + Assert.Throws(() => stream.Not(Comp.Plain)); + Assert.Throws(() => stream.Job((ref Animal _) => { })); + Assert.Throws(() => stream.Job(0, (int _, ref Animal _) => { })); + } + + + [Fact] + public void Family_Unrelated_Base_Stays_Unmatched() + { + // Exercises both the bloom fast-reject and (on any collision) the precise ancestor confirm. + var signature = new Signature( + TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Plain)); + var bits = new ArchetypeBits(signature); + + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Family))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Family))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Family))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Family))); + } + + + [Fact] + public void Family_In_Batch_Conflict_Masks() + { + // SafeForAddition: an addition is safe when the Not clause filters it out — including via Family. + var notFamily = new Mask().Not(TypeExpression.Of(Match.Family)); + Assert.True(notFamily.SafeForAddition(TypeExpression.Of(Match.Plain))); + Assert.True(notFamily.SafeForAddition(TypeExpression.Of(Match.Plain))); + Assert.False(notFamily.SafeForAddition(TypeExpression.Of(Match.Plain))); + + // Bidirectional: a Family expression is covered by plain clause entries of derived types. + var plainClause = new Mask().Has(TypeExpression.Of(Match.Plain)); + Assert.True(plainClause.Bits.Has.MatchesBidirectional(TypeExpression.Of(Match.Family))); + Assert.False(plainClause.Bits.Has.MatchesBidirectional(TypeExpression.Of(Match.Family))); + } + + + [Fact] + public void Family_Batch_Rejects_Derived_Add_Remove_Conflicts() + { + using var world = new World(); + var query = world.Query().Compile(); + + using var addFirst = query.Batch(AddConflict.Preserve, RemoveConflict.Allow).Add(new Fennec()); + Assert.Throws(() => addFirst.Remove(Match.Family)); + + using var removeFirst = query.Batch(AddConflict.Preserve, RemoveConflict.Allow) + .Remove(Match.Family); + Assert.Throws(() => removeFirst.Add(new Fennec())); + removeFirst.Add(new Rock()); + } + + + [Fact] + public void Family_Query_Does_Not_Guarantee_Exact_Type_Wildcard_Removal() + { + using var world = new World(); + var entity = world.Spawn().Add(new Fennec()); + var query = world.Query().Has(Match.Family).Compile(); + + using (var any = query.Batch(RemoveConflict.Strict)) + Assert.Throws(() => any.Remove(Match.Any)); + using (var target = query.Batch(RemoveConflict.Strict)) + Assert.Throws(() => target.Remove(Match.Target)); + + query.Batch(RemoveConflict.Allow).Remove(Match.Any).Submit(); + Assert.True(entity.Has()); + } + + + [Fact] + public void Wildcard_Semantics_Unchanged_By_Family_Support() + { + using var world = new World(); + var plain = world.Spawn().Add(new Fox()); + + // Family never leaks into Any/Target semantics for stored plain components. + Assert.True(world.Query().Has(Match.Any).Compile().Contains(plain)); + Assert.False(world.Query().Has(Match.Target).Compile().Contains(plain)); + } +} diff --git a/src/fennecs.tests/KeyTests.cs b/src/fennecs.tests/KeyTests.cs index a9d42d8e..450b567f 100644 --- a/src/fennecs.tests/KeyTests.cs +++ b/src/fennecs.tests/KeyTests.cs @@ -38,6 +38,8 @@ public void ToString_Describes_All_Categories() Assert.Equal("wildcard[Target]", Key.Target.ToString()); Assert.Equal("wildcard[Entity]", Key.AnyEntity.ToString()); Assert.Equal("wildcard[Object]", Key.AnyObject.ToString()); + Assert.Equal("wildcard[Family]", Key.Family.ToString()); + Assert.Equal("wildcard[Family]", Match.Family.ToString()); var entity = new Entity(1, 123, 1); Assert.StartsWith("E-", entity.Key.ToString()); @@ -50,11 +52,6 @@ public void ToString_Describes_All_Categories() [Fact] public void ToString_Describes_Forged_Keys() { - // A Wildcard nibble outside the recognized categories (reserved: Family). - var forgedWildcard = new Key((ulong)SecondaryKind.Family << Key.KindShift); - Assert.True(forgedWildcard.IsWildcard); - Assert.StartsWith("wildcard[?-", forgedWildcard.ToString()); - // A specific (non-wildcard) Key that is neither an Entity nor an Object. var forgedKey = new Key(((ulong)SecondaryKind.Family << Key.KindShift) | 42); Assert.False(forgedKey.IsWildcard); diff --git a/src/fennecs.tests/MatchBitsTests.cs b/src/fennecs.tests/MatchBitsTests.cs new file mode 100644 index 00000000..02b49a5a --- /dev/null +++ b/src/fennecs.tests/MatchBitsTests.cs @@ -0,0 +1,283 @@ +namespace fennecs.tests; + +public class MatchBitsTests +{ + private class Animal; + + private class Fox : Animal; + + private class Fennec : Fox; + + private class Rock; + + private static readonly Entity E1 = new(1, 123, 1); + private static readonly Entity E2 = new(1, 456, 1); + + // Archetype content: one plain, one Entity relation, one Object Link. + private static Signature ArchetypeSignature => new( + TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Relation(E1)), + TypeExpression.Of(Match.Link("hello"))); + + + private static IEnumerable AllQueryForms() + { + Match[] matches = + [ + Match.Plain, Match.Any, Match.Target, Match.Entity, Match.Object, + Match.Relation(E1), Match.Relation(E2), + ]; + + foreach (var match in matches) + { + yield return TypeExpression.Of(match); + yield return TypeExpression.Of(match); + yield return TypeExpression.Of(match); + yield return TypeExpression.Of(match); + } + + yield return TypeExpression.Of(Match.Link("hello")); + yield return TypeExpression.Of(Match.Link("world")); + } + + + [Fact] + public void MatchesElement_Truth_Table() + { + var bits = new ArchetypeBits(ArchetypeSignature); + + // Plain: only the plain int matches. + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Plain))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Plain))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Plain))); + + // Any: every present type in any key form. + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Any))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Any))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Any))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Any))); + + // Target: non-plain forms only. + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Target))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Target))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Target))); + + // Entity / Object Wildcards: their category only. + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Entity))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Entity))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Object))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Object))); + + // Specific keys: exact target only, and only on the right type. + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Relation(E1)))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Relation(E2)))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Relation(E1)))); + Assert.True(bits.MatchesElement(TypeExpression.Of(Match.Link("hello")))); + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Link("world")))); + } + + + // Independent oracle: a query expression matches a signature iff it pairwise-matches any element. + private static bool ReferenceContains(Signature signature, TypeExpression expression) => + signature.Any(expression.Matches); + + + private static bool ReferenceFamilyMatches(TypeExpression query, TypeExpression candidate) => + query.Key == Key.Family && candidate.Key == default + && LanguageType.IsInFamily(query.TypeId, candidate.TypeId); + + + [Fact] + public void MatchesElement_Agrees_With_Pairwise_Reference() + { + var signature = ArchetypeSignature; + var bits = new ArchetypeBits(signature); + + foreach (var expression in AllQueryForms()) + Assert.Equal(ReferenceContains(signature, expression), bits.MatchesElement(expression)); + } + + + [Fact] + public void Family_Matching_Agrees_With_Inheritance_Oracle() + { + TypeExpression[] candidates = + [ + TypeExpression.Of(Match.Plain), TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Plain), TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Relation(E1)), + ]; + TypeExpression[] queries = + [ + TypeExpression.Of(Match.Family), TypeExpression.Of(Match.Family), + TypeExpression.Of(Match.Family), TypeExpression.Of(Match.Family), + ]; + + foreach (var candidate in candidates) + { + var bits = new ArchetypeBits(new Signature(candidate)); + foreach (var query in queries) + Assert.Equal(ReferenceFamilyMatches(query, candidate), bits.MatchesElement(query)); + } + + foreach (var candidate in candidates) + { + var clause = ClauseBits.Of([candidate]); + foreach (var query in queries) + Assert.Equal(ReferenceFamilyMatches(query, candidate), clause.MatchesBidirectional(query)); + } + + foreach (var query in queries) + { + var clause = ClauseBits.Of([query]); + foreach (var candidate in candidates) + Assert.Equal(ReferenceFamilyMatches(query, candidate), clause.MatchesBidirectional(candidate)); + } + } + + + // The historical formula of Archetype.Matches(Mask), rebuilt on the pairwise oracle. + private static bool ReferenceMatches(Signature signature, Mask mask) + { + if (mask.NotTypes.Any(e => ReferenceContains(signature, e))) return false; + if (!mask.HasTypes.All(e => ReferenceContains(signature, e))) return false; + return mask.AnyTypes.Count == 0 || mask.AnyTypes.Any(e => ReferenceContains(signature, e)); + } + + + private static void AssertMaskAgreement(Signature signature, Mask mask, bool? expected = null) + { + var actual = new ArchetypeBits(signature).Matches(MaskBits.Of(mask)); + Assert.Equal(ReferenceMatches(signature, mask), actual); + if (expected is not null) Assert.Equal(expected, actual); + } + + + [Fact] + public void Mask_Scenarios() + { + var signature = ArchetypeSignature; + + AssertMaskAgreement(signature, new Mask(), expected: true); + AssertMaskAgreement(signature, new Mask().Has(TypeExpression.Of(Match.Plain)), expected: true); + AssertMaskAgreement(signature, new Mask().Has(TypeExpression.Of(Match.Plain)), expected: false); + AssertMaskAgreement(signature, new Mask() + .Has(TypeExpression.Of(Match.Plain)) + .Has(TypeExpression.Of(Match.Relation(E1))), expected: true); + AssertMaskAgreement(signature, new Mask() + .Has(TypeExpression.Of(Match.Relation(E2))), expected: false); + + // Not overrides Has. + AssertMaskAgreement(signature, new Mask() + .Has(TypeExpression.Of(Match.Plain)) + .Not(TypeExpression.Of(Match.Object)), expected: false); + AssertMaskAgreement(signature, new Mask() + .Not(TypeExpression.Of(Match.Any)), expected: true); + AssertMaskAgreement(signature, new Mask() + .Not(TypeExpression.Of(Match.Relation(E2))), expected: true); + AssertMaskAgreement(signature, new Mask() + .Not(TypeExpression.Of(Match.Relation(E1))), expected: false); + + // Any: at least one entry. + AssertMaskAgreement(signature, new Mask() + .Any(TypeExpression.Of(Match.Plain)) + .Any(TypeExpression.Of(Match.Target)), expected: true); + AssertMaskAgreement(signature, new Mask() + .Any(TypeExpression.Of(Match.Plain)) + .Any(TypeExpression.Of(Match.Any)), expected: false); + } + + + [Fact] + public void Randomized_Mask_Agreement() + { + TypeExpression[] signaturePool = + [ + TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Plain), + TypeExpression.Of(Match.Relation(E1)), + TypeExpression.Of(Match.Relation(E2)), + TypeExpression.Of(Match.Relation(E1)), + TypeExpression.Of(Match.Link("hello")), + TypeExpression.Of(Match.Link("world")), + ]; + + var queryPool = AllQueryForms().ToArray(); + var random = new Random(20260805); + + for (var round = 0; round < 10_000; round++) + { + var content = signaturePool.Where(_ => random.Next(2) == 0).ToArray(); + var signature = new Signature(content); + + var mask = new Mask(); + for (var i = random.Next(0, 4); i > 0; i--) mask.Has(queryPool[random.Next(queryPool.Length)]); + for (var i = random.Next(0, 3); i > 0; i--) mask.Not(queryPool[random.Next(queryPool.Length)]); + for (var i = random.Next(0, 3); i > 0; i--) mask.Any(queryPool[random.Next(queryPool.Length)]); + + AssertMaskAgreement(signature, mask); + } + } + + + [Fact] + public void MatchesBidirectional_Agrees_With_Pairwise_Scan() + { + var pool = AllQueryForms().ToArray(); + var random = new Random(99); + + for (var round = 0; round < 2000; round++) + { + var clause = pool.Where(_ => random.Next(3) == 0).ToArray(); + var bits = ClauseBits.Of(clause); + + foreach (var expression in pool) + { + var reference = clause.Any(entry => + expression.Equals(entry) || expression.Matches(entry) || entry.Matches(expression)); + Assert.Equal(reference, bits.MatchesBidirectional(expression)); + } + } + } + + + [Fact] + public void Bloom_False_Positive_Falls_Through_To_Precise_No() + { + var target = TypeExpression.Of(Match.Relation(E1)); + var signature = new Signature(TypeExpression.Of(Match.Plain), target); + var bits = new ArchetypeBits(signature); + var targetPattern = KeyBloom.Of(target.Raw); + + // Brute-force a different relation whose bloom pattern is covered by the target's. + var found = false; + for (uint index = 1; index < 500_000 && !found; index++) + { + if (index == 123) continue; + + var candidate = TypeExpression.Of(Match.Relation(new Entity(1, index, 1))); + if (!targetPattern.MayContain(KeyBloom.Of(candidate.Raw))) continue; + + found = true; + Assert.False(bits.MatchesElement(candidate)); + Assert.False(bits.Overlaps(ClauseBits.Of([candidate]))); + Assert.False(bits.IsSupersetOf(ClauseBits.Of([candidate]))); + } + + Assert.True(found, "No bloom collision found in 500k candidates — hash may be degenerate."); + } + + + [Fact] + public void Empty_Signature_Behaves() + { + var signature = new Signature(Array.Empty()); + var bits = new ArchetypeBits(signature); + + Assert.False(bits.MatchesElement(TypeExpression.Of(Match.Any))); + AssertMaskAgreement(signature, new Mask(), expected: true); + AssertMaskAgreement(signature, new Mask().Has(TypeExpression.Of(Match.Plain)), expected: false); + } +} diff --git a/src/fennecs.tests/StorageTests.cs b/src/fennecs.tests/StorageTests.cs index 9d01bf87..72598998 100644 --- a/src/fennecs.tests/StorageTests.cs +++ b/src/fennecs.tests/StorageTests.cs @@ -6,6 +6,8 @@ private struct ValueType; private class ReferenceType; + private class DerivedReferenceType : ReferenceType; + [Fact] public void Storage_Can_Be_Created() { @@ -49,6 +51,18 @@ public void Storage_Interface_Denies_Wrong_Types() storage.Append(420); } + [Fact] + public void GetAs_Validates_Type_And_Logical_Bounds() + { + var value = new DerivedReferenceType(); + var storage = new Storage(); + storage.Append(value); + + Assert.Same(value, storage.GetAs(0)); + Assert.Throws(() => storage.GetAs(0)); + Assert.Throws(() => storage.GetAs(storage.Count)); + } + [Fact] public void Storage_Can_Blit() { diff --git a/src/fennecs.tests/TypeBitsTests.cs b/src/fennecs.tests/TypeBitsTests.cs new file mode 100644 index 00000000..70efceaf --- /dev/null +++ b/src/fennecs.tests/TypeBitsTests.cs @@ -0,0 +1,235 @@ +namespace fennecs.tests; + +public class TypeBitsTests +{ + private static TypeBits BitsOf(int maxTypeId, params int[] typeIds) + { + var words = TypeBits.AllocateFor(maxTypeId); + foreach (var id in typeIds) TypeBits.Set(words, (TypeID)id); + return new(words); + } + + + [Theory] + [InlineData(1)] + [InlineData(63)] + [InlineData(64)] + [InlineData(255)] + [InlineData(256)] + [InlineData(4094)] + public void Set_Then_Get_Roundtrips(int typeId) + { + var bits = BitsOf(typeId, typeId); + Assert.True(bits.Get((TypeID)typeId)); + Assert.False(bits.IsEmpty); + } + + + [Fact] + public void Get_Beyond_Length_Is_False() + { + var bits = BitsOf(10, 3); + Assert.False(bits.Get(3000)); + } + + + [Fact] + public void Empty_Is_Empty() + { + Assert.True(TypeBits.Empty.IsEmpty); + Assert.True(BitsOf(1000).IsEmpty); + Assert.False(TypeBits.Empty.Get(1)); + } + + + [Fact] + public void AllocateFor_Rounds_To_Blocks() + { + Assert.Equal(4, TypeBits.AllocateFor(0).Length); + Assert.Equal(4, TypeBits.AllocateFor(255).Length); + Assert.Equal(8, TypeBits.AllocateFor(256).Length); + Assert.Equal(64, TypeBits.AllocateFor(4094).Length); + } + + + [Fact] + public void ContainsAll_Subset_And_Superset() + { + var super = BitsOf(300, 1, 64, 200, 299); + var sub = BitsOf(300, 64, 299); + var other = BitsOf(300, 64, 128); + + Assert.True(super.ContainsAll(sub)); + Assert.True(super.ContainsAll(super)); + Assert.False(sub.ContainsAll(super)); + Assert.False(super.ContainsAll(other)); + Assert.True(super.ContainsAll(TypeBits.Empty)); + Assert.True(TypeBits.Empty.ContainsAll(BitsOf(300))); + } + + + [Fact] + public void ContainsAll_Zero_Extends_Both_Directions() + { + var shortBits = BitsOf(10, 3, 7); // one block + var longSame = BitsOf(3000, 3, 7); // many blocks, same content + var longHigh = BitsOf(3000, 3, 7, 2999); // high bit beyond short's length + + Assert.True(shortBits.ContainsAll(longSame)); + Assert.True(longSame.ContainsAll(shortBits)); + Assert.False(shortBits.ContainsAll(longHigh)); + Assert.True(longHigh.ContainsAll(shortBits)); + } + + + [Fact] + public void Intersects_Basics_And_Lengths() + { + var a = BitsOf(10, 3, 7); + var b = BitsOf(3000, 7, 2999); + var c = BitsOf(3000, 2999); + + Assert.True(a.Intersects(b)); + Assert.True(b.Intersects(a)); + Assert.False(a.Intersects(c)); + Assert.False(c.Intersects(a)); + Assert.False(a.Intersects(TypeBits.Empty)); + Assert.False(TypeBits.Empty.Intersects(a)); + } + + + [Fact] + public void Fused_Union_Operations_Match_Manual_Union() + { + var a = BitsOf(100, 1, 2); + var b = BitsOf(400, 300); + var c = BitsOf(100, 50); + + Assert.True(TypeBits.ContainsAll2(BitsOf(400, 1, 300), a, b)); + Assert.False(TypeBits.ContainsAll2(BitsOf(400, 1, 300, 399), a, b)); + Assert.True(TypeBits.ContainsAll3(BitsOf(400, 2, 50, 300), a, b, c)); + Assert.False(TypeBits.ContainsAll3(BitsOf(400, 2, 50, 301), a, b, c)); + + Assert.True(TypeBits.Intersects2(BitsOf(400, 300, 99), a, b)); + Assert.False(TypeBits.Intersects2(BitsOf(400, 99, 50), a, b)); + Assert.True(TypeBits.Intersects3(BitsOf(100, 50), a, b, c)); + Assert.False(TypeBits.Intersects3(BitsOf(4000, 3999), a, b, c)); + } + + + [Fact] + public void Fused_Union_Operations_Agree_Across_Blocks_And_Lengths() + { + var random = new Random(20260806); + + for (var round = 0; round < 500; round++) + { + var sets = Enumerable.Range(0, 4) + .Select(_ => new HashSet(Enumerable.Range(0, random.Next(20)) + .Select(_ => random.Next(1, 4095)))) + .ToArray(); + var bits = sets.Select(set => BitsOf(set.Count == 0 ? 1 : set.Max(), set.ToArray())).ToArray(); + var union2 = new HashSet(sets[1]); + union2.UnionWith(sets[2]); + var union3 = new HashSet(union2); + union3.UnionWith(sets[3]); + + Assert.Equal(union2.IsSupersetOf(sets[0]), TypeBits.ContainsAll2(bits[0], bits[1], bits[2])); + Assert.Equal(union3.IsSupersetOf(sets[0]), TypeBits.ContainsAll3(bits[0], bits[1], bits[2], bits[3])); + Assert.Equal(sets[0].Overlaps(union2), TypeBits.Intersects2(bits[0], bits[1], bits[2])); + Assert.Equal(sets[0].Overlaps(union3), TypeBits.Intersects3(bits[0], bits[1], bits[2], bits[3])); + } + } + + + [Fact] + public void Randomized_Agreement_With_Set_Reference() + { + var random = new Random(20260805); + + for (var round = 0; round < 500; round++) + { + var maxA = random.Next(1, 4094); + var maxB = random.Next(1, 4094); + var setA = new HashSet(Enumerable.Range(0, random.Next(0, 20)).Select(_ => random.Next(1, maxA + 1))); + var setB = new HashSet(Enumerable.Range(0, random.Next(0, 20)).Select(_ => random.Next(1, maxB + 1))); + + // Sometimes force subset/overlap relationships to hit the true branches. + if (round % 3 == 0) setB.UnionWith(setA); + if (round % 5 == 0 && setA.Count > 0) setB.Add(setA.First()); + + var bitsA = BitsOf(maxA, setA.ToArray()); + var bitsB = BitsOf(Math.Max(maxB, setB.Count == 0 ? 1 : setB.Max()), setB.ToArray()); + + Assert.Equal(setA.IsSupersetOf(setB), bitsA.ContainsAll(bitsB)); + Assert.Equal(setB.IsSupersetOf(setA), bitsB.ContainsAll(bitsA)); + Assert.Equal(setA.Overlaps(setB), bitsA.Intersects(bitsB)); + } + } + + + [Fact] + public void Bloom_Pattern_Is_Deterministic() + { + var a = KeyBloom.Of(0xDEADBEEF12345678ul); + var b = KeyBloom.Of(0xDEADBEEF12345678ul); + + Assert.True(a.MayContain(b)); + Assert.True(b.MayContain(a)); + Assert.False(a.IsEmpty); + } + + + [Fact] + public void Bloom_Never_False_Negative() + { + var random = new Random(42); + var values = Enumerable.Range(0, 64).Select(_ => (ulong)random.NextInt64()).ToArray(); + + var filter = KeyBloom.Empty; + foreach (var value in values) filter = filter.Union(KeyBloom.Of(value)); + + foreach (var value in values) Assert.True(filter.MayContain(KeyBloom.Of(value))); + } + + + [Fact] + public void Bloom_Empty_Rejects_Everything() + { + Assert.True(KeyBloom.Empty.IsEmpty); + Assert.False(KeyBloom.Empty.MayContain(KeyBloom.Of(123456789ul))); + Assert.True(KeyBloom.Empty.MayContain(KeyBloom.Empty)); + } + + + [Fact] + public void Bloom_Intersects_Certifies_Group_Absence() + { + var present = KeyBloom.Of(0x1111_2222_3333_4444ul).Union(KeyBloom.Of(0x5555_6666_7777_8888ul)); + var absentGroup = KeyBloom.Of(0x9999_AAAA_BBBB_CCCCul).Union(KeyBloom.Of(0xDDDD_EEEE_FFFF_0000ul)); + + // Overlap with itself is guaranteed; disjointness certifies absence of every group entry. + Assert.True(present.Intersects(present)); + Assert.False(present.Intersects(absentGroup)); + Assert.False(present.MayContain(KeyBloom.Of(0x9999_AAAA_BBBB_CCCCul))); + Assert.False(present.MayContain(KeyBloom.Of(0xDDDD_EEEE_FFFF_0000ul))); + Assert.False(present.Intersects(KeyBloom.Empty)); + Assert.False(KeyBloom.Empty.Intersects(present)); + } + + + [Fact] + public void Bloom_Rejects_Most_Absent_Values() + { + var random = new Random(7); + var filter = KeyBloom.Empty; + for (var i = 0; i < 8; i++) filter = filter.Union(KeyBloom.Of((ulong)random.NextInt64())); + + var falsePositives = 0; + for (var i = 0; i < 10_000; i++) + if (filter.MayContain(KeyBloom.Of((ulong)random.NextInt64()))) falsePositives++; + + // k=2, m=256, n=8: expected FP rate ≈ 0.4%; allow generous headroom against seed variance. + Assert.True(falsePositives < 300, $"Bloom false-positive rate too high: {falsePositives}/10000"); + } +} diff --git a/src/fennecs.tests/TypeExpressionTests.cs b/src/fennecs.tests/TypeExpressionTests.cs index abe596bb..3a2ba5b4 100644 --- a/src/fennecs.tests/TypeExpressionTests.cs +++ b/src/fennecs.tests/TypeExpressionTests.cs @@ -274,8 +274,8 @@ public void Kind_is_Data() [Fact] public void Forged_Wildcard_Matches_only_Itself() { - // A Wildcard nibble outside the recognized categories (reserved: Family). - var forged = new Key((ulong)SecondaryKind.Family << Key.KindShift); + // A Wildcard nibble outside the recognized categories (reserved: Data). + var forged = new Key((ulong)SecondaryKind.Data << Key.KindShift); Assert.True(forged.IsWildcard); var expression = new TypeExpression(PrimaryKind.Data, (TypeID)5, forged); @@ -284,6 +284,21 @@ public void Forged_Wildcard_Matches_only_Itself() } + [Fact] + public void Family_Wildcard_Pairwise_Matches_Plain_Of_Its_Type() + { + // Pairwise Family semantics cover only the same TypeId (self); derived-type + // matching crosses TypeIds and is handled by ArchetypeBits. + var family = new TypeExpression(PrimaryKind.Data, (TypeID)5, Key.Family); + var plain = new TypeExpression(PrimaryKind.Data, (TypeID)5, Key.Plain); + var otherPlain = new TypeExpression(PrimaryKind.Data, (TypeID)6, Key.Plain); + + Assert.True(family.Matches(plain)); + Assert.False(family.Matches(otherPlain)); + Assert.False(plain.Matches(family)); + } + + private struct TypeIntInt { private int _value; diff --git a/src/fennecs/Archetype.cs b/src/fennecs/Archetype.cs index 6c890897..c74459c2 100644 --- a/src/fennecs/Archetype.cs +++ b/src/fennecs/Archetype.cs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT using System.Collections; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using fennecs.pools; @@ -20,9 +21,9 @@ public sealed class Archetype : IEnumerable, IComparable internal readonly Signature Signature; /// - /// Expanded Signature with all Wildcards resolved for fast, set-level matching. + /// The Signature in bit-testable form: per-key-kind TypeId planes plus a keyed-expression bloom. /// - internal readonly Signature MatchSignature; + internal readonly ArchetypeBits Bits; /// /// Actual Component data storages. It' is a fixed size array because an Archetype doesn't change. @@ -68,7 +69,7 @@ internal Archetype(Aspect aspect, Signature signature) Storages = new IStorage[signature.Count]; Signature = signature; - MatchSignature = signature.Expand(); + Bits = new(signature); // Types are sorted by TypeID first, so we can iterate them in order to add them to Wildcard buckets. for (var index = 0; index < signature.Count; index++) @@ -109,30 +110,52 @@ internal PooledList> Match(TypeExpression expression) } - internal bool Matches(TypeExpression type) + /// + /// Resolves the storages matched by any expression form — including Family, which admits plain + /// storages of derived types — as untyped storages for read-only (covariant) access. + /// + internal PooledList MatchReadable(TypeExpression expression) { - var yes = MatchSignature.Matches(type); - return yes; + var result = PooledList.Rent(); + + if (expression.Key == Key.Family) + { + foreach (var (type, index) in _storageIndices) + { + if (type.Key != default) continue; + if (type.TypeId == expression.TypeId) + { + result.Add(Storages[index]); + continue; + } + + if (LanguageType.IsInFamily(expression.TypeId, type.TypeId)) result.Add(Storages[index]); + } + } + else if (expression.isWildcard) + { + foreach (var (type, index) in _storageIndices) + { + if (expression.Matches(type)) result.Add(Storages[index]); + } + } + else if (TryGetStorage(expression, out var storage)) + { + result.Add(storage); + } + + return result; } - // A method that checks if a given Mask parameter matches certain criteria using boolean logic and short circuiting. - internal bool Matches(Mask mask) - { - //Not overrides both Any and Has. - var matchesNot = !MatchSignature.Matches(mask.NotTypes); - if (!matchesNot) return false; + internal Cross.ReadJoin CrossJoinRead(ReadOnlySpan streamTypes) => + IsEmpty ? default : new(this, streamTypes); - //If already matching, no need to check any further. - var matchesHas = MatchSignature.IsSupersetOf(mask.HasTypes); - if (!matchesHas) return false; - //Short circuit to avoid enumerating all AnyTypes if already matching; or if none present. - var matchesAny = mask.AnyTypes.Count == 0; - matchesAny |= MatchSignature.Matches(mask.AnyTypes); + internal bool Matches(TypeExpression type) => Bits.MatchesElement(type); - return matchesHas && matchesNot && matchesAny; - } + + internal bool Matches(in MaskBits mask) => Bits.Matches(mask); /// /// Remove one or more Entities and all associated Component data from the Archetype. @@ -313,7 +336,7 @@ internal bool TryGetStorage(TypeExpression typeExpression, out IStorage storage) /// /// Does this Archetype have a storage for the given concrete (non-wildcard) expression? - /// (a single dictionary probe — for wildcards, match against instead) + /// (a single dictionary probe — for wildcards, match against instead) /// internal bool HasStorage(TypeExpression typeExpression) => _storageIndices.ContainsKey(typeExpression); diff --git a/src/fennecs/Aspect.CRUD.cs b/src/fennecs/Aspect.CRUD.cs index 9c548a50..1b18fcf0 100644 --- a/src/fennecs/Aspect.CRUD.cs +++ b/src/fennecs/Aspect.CRUD.cs @@ -99,8 +99,8 @@ internal bool HasComponent(Entity entity, TypeExpression typeExpression) // Concrete expressions are exactly the Archetype's storage keys: one dictionary probe. if (!typeExpression.isWildcard) return archetype.HasStorage(typeExpression); - // Wildcards match against the expanded signature (which contains the wildcard entries). - return typeExpression.Matches(archetype.MatchSignature); + // Wildcards match against the per-kind TypeId presence planes. + return archetype.Matches(typeExpression); } diff --git a/src/fennecs/Aspect.cs b/src/fennecs/Aspect.cs index 020f0239..3682b894 100644 --- a/src/fennecs/Aspect.cs +++ b/src/fennecs/Aspect.cs @@ -233,7 +233,7 @@ internal Archetype GetArchetype(Signature types) // identifies them just by their Signature root. (?) foreach (var query in _queries) { - if (table.Matches(query.Mask)) + if (table.Matches(query.MaskBits)) { query.TrackArchetype(table); } @@ -318,36 +318,46 @@ internal void DisposeArchetype(Archetype archetype) internal Query CompileQuery(Mask mask) { - // Return cached query if available. - if (_queryCache.TryGetValue(mask.GetHashCode(), out var query)) return query; + var copy = mask.Clone(); - // A Query matches Archetypes of exactly one Aspect; reject types stored elsewhere. - // (cached queries were already validated when first compiled) - foreach (var type in mask.HasTypes.Concat(mask.NotTypes).Concat(mask.AnyTypes)) + // Return cached query if available. + if (_queryCache.TryGetValue(copy.GetHashCode(), out var query)) { - if (type.TypeId == LanguageType.EntityId) continue; + copy.Dispose(); + return query; + } - var owner = World.AspectOf(type); - if (owner == this) continue; + try + { + // A Query matches Archetypes of exactly one Aspect; reject types stored elsewhere. + // (cached queries were already validated when first compiled) + foreach (var type in copy.HasTypes.Concat(copy.NotTypes).Concat(copy.AnyTypes)) + { + if (type.TypeId == LanguageType.EntityId) continue; - throw new InvalidOperationException( - $"Query on Aspect \"{Name}\" includes {type}, which is stored in Aspect \"{owner.Name}\". " + - "A Query can only match Component types of a single Aspect."); - } + var owner = World.AspectOf(type); + if (owner == this) continue; - //TODO: if we operate on the mask itself, modifications to that mask downstream cause issues. - //The mask should not be modifiable outside of that scope, so there's an upstream bug. - // var copy = mask.Clone(); <-- even just copying here hides the race condition + throw new InvalidOperationException( + $"Query on Aspect \"{Name}\" includes {type}, which is stored in Aspect \"{owner.Name}\". " + + "A Query can only match Component types of a single Aspect."); + } - // Create a new query and cache it. - var matchingTables = new SortedSet(_archetypes.Where(table => table.Matches(mask))); + // Create a new query and cache it. + var maskBits = copy.Bits; + var matchingTables = new SortedSet(_archetypes.Where(table => table.Matches(maskBits))); - var copy = mask.Clone(); - query = new(this, copy, matchingTables); + query = new(this, copy, maskBits, matchingTables); - _queries.Add(query); - _queryCache.Add(copy.GetHashCode(), query); - return query; + _queries.Add(query); + _queryCache.Add(copy.GetHashCode(), query); + return query; + } + catch + { + copy.Dispose(); + throw; + } } diff --git a/src/fennecs/Batch.cs b/src/fennecs/Batch.cs index 2835a68c..5f98222b 100644 --- a/src/fennecs/Batch.cs +++ b/src/fennecs/Batch.cs @@ -64,7 +64,7 @@ private Batch AddComponent(T data, Match match) throw new InvalidOperationException($"Duplicate addition {typeExpression} : {data} in same batch!"); // Matches is checked both ways so Wildcard removals conflict with concrete additions of the same type. - if (typeExpression.Matches(Removals)) + if (ConflictsWith(typeExpression, Removals)) throw new InvalidOperationException($"Addition {typeExpression} conflicts with removal in same batch!"); Additions.Add(typeExpression); @@ -84,7 +84,7 @@ private Batch RemoveComponent(Match match = default) $"TypeExpression {typeExpression} is not included via Has or Any by this Query/Mask, removals could cause unintended runtime state. See QueryBuilder.Has(). See RemoveConflict.Disallow, RemoveConflict.Skip."); // Matches is checked both ways so Wildcard removals conflict with concrete additions of the same type. - if (typeExpression.Matches(Additions)) + if (ConflictsWith(typeExpression, Additions)) throw new InvalidOperationException($"Removal of {typeExpression} conflicts with addition in same batch!"); if (Removals.Contains(typeExpression)) @@ -95,6 +95,22 @@ private Batch RemoveComponent(Match match = default) } + // Pairwise, both directions: optimal for the handful of operations a single Batch accumulates. + private static bool ConflictsWith(TypeExpression expression, PooledList others) + { + foreach (var other in others) + { + if (expression.Equals(other) || expression.Matches(other) || other.Matches(expression)) return true; + if (expression.Key == Key.Family && other.Key == default + && LanguageType.IsInFamily(expression.TypeId, other.TypeId)) return true; + if (other.Key == Key.Family && expression.Key == default + && LanguageType.IsInFamily(other.TypeId, expression.TypeId)) return true; + } + + return false; + } + + private void AssertSameAspect(TypeExpression typeExpression) { var owner = World.AspectOf(typeExpression); diff --git a/src/fennecs/Cross.cs b/src/fennecs/Cross.cs index bd8fc0d0..eef114bb 100644 --- a/src/fennecs/Cross.cs +++ b/src/fennecs/Cross.cs @@ -537,5 +537,94 @@ public void Dispose() #endregion + + #region Read Join + + /// + /// Read-only Cross-Join over untyped storages: serves every stream type form, including + /// , whose derived storages are viewed covariantly as their base type. + /// + internal readonly struct ReadJoin : IDisposable + { + private readonly int[] _counter; + private readonly int[] _limiter; + + private readonly PooledList _storages0; + private readonly PooledList? _storages1; + private readonly PooledList? _storages2; + private readonly PooledList? _storages3; + private readonly PooledList? _storages4; + + private readonly int _arity; + private readonly bool _populated; + + + internal ReadJoin(Archetype archetype, ReadOnlySpan streamTypes) + { + Debug.Assert(streamTypes.Length is >= 1 and <= 5, "Unsupported stream type arity."); + + _arity = streamTypes.Length; + _counter = ArrayPool.Rent(_arity); + _limiter = ArrayPool.Rent(_arity); + _populated = true; + + _storages0 = archetype.MatchReadable(streamTypes[0]); + if (_arity > 1) _storages1 = archetype.MatchReadable(streamTypes[1]); + if (_arity > 2) _storages2 = archetype.MatchReadable(streamTypes[2]); + if (_arity > 3) _storages3 = archetype.MatchReadable(streamTypes[3]); + if (_arity > 4) _storages4 = archetype.MatchReadable(streamTypes[4]); + + for (var slot = 0; slot < _arity; slot++) + { + _counter[slot] = 0; + _limiter[slot] = Slot(slot).Count; + _populated &= _limiter[slot] > 0; + } + } + + + private PooledList Slot(int slot) => slot switch + { + 0 => _storages0, + 1 => _storages1!, + 2 => _storages2!, + 3 => _storages3!, + _ => _storages4!, + }; + + + /// Read-only view of the current permutation's storage for the given slot, as C. + internal ReadOnlySpan Span(int slot) => Slot(slot)[_counter[slot]].ReadAs(); + + /// Single element of the current permutation's storage for the given slot, as C. + internal C Get(int slot, int row) => Slot(slot)[_counter[slot]].GetAs(row); + + + internal bool Iterate() + { + Debug.Assert(_counter.Length >= _arity && _limiter.Length >= _arity); + return FullPermutation(_counter.AsSpan(0, _arity), _limiter.AsSpan(0, _arity)); + } + + + internal bool Empty => !_populated; + + + public void Dispose() + { + if (_counter is null) return; + + _storages0.Dispose(); + _storages1?.Dispose(); + _storages2?.Dispose(); + _storages3?.Dispose(); + _storages4?.Dispose(); + ArrayPool.Return(_counter); + ArrayPool.Return(_limiter); + } + } + + #endregion + private static readonly ArrayPool ArrayPool = ArrayPool.Create(); } diff --git a/src/fennecs/LanguageType.cs b/src/fennecs/LanguageType.cs index 487f1799..224120df 100644 --- a/src/fennecs/LanguageType.cs +++ b/src/fennecs/LanguageType.cs @@ -38,6 +38,10 @@ internal class LanguageType // live here instead of inside the packed TypeExpression) private static readonly TypeFlags[] FlagTable = new TypeFlags[AnyId + 1]; + // Side-table of base classes per TypeId, nearest first; null = none. + // Feeds Match.Family (inheritance-aware) matching. Interfaces are not seeded (policy choice). + private static readonly Type[]?[] AncestorTable = new Type[]?[AnyId + 1]; + /// /// The of the type registered with the given TypeId. @@ -45,6 +49,24 @@ internal class LanguageType internal static TypeFlags FlagsById(TypeID typeId) => FlagTable[typeId]; + /// + /// The registered type's base classes, nearest first. (empty for structs) + /// + internal static ReadOnlySpan AncestorsById(TypeID typeId) => AncestorTable[typeId] ?? []; + + + internal static bool IsInFamily(TypeID baseId, TypeID candidateId) + { + if (baseId == candidateId) return true; + + var baseType = Resolve(baseId); + foreach (var ancestor in AncestorsById(candidateId)) + if (ancestor == baseType) return true; + + return false; + } + + protected internal static TypeID Identify(Type type) { // Query the registry directly for a fast response. @@ -101,6 +123,23 @@ protected static TypeFlags ComputeFlags() } protected static void StoreFlags(TypeID id, TypeFlags flags) => FlagTable[id] = flags; + + + // Walks the base-class chain without registering ancestors as Components. + protected static void StoreAncestors(TypeID id, Type type) + { + List? ancestors = null; + + for (var baseType = type.BaseType; + baseType is not null + && baseType != typeof(object) && baseType != typeof(ValueType) && baseType != typeof(Enum); + baseType = baseType.BaseType) + { + (ancestors ??= []).Add(baseType); + } + + if (ancestors is not null) AncestorTable[id] = ancestors.ToArray(); + } } internal class LanguageType : LanguageType @@ -132,6 +171,7 @@ static LanguageType() } StoreFlags(Id, ComputeFlags()); + StoreAncestors(Id, typeof(T)); } } } diff --git a/src/fennecs/Mask.cs b/src/fennecs/Mask.cs index c572bf80..66dc73a7 100644 --- a/src/fennecs/Mask.cs +++ b/src/fennecs/Mask.cs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT +using System.Diagnostics; using fennecs.pools; namespace fennecs; @@ -10,13 +11,22 @@ internal sealed class Mask : IDisposable internal readonly SortedSet NotTypes = []; internal readonly SortedSet AnyTypes = []; - public bool SafeForAddition(TypeExpression typeExpression) => typeExpression.Matches(NotTypes); - public bool SafeForRemoval(TypeExpression typeExpression) => typeExpression.Matches(HasTypes) || typeExpression.Matches(AnyTypes); + // Compiled lazily; invalidated on mutation (Masks are pooled and mutable). + private MaskBits? _bits; + + internal MaskBits Bits => _bits ??= MaskBits.Of(this); + + + public bool SafeForAddition(TypeExpression typeExpression) => Bits.Not.MatchesBidirectional(typeExpression); + + public bool SafeForRemoval(TypeExpression typeExpression) => + Bits.Has.MatchesBidirectional(typeExpression) || Bits.Any.MatchesBidirectional(typeExpression); public Mask Has(TypeExpression typeExpression) { HasTypes.Add(typeExpression); + _bits = null; return this; } @@ -24,6 +34,7 @@ public Mask Has(TypeExpression typeExpression) public Mask Not(TypeExpression typeExpression) { NotTypes.Add(typeExpression); + _bits = null; return this; } @@ -31,6 +42,7 @@ public Mask Not(TypeExpression typeExpression) public Mask Any(TypeExpression typeExpression) { AnyTypes.Add(typeExpression); + _bits = null; return this; } @@ -40,6 +52,7 @@ public void Clear() HasTypes.Clear(); NotTypes.Clear(); AnyTypes.Clear(); + _bits = null; } @@ -77,6 +90,7 @@ public Mask Clone() mask.HasTypes.UnionWith(HasTypes); mask.NotTypes.UnionWith(NotTypes); mask.AnyTypes.UnionWith(AnyTypes); + mask._bits = null; return mask; } } diff --git a/src/fennecs/Query.cs b/src/fennecs/Query.cs index dc0819d9..4766d83e 100644 --- a/src/fennecs/Query.cs +++ b/src/fennecs/Query.cs @@ -50,7 +50,7 @@ public bool Contains(Entity entity) public bool Contains(Match match = default) { var typeExpression = TypeExpression.Of(match); - return Archetypes.Any(a => typeExpression.Matches(a.MatchSignature)); + return Archetypes.Any(a => a.Matches(typeExpression)); } @@ -111,11 +111,17 @@ internal void ForgetArchetype(Archetype archetype) /// internal readonly Mask Mask; - internal Query(Aspect aspect, Mask mask, SortedSet matchingTables) + /// + /// The Mask compiled to bit-testable form, cached for matching newly created Archetypes. + /// + internal readonly MaskBits MaskBits; + + internal Query(Aspect aspect, Mask mask, MaskBits maskBits, SortedSet matchingTables) { Archetypes = matchingTables; Aspect = aspect; Mask = mask; + MaskBits = maskBits; } #endregion diff --git a/src/fennecs/QueryBuilder.cs b/src/fennecs/QueryBuilder.cs index 07e181fa..08051b39 100644 --- a/src/fennecs/QueryBuilder.cs +++ b/src/fennecs/QueryBuilder.cs @@ -14,6 +14,7 @@ public abstract class QueryBuilderBase : IDisposable where QB : QueryBuilder private Mask _mask = MaskPool.Rent(); private bool _disposed; + private readonly Lock _gate = new(); private readonly World _world; @@ -27,8 +28,12 @@ public abstract class QueryBuilderBase : IDisposable where QB : QueryBuilder /// internal QB Within(Aspect aspect) { - _aspect = aspect; - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _aspect = aspect; + return (QB)this; + } } /// @@ -56,7 +61,14 @@ private protected QueryBuilderBase(World world, Span streamTypes /// or any of its overloads. /// /// compiled query (you can compile more than one query from the same builder) - public Query Compile() => _aspect?.CompileQuery(_mask) ?? _world.CompileQuery(_mask); + public Query Compile() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _aspect?.CompileQuery(_mask) ?? _world.CompileQuery(_mask); + } + } #endregion @@ -71,8 +83,12 @@ private protected QueryBuilderBase(World world, Span streamTypes /// if the StreamTypes already cover this or conflict with it public QB Has(Match match = default) { - _mask.Has(TypeExpression.Of(match)); - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _mask.Has(TypeExpression.Of(match)); + return (QB)this; + } } @@ -85,8 +101,12 @@ public QB Has(Match match = default) /// if the StreamTypes already cover this or conflict with it public QB Has(Link link) where T : class { - _mask.Has(TypeExpression.Of(link)); - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _mask.Has(TypeExpression.Of(link)); + return (QB)this; + } } @@ -100,8 +120,12 @@ public QB Has(Link link) where T : class /// if the StreamTypes already cover this or conflict with it public QB Not(Match match = default) { - _mask.Not(TypeExpression.Of(match)); - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _mask.Not(TypeExpression.Of(match)); + return (QB)this; + } } /// @@ -113,8 +137,12 @@ public QB Not(Match match = default) /// if the StreamTypes already cover this or conflict with it public QB Not(Link link) where T : class { - _mask.Not(TypeExpression.Of(link)); - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _mask.Not(TypeExpression.Of(link)); + return (QB)this; + } } /// @@ -127,8 +155,12 @@ public QB Not(Link link) where T : class /// if the StreamTypes already cover this or conflict with it public QB Any(Match match = default) { - _mask.Any(TypeExpression.Of(match)); - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _mask.Any(TypeExpression.Of(match)); + return (QB)this; + } } /// @@ -141,8 +173,12 @@ public QB Any(Match match = default) /// if the StreamTypes already cover this or conflict with it public QB Any(Link link) where T : class { - _mask.Any(TypeExpression.Of(link)); - return (QB)this; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _mask.Any(TypeExpression.Of(link)); + return (QB)this; + } } #endregion @@ -152,12 +188,15 @@ public QB Any(Link link) where T : class /// public void Dispose() { - ObjectDisposedException.ThrowIf(_disposed, this); - - GC.SuppressFinalize(this); - _disposed = true; - _mask.Dispose(); - _mask = null!; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + GC.SuppressFinalize(this); + _disposed = true; + _mask.Dispose(); + _mask = null!; + } } #endregion diff --git a/src/fennecs/Storage.cs b/src/fennecs/Storage.cs index 99def19b..51d0cb93 100644 --- a/src/fennecs/Storage.cs +++ b/src/fennecs/Storage.cs @@ -98,6 +98,17 @@ public static IStorage Instantiate(TypeExpression expression) /// /// if the row index is out of range object Get(int row); + + /// + /// A read-only view of the storage as a base type of its element type (or the exact type). + /// Sound because read-only spans permit covariant arrays; used by Family stream runners. + /// + ReadOnlySpan ReadAs(); + + /// + /// The element at the given row, as a base type of the element type (or the exact type). + /// + TBase GetAs(int row); } /// @@ -336,6 +347,16 @@ private void Append(Span appendage) /// public Span Span => _data.AsSpan(0, Count); + /// + /// + /// The array cast enforces the base-of- (or exact type) requirement at runtime; + /// the ReadOnlySpan constructor accepts the covariant array because it cannot be written through. + /// + public ReadOnlySpan ReadAs() => new((TBase[])(object)_data, 0, Count); + + /// + public TBase GetAs(int row) => ReadAs()[row]; + private Span FullSpan => _data.AsSpan(); /// diff --git a/src/fennecs/expressions/Key.cs b/src/fennecs/expressions/Key.cs index af41f3a5..6b5257f2 100644 --- a/src/fennecs/expressions/Key.cs +++ b/src/fennecs/expressions/Key.cs @@ -156,6 +156,12 @@ internal static Key Of(T item) where T : class => /// Wildcard: matches only Entity-Object Link targets. public static Key AnyObject => new((ulong)SecondaryKind.Object << KindShift); + /// + /// Wildcard: matches plain Components of the expression's type OR any type derived from it. + /// Inheritance-aware matching happens in . + /// + public static Key Family => new((ulong)SecondaryKind.Family << KindShift); + #endregion @@ -183,6 +189,7 @@ public override string ToString() SecondaryKind.Target => "wildcard[Target]", SecondaryKind.Entity => "wildcard[Entity]", SecondaryKind.Object => "wildcard[Object]", + SecondaryKind.Family => "wildcard[Family]", _ => $"wildcard[?-{Value:x16}]", }; diff --git a/src/fennecs/expressions/Match.cs b/src/fennecs/expressions/Match.cs index a149d4b2..ff0b15be 100644 --- a/src/fennecs/expressions/Match.cs +++ b/src/fennecs/expressions/Match.cs @@ -84,6 +84,17 @@ public readonly record struct Match public static Match Entity => new(Key.AnyEntity); + /// + /// Inheritance-aware match expression for query filters.
+ /// Matches Plain Components of the given type or any type derived from it (base classes + /// discovered from each Component type's inheritance chain).
+ /// Usable in Has/Not/Any clauses and filters on ordinary streams. + /// As a Stream Type, derived Component storages are available through read-only iteration with + /// ForRead or enumeration; writable runners and filtered views are not supported. + ///
+ public static Match Family => new(Key.Family); + + /// /// Plain Component match expression for Entity iteration.
This matches only Plain Components of the given Stream Type. ///
diff --git a/src/fennecs/expressions/MatchBits.cs b/src/fennecs/expressions/MatchBits.cs new file mode 100644 index 00000000..3a74b55f --- /dev/null +++ b/src/fennecs/expressions/MatchBits.cs @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: MIT + +using System.Diagnostics; + +namespace fennecs; + +/// A specific keyed (relation/link) clause entry with its precomputed bloom pattern. +internal readonly struct KeyedEntry(TypeExpression expression) +{ + internal readonly TypeExpression Expression = expression; + internal readonly KeyBloom Pattern = KeyBloom.Of(expression.Raw); +} + + +/// +/// One compiled query clause (Has, Not, or Any): its entries bucketed by key form into TypeId +/// requirement planes, plus specific keyed entries with bloom patterns. +/// +internal readonly struct ClauseBits +{ + /// TypeIds required as plain Components. + internal readonly TypeBits Plain; + + /// TypeIds required with the Any Wildcard (any key form, including Plain). + internal readonly TypeBits WildAny; + + /// TypeIds required with the Target Wildcard (any non-plain key). + internal readonly TypeBits WildTarget; + + /// TypeIds required with the Entity Wildcard (any specific Entity relation). + internal readonly TypeBits WildEntity; + + /// TypeIds required with the Object Wildcard (any specific Object Link). + internal readonly TypeBits WildObject; + + /// Entries with one specific relation/link target. + internal readonly KeyedEntry[] Keyed; + + /// Union of all patterns: group-level bloom reject in one op. + internal readonly KeyBloom KeyedBloom; + + /// TypeIds having ≥1 specific Entity-keyed entry. (bidirectional SafeFor* tests only) + internal readonly TypeBits SpecEntity; + + /// TypeIds having ≥1 specific Object-keyed entry. (bidirectional SafeFor* tests only) + internal readonly TypeBits SpecObject; + + /// Family (inheritance-aware) entries: match plain Components of their type or any derived type. + internal readonly KeyedEntry[] Family; + + /// TypeIds of the plain entries, for the bidirectional Family arm. (SafeFor* tests only) + internal readonly TypeID[] PlainIds; + + internal readonly bool IsEmpty; + + internal static readonly ClauseBits Empty = Of([]); + + + private ClauseBits(TypeBits plain, TypeBits wildAny, TypeBits wildTarget, TypeBits wildEntity, + TypeBits wildObject, KeyedEntry[] keyed, KeyBloom keyedBloom, TypeBits specEntity, TypeBits specObject, + KeyedEntry[] family, TypeID[] plainIds) + { + Plain = plain; + WildAny = wildAny; + WildTarget = wildTarget; + WildEntity = wildEntity; + WildObject = wildObject; + Keyed = keyed; + KeyedBloom = keyedBloom; + SpecEntity = specEntity; + SpecObject = specObject; + Family = family; + PlainIds = plainIds; + IsEmpty = plain.IsEmpty && wildAny.IsEmpty && wildTarget.IsEmpty && wildEntity.IsEmpty + && wildObject.IsEmpty && keyed.Length == 0 && family.Length == 0; + } + + + internal static ClauseBits Of(IEnumerable clause) + { + List plain = [], wildAny = [], wildTarget = [], wildEntity = [], wildObject = []; + List specEntity = [], specObject = []; + List keyed = [], family = []; + var keyedBloom = KeyBloom.Empty; + + foreach (var expression in clause) + { + var key = expression.Key; + if (key == default) + { + plain.Add(expression.TypeId); + } + else if (key.IsWildcard) + { + switch (key.Kind) + { + case SecondaryKind.Any: wildAny.Add(expression.TypeId); break; + case SecondaryKind.Target: wildTarget.Add(expression.TypeId); break; + case SecondaryKind.Entity: wildEntity.Add(expression.TypeId); break; + case SecondaryKind.Object: wildObject.Add(expression.TypeId); break; + case SecondaryKind.Family: family.Add(new(expression)); break; + default: + throw new NotSupportedException($"Unsupported Wildcard in clause: {expression}"); + } + } + else + { + Debug.Assert(key.IsRelation, $"Clause entry is neither Plain, Wildcard, nor specific: {expression}"); + var entry = new KeyedEntry(expression); + keyed.Add(entry); + keyedBloom = keyedBloom.Union(entry.Pattern); + (key.IsObject ? specObject : specEntity).Add(expression.TypeId); + } + } + + return new( + Plane(plain), Plane(wildAny), Plane(wildTarget), Plane(wildEntity), Plane(wildObject), + keyed.ToArray(), keyedBloom, Plane(specEntity), Plane(specObject), + family.ToArray(), plain.ToArray()); + } + + + private static TypeBits Plane(List typeIds) + { + if (typeIds.Count == 0) return TypeBits.Empty; + + var max = 0; + foreach (var id in typeIds) max = Math.Max(max, id); + + var words = TypeBits.AllocateFor(max); + foreach (var id in typeIds) TypeBits.Set(words, id); + return new(words); + } + + + /// + /// Bidirectional single-expression test: does match, or get matched by, + /// any clause entry? (the symmetrized pairwise semantics of Batch conflict checks) + /// + internal bool MatchesBidirectional(TypeExpression expression) + { + var typeId = expression.TypeId; + var key = expression.Key; + + if (key == default) return Plain.Get(typeId) || WildAny.Get(typeId) || FamilyCovers(typeId); + + if (!key.IsWildcard) + { + if (WildAny.Get(typeId) || WildTarget.Get(typeId)) return true; + if ((key.IsObject ? WildObject : WildEntity).Get(typeId)) return true; + + if (Keyed.Length == 0 || !KeyedBloom.MayContain(KeyBloom.Of(expression.Raw))) return false; + foreach (var entry in Keyed) + if (entry.Expression.Equals(expression)) return true; + return false; + } + + var anyWildPlane = WildAny.Get(typeId) || WildTarget.Get(typeId) + || WildEntity.Get(typeId) || WildObject.Get(typeId); + + return key.Kind switch + { + SecondaryKind.Any => Plain.Get(typeId) || SpecEntity.Get(typeId) || SpecObject.Get(typeId) + || anyWildPlane, + SecondaryKind.Target => SpecEntity.Get(typeId) || SpecObject.Get(typeId) || anyWildPlane, + SecondaryKind.Entity => SpecEntity.Get(typeId) || WildAny.Get(typeId) || WildTarget.Get(typeId) + || WildEntity.Get(typeId), + SecondaryKind.Object => SpecObject.Get(typeId) || WildAny.Get(typeId) || WildTarget.Get(typeId) + || WildObject.Get(typeId), + SecondaryKind.Family => WildAny.Get(typeId) || WildTarget.Get(typeId) || FamilyIdPresent(typeId) + || PlainDescendsFrom(typeId), + _ => throw new NotSupportedException($"Unsupported Wildcard expression: {expression}"), + }; + } + + + // Does any Family entry cover this plain TypeId? (same type, or one of its base classes) + private bool FamilyCovers(TypeID typeId) + { + if (Family.Length == 0) return false; + + foreach (var entry in Family) + { + var baseId = entry.Expression.TypeId; + if (baseId == typeId) return true; + + if (LanguageType.IsInFamily(baseId, typeId)) return true; + } + + return false; + } + + + private bool FamilyIdPresent(TypeID typeId) + { + foreach (var entry in Family) + if (entry.Expression.TypeId == typeId) return true; + return false; + } + + + // Does any plain entry name this type, or a type derived from it? + private bool PlainDescendsFrom(TypeID baseId) + { + foreach (var plainId in PlainIds) + { + if (plainId == baseId) return true; + + if (LanguageType.IsInFamily(baseId, plainId)) return true; + } + + return false; + } +} + + +/// A compiled : its three clauses in bit-testable form. +internal readonly struct MaskBits +{ + internal readonly ClauseBits Has; + internal readonly ClauseBits Not; + internal readonly ClauseBits Any; + + /// Hoisted: an empty Any clause always passes. + internal readonly bool AnyEmpty; + + + private MaskBits(ClauseBits has, ClauseBits not, ClauseBits any) + { + Has = has; + Not = not; + Any = any; + AnyEmpty = any.IsEmpty; + } + + + internal static MaskBits Of(Mask mask) => + new(ClauseBits.Of(mask.HasTypes), ClauseBits.Of(mask.NotTypes), ClauseBits.Of(mask.AnyTypes)); +} + + +/// +/// An Archetype's Signature in bit-testable form: per-key-kind TypeId presence planes, plus a bloom +/// filter over its specific keyed (relation/link) expressions. +/// +/// +/// Archetype signatures contain no Wildcards, so the three planes answer every type-level Wildcard +/// question exactly; only specific-key membership needs the bloom (certain NO) and the exact +/// Signature (certain verdict). +/// +internal readonly struct ArchetypeBits +{ + /// TypeIds present as plain Components. + internal readonly TypeBits Plain; + + /// TypeIds present with ≥1 specific Entity-relation key. + internal readonly TypeBits Entity; + + /// TypeIds present with ≥1 specific Object-Link key. + internal readonly TypeBits Object; + + /// Bloom over all specific keyed expressions. + internal readonly KeyBloom Keyed; + + /// + /// Bloom over the Family expressions of every plain Component's base classes: + /// a base-type Family query tests one pattern; a miss is certain absence of any derived Component. + /// + internal readonly KeyBloom Family; + + // The exact signature is the precise oracle for specific-key membership after a bloom maybe. + private readonly Signature _signature; + + + internal ArchetypeBits(Signature signature) + { + _signature = signature; + + List plain = [], entity = [], obj = []; + var keyed = KeyBloom.Empty; + var family = KeyBloom.Empty; + + foreach (var expression in signature) + { + Debug.Assert(!expression.isWildcard, $"Archetype Signatures must not contain Wildcards: {expression}"); + + var key = expression.Key; + if (key == default) + { + plain.Add(expression.TypeId); + + foreach (var ancestor in LanguageType.AncestorsById(expression.TypeId)) + family = family.Union(KeyBloom.Of(FamilyRaw(ancestor))); + } + else + { + keyed = keyed.Union(KeyBloom.Of(expression.Raw)); + (key.IsObject ? obj : entity).Add(expression.TypeId); + } + } + + // All three planes share one allocation width so typical tests stay within one block. + var max = 0; + foreach (var expression in signature) max = Math.Max(max, expression.TypeId); + + Plain = Plane(plain, max); + Entity = Plane(entity, max); + Object = Plane(obj, max); + Keyed = keyed; + Family = family; + } + + + private static ulong FamilyRaw(Type type) => unchecked((uint)type.GetHashCode()); + + + // Precise confirm after a Family bloom maybe: is any plain Component derived from the base type? + private bool ConfirmFamily(TypeID baseId) + { + foreach (var expression in _signature) + { + if (expression.Key != default) continue; + + if (LanguageType.IsInFamily(baseId, expression.TypeId)) return true; + } + + return false; + } + + + // Family test: the base type itself as a plain Component, or bloom + precise derived-type confirm. + private bool MatchesFamily(TypeID baseId) => + Plain.Get(baseId) + || (Family.MayContain(KeyBloom.Of(FamilyRaw(LanguageType.Resolve(baseId)))) && ConfirmFamily(baseId)); + + + private static TypeBits Plane(List typeIds, int maxTypeId) + { + if (typeIds.Count == 0) return TypeBits.Empty; + + var words = TypeBits.AllocateFor(maxTypeId); + foreach (var id in typeIds) TypeBits.Set(words, id); + return new(words); + } + + + /// Does this Archetype contain a match for the given (possibly Wildcard) expression? + internal bool MatchesElement(TypeExpression expression) + { + var key = expression.Key; + var typeId = expression.TypeId; + + if (key == default) return Plain.Get(typeId); + + if (!key.IsWildcard) + return (key.IsObject ? Object : Entity).Get(typeId) + && Keyed.MayContain(KeyBloom.Of(expression.Raw)) + && _signature.Contains(expression); + + return key.Kind switch + { + SecondaryKind.Any => Plain.Get(typeId) || Entity.Get(typeId) || Object.Get(typeId), + SecondaryKind.Target => Entity.Get(typeId) || Object.Get(typeId), + SecondaryKind.Entity => Entity.Get(typeId), + SecondaryKind.Object => Object.Get(typeId), + SecondaryKind.Family => MatchesFamily(typeId), + _ => throw new NotSupportedException($"Unsupported Wildcard expression: {expression}"), + }; + } + + + /// Does this Archetype contain a match for every entry of the clause? (Has semantics) + internal bool IsSupersetOf(in ClauseBits clause) + { + if (!Plain.ContainsAll(clause.Plain)) return false; + if (!Entity.ContainsAll(clause.WildEntity)) return false; + if (!Object.ContainsAll(clause.WildObject)) return false; + if (!TypeBits.ContainsAll2(clause.WildTarget, Entity, Object)) return false; + if (!TypeBits.ContainsAll3(clause.WildAny, Plain, Entity, Object)) return false; + + foreach (var entry in clause.Family) + { + if (!MatchesFamily(entry.Expression.TypeId)) return false; + } + + if (clause.Keyed.Length == 0) return true; + if (!Keyed.MayContain(clause.KeyedBloom)) return false; + + foreach (var entry in clause.Keyed) + { + var gate = entry.Expression.Key.IsObject ? Object : Entity; + if (!gate.Get(entry.Expression.TypeId)) return false; + if (!_signature.Contains(entry.Expression)) return false; + } + + return true; + } + + + /// Does this Archetype contain a match for at least one entry of the clause? (Not/Any semantics) + internal bool Overlaps(in ClauseBits clause) + { + if (clause.Plain.Intersects(Plain)) return true; + if (clause.WildEntity.Intersects(Entity)) return true; + if (clause.WildObject.Intersects(Object)) return true; + if (TypeBits.Intersects2(clause.WildTarget, Entity, Object)) return true; + if (TypeBits.Intersects3(clause.WildAny, Plain, Entity, Object)) return true; + + foreach (var entry in clause.Family) + { + if (MatchesFamily(entry.Expression.TypeId)) return true; + } + + // Group-level certain NO in one op; a bloom maybe alone must never count as a hit. + if (clause.Keyed.Length == 0 || !Keyed.Intersects(clause.KeyedBloom)) return false; + + foreach (var entry in clause.Keyed) + { + var gate = entry.Expression.Key.IsObject ? Object : Entity; + if (gate.Get(entry.Expression.TypeId) + && Keyed.MayContain(entry.Pattern) + && _signature.Contains(entry.Expression)) return true; + } + + return false; + } + + + /// Does this Archetype match the compiled Mask? (Not overrides Any and Has) + internal bool Matches(in MaskBits mask) + { + if (Overlaps(mask.Not)) return false; + if (!IsSupersetOf(mask.Has)) return false; + return mask.AnyEmpty || Overlaps(mask.Any); + } +} diff --git a/src/fennecs/expressions/Signature.cs b/src/fennecs/expressions/Signature.cs index 909d0989..cd23a79a 100644 --- a/src/fennecs/expressions/Signature.cs +++ b/src/fennecs/expressions/Signature.cs @@ -122,15 +122,20 @@ public int CompareTo(Signature other) { if (other._set == default!) return 1; - var minCount = Math.Min(_set.Count, other._set.Count); + // Lexicographic over the sorted elements; enumerators avoid ElementAt's O(log n) per index. + using var mine = _set.GetEnumerator(); + using var theirs = other._set.GetEnumerator(); - for (var i = 0; i < minCount; i++) + while (true) { - var cmp = _set.ElementAt(i).CompareTo(other._set.ElementAt(i)); + var mineNext = mine.MoveNext(); + var theirsNext = theirs.MoveNext(); + + if (!mineNext || !theirsNext) return mineNext.CompareTo(theirsNext); + + var cmp = mine.Current.CompareTo(theirs.Current); if (cmp != 0) return cmp; } - - return _set.Count.CompareTo(other._set.Count); } /// @@ -162,20 +167,4 @@ private static int BakeHash(IEnumerable source) /// public TypeExpression this[int index] => _set.ElementAt(index); - - - - - /// - /// Create a copy of set with its Wildcards expanded. - /// - internal Signature Expand() - { - var expanded = _set.ToBuilder(); - - var expansions = _set.SelectMany(type => type.Expand()); - expanded.UnionWith(expansions); - - return new(expanded.ToImmutable()); - } } diff --git a/src/fennecs/expressions/TypeBits.cs b/src/fennecs/expressions/TypeBits.cs new file mode 100644 index 00000000..ac8ce9ae --- /dev/null +++ b/src/fennecs/expressions/TypeBits.cs @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: MIT + +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; + +namespace fennecs; + +/// +/// Dense bitset over s, allocated in 256-bit blocks. +/// +/// +/// TypeIds are assigned monotonically, so bitsets created at different times differ in length; +/// all operations treat words beyond an operand's length as zero. +/// +internal readonly struct TypeBits +{ + private readonly ulong[] _words; + + /// True if no bit is set; callers use this to skip whole-plane tests. + internal readonly bool IsEmpty; + + /// 256 bits per block: one of ulongs. + internal const int WordsPerBlock = 4; + + internal static readonly TypeBits Empty = new([]); + + + internal TypeBits(ulong[] words) + { + _words = words; + var empty = true; + foreach (var word in words) empty &= word == 0; + IsEmpty = empty; + } + + + /// Allocates a word array covering TypeIds 0.. in whole 256-bit blocks. + internal static ulong[] AllocateFor(int maxTypeId) => new ulong[(maxTypeId / 256 + 1) * WordsPerBlock]; + + + // Shift counts on ulong are masked to 63 by the runtime, so 1ul << typeId addresses the bit within the word. + internal static void Set(ulong[] words, TypeID typeId) => words[typeId >> 6] |= 1ul << typeId; + + + private static Vector256 LoadOrZero(ulong[] words, int offset) => + offset + WordsPerBlock <= words.Length + ? Vector256.LoadUnsafe(ref MemoryMarshal.GetArrayDataReference(words), (nuint)offset) + : Vector256.Zero; + + + /// Is the bit for this TypeId set? (bits beyond this bitset's length are unset) + internal bool Get(TypeID typeId) + { + var index = typeId >> 6; + return index < _words.Length && (_words[index] & (1ul << typeId)) != 0; + } + + + /// Are all bits of also set in this bitset? + internal bool ContainsAll(in TypeBits required) + { + if (required.IsEmpty) return true; + + var req = required._words; + var have = _words; + var shared = Math.Min(req.Length, have.Length); + var i = 0; + + if (Vector256.IsHardwareAccelerated && shared >= WordsPerBlock) + { + ref var reqRef = ref MemoryMarshal.GetArrayDataReference(req); + ref var haveRef = ref MemoryMarshal.GetArrayDataReference(have); + for (; i + WordsPerBlock <= shared; i += WordsPerBlock) + { + var r = Vector256.LoadUnsafe(ref reqRef, (nuint)i); + var h = Vector256.LoadUnsafe(ref haveRef, (nuint)i); + if (Vector256.AndNot(r, h) != Vector256.Zero) return false; + } + } + + for (; i < shared; i++) + if ((req[i] & ~have[i]) != 0) return false; + + // Required bits beyond our length are misses. + for (; i < req.Length; i++) + if (req[i] != 0) return false; + + return true; + } + + + /// Do this bitset and share any set bit? + internal bool Intersects(in TypeBits other) + { + if (IsEmpty || other.IsEmpty) return false; + + var a = _words; + var b = other._words; + var shared = Math.Min(a.Length, b.Length); + var i = 0; + + if (Vector256.IsHardwareAccelerated && shared >= WordsPerBlock) + { + ref var aRef = ref MemoryMarshal.GetArrayDataReference(a); + ref var bRef = ref MemoryMarshal.GetArrayDataReference(b); + for (; i + WordsPerBlock <= shared; i += WordsPerBlock) + { + if ((Vector256.LoadUnsafe(ref aRef, (nuint)i) & Vector256.LoadUnsafe(ref bRef, (nuint)i)) + != Vector256.Zero) return true; + } + } + + for (; i < shared; i++) + if ((a[i] & b[i]) != 0) return true; + + return false; + } + + + /// Is fully covered by the union | ? + internal static bool ContainsAll2(in TypeBits required, in TypeBits a, in TypeBits b) + { + if (required.IsEmpty) return true; + + var req = required._words; + var aw = a._words; + var bw = b._words; + var i = 0; + + if (Vector256.IsHardwareAccelerated) + { + ref var reqRef = ref MemoryMarshal.GetArrayDataReference(req); + for (; i + WordsPerBlock <= req.Length; i += WordsPerBlock) + { + var requiredBlock = Vector256.LoadUnsafe(ref reqRef, (nuint)i); + if (Vector256.AndNot(requiredBlock, LoadOrZero(aw, i) | LoadOrZero(bw, i)) + != Vector256.Zero) return false; + } + } + + for (; i < req.Length; i++) + { + var have = (i < aw.Length ? aw[i] : 0) | (i < bw.Length ? bw[i] : 0); + if ((req[i] & ~have) != 0) return false; + } + + return true; + } + + + /// Is fully covered by the union of , , ? + internal static bool ContainsAll3(in TypeBits required, in TypeBits a, in TypeBits b, in TypeBits c) + { + if (required.IsEmpty) return true; + + var req = required._words; + var aw = a._words; + var bw = b._words; + var cw = c._words; + var i = 0; + + if (Vector256.IsHardwareAccelerated) + { + ref var reqRef = ref MemoryMarshal.GetArrayDataReference(req); + for (; i + WordsPerBlock <= req.Length; i += WordsPerBlock) + { + var requiredBlock = Vector256.LoadUnsafe(ref reqRef, (nuint)i); + var have = LoadOrZero(aw, i) | LoadOrZero(bw, i) | LoadOrZero(cw, i); + if (Vector256.AndNot(requiredBlock, have) != Vector256.Zero) return false; + } + } + + for (; i < req.Length; i++) + { + var have = (i < aw.Length ? aw[i] : 0) | (i < bw.Length ? bw[i] : 0) | (i < cw.Length ? cw[i] : 0); + if ((req[i] & ~have) != 0) return false; + } + + return true; + } + + + /// Does intersect the union | ? + internal static bool Intersects2(in TypeBits probe, in TypeBits a, in TypeBits b) + { + if (probe.IsEmpty) return false; + + var pw = probe._words; + var aw = a._words; + var bw = b._words; + var i = 0; + + if (Vector256.IsHardwareAccelerated) + { + ref var probeRef = ref MemoryMarshal.GetArrayDataReference(pw); + for (; i + WordsPerBlock <= pw.Length; i += WordsPerBlock) + if ((Vector256.LoadUnsafe(ref probeRef, (nuint)i) & (LoadOrZero(aw, i) | LoadOrZero(bw, i))) + != Vector256.Zero) return true; + } + + for (; i < pw.Length; i++) + { + var have = (i < aw.Length ? aw[i] : 0) | (i < bw.Length ? bw[i] : 0); + if ((pw[i] & have) != 0) return true; + } + + return false; + } + + + /// Does intersect the union of , , ? + internal static bool Intersects3(in TypeBits probe, in TypeBits a, in TypeBits b, in TypeBits c) + { + if (probe.IsEmpty) return false; + + var pw = probe._words; + var aw = a._words; + var bw = b._words; + var cw = c._words; + var i = 0; + + if (Vector256.IsHardwareAccelerated) + { + ref var probeRef = ref MemoryMarshal.GetArrayDataReference(pw); + for (; i + WordsPerBlock <= pw.Length; i += WordsPerBlock) + { + var have = LoadOrZero(aw, i) | LoadOrZero(bw, i) | LoadOrZero(cw, i); + if ((Vector256.LoadUnsafe(ref probeRef, (nuint)i) & have) != Vector256.Zero) return true; + } + } + + for (; i < pw.Length; i++) + { + var have = (i < aw.Length ? aw[i] : 0) | (i < bw.Length ? bw[i] : 0) | (i < cw.Length ? cw[i] : 0); + if ((pw[i] & have) != 0) return true; + } + + return false; + } +} + + +/// +/// 256-bit bloom filter over packed values: a miss is certain absence, +/// a hit means "maybe present" and requires a precise confirm. +/// +/// +/// Patterns compose by OR (); membership tests are superset tests. This is the extension +/// point for Family/inheritance matching: a filter that also ORs in patterns of ancestor expressions answers +/// "this-or-derived" queries with the same test. +/// +internal readonly struct KeyBloom +{ + private readonly Vector256 _bits; + + internal static readonly KeyBloom Empty = default; + + private KeyBloom(Vector256 bits) => _bits = bits; + + + internal bool IsEmpty => _bits == Vector256.Zero; + + + /// The k=2 bloom pattern of a raw 64-bit expression value. + internal static KeyBloom Of(ulong raw) + { + var hash = Mix(raw); + var bit1 = (int)(hash & 255); + var bit2 = (int)((hash >> 8) & 255); + + Span lanes = stackalloc ulong[4]; + lanes.Clear(); + lanes[bit1 >> 6] |= 1ul << bit1; + lanes[bit2 >> 6] |= 1ul << bit2; + + return new(Vector256.Create(lanes[0], lanes[1], lanes[2], lanes[3])); + } + + + /// The combined filter containing everything in this filter and . + internal KeyBloom Union(in KeyBloom other) => new(_bits | other._bits); + + + /// May this filter contain all entries of ? False is certain absence. + internal bool MayContain(in KeyBloom pattern) => (pattern._bits & ~_bits) == Vector256.Zero; + + + /// + /// Do the two filters share any bit? False certifies that NO entry of is present + /// in this filter — the one-op group rejection for Not clauses (true still requires per-entry confirms). + /// + internal bool Intersects(in KeyBloom other) => (_bits & other._bits) != Vector256.Zero; + + + // splitmix64 finalizer: cheap, well-mixed bits from the structured expression value. + private static ulong Mix(ulong z) + { + z ^= z >> 30; + z *= 0xBF58476D1CE4E5B9ul; + z ^= z >> 27; + z *= 0x94D049BB133111EBul; + z ^= z >> 31; + return z; + } +} diff --git a/src/fennecs/expressions/TypeExpression.cs b/src/fennecs/expressions/TypeExpression.cs index 9b37236d..aaf4e119 100644 --- a/src/fennecs/expressions/TypeExpression.cs +++ b/src/fennecs/expressions/TypeExpression.cs @@ -39,6 +39,9 @@ internal TypeExpression(PrimaryKind kind, TypeID typeId, Key key) } + /// The raw packed 64-bit value; used for hashing into patterns. + internal ulong Raw => _value; + /// The storage kind of this expression. (always today) internal PrimaryKind Kind => (PrimaryKind)(_value >> 60); @@ -75,39 +78,6 @@ internal TypeExpression(PrimaryKind kind, TypeID typeId, Key key) public Type Type => LanguageType.Resolve(TypeId); - /// - /// TODO: Remove me. - /// A method to check if a TypeExpression matches any of the given type expressions in an IEnumerable. - /// Does this match any of the given type expressions? - /// - /// a collection of type expressions - /// true if matched - public bool Matches(IEnumerable other) - { - var self = this; - - //TODO: HUGE OPTIMIZATION POTENTIAL! (set comparison is way faster than linear search, etc.) FIXME!! - foreach (var type in other) - { - // Identical expressions always match; the pairwise check below is not reflexive - // for the Entity/Object Wildcards (their category arms admit only specific keys). - if (self.Equals(type)) return true; - if (self.Matches(type)) return true; - if (type.Matches(self)) return true; - } - - return false; - } - - /// - /// Fast O(1) Matching against (expanded) Signature. - /// - /// - /// The other signature must be a Wildcard-Expanded signature. - /// - public bool Matches(Signature expandedSignature) => expandedSignature.Matches(this); - - /// /// Match against another TypeExpression; used for Query Matching. /// Examines the Type and Key fields of either and decides whether the other TypeExpression is a match. @@ -156,6 +126,10 @@ public bool Matches(TypeExpression other) SecondaryKind.Entity => otherKey.IsEntity, SecondaryKind.Object => otherKey.IsObject, + // Family matches Plain of its own type here; derived types differ in TypeId and are + // rejected by the header check above — inheritance matching is ArchetypeBits' job. + SecondaryKind.Family => otherKey == default, + _ => key == otherKey, }; } @@ -191,39 +165,6 @@ public override string ToString() /// internal TypeExpression WithKey(Key key) => new((_value & TypeHeaderMask) | key.Value); - /// - /// Expands this TypeExpression into a set of TypeExpressions that are Equivalent but unique. - /// - /// - ///
    - ///
  • wild Any -> [ Plain, wild Entity, wild Object, wild Target ]
  • - ///
  • wild Target -> [ wild Any, wild Entity, wild Object ]
  • - ///
  • wild Entity / wild Object -> [ wild Any, wild Target ]
  • - ///
  • specific Object -> [ wild Any, wild Target, wild Object ]
  • - ///
  • specific Entity -> [ wild Any, wild Target, wild Entity ]
  • - ///
  • Plain -> [ wild Any ]
  • - ///
- ///
- public ImmutableHashSet Expand() - { - var key = Key; - - if (key == Key.Any) return [WithKey(default), WithKey(Key.AnyEntity), WithKey(Key.AnyObject), WithKey(Key.Target)]; - - if (key == Key.Target) return [WithKey(Key.Any), WithKey(Key.AnyEntity), WithKey(Key.AnyObject)]; - - if (key == Key.AnyEntity) return [WithKey(Key.Any), WithKey(Key.Target)]; - - if (key == Key.AnyObject) return [WithKey(Key.Any), WithKey(Key.Target)]; - - if (key.IsObject) return [WithKey(Key.Any), WithKey(Key.Target), WithKey(Key.AnyObject)]; - - if (key.IsEntity) return [WithKey(Key.Any), WithKey(Key.Target), WithKey(Key.AnyEntity)]; - - return [WithKey(Key.Any)]; - } - - /// public int CompareTo(TypeExpression other) => _value.CompareTo(other._value); } diff --git a/src/fennecs/generators/FilteredStream.generated.cs b/src/fennecs/generators/FilteredStream.generated.cs index f8571d17..530c3fb2 100644 --- a/src/fennecs/generators/FilteredStream.generated.cs +++ b/src/fennecs/generators/FilteredStream.generated.cs @@ -37,9 +37,9 @@ public readonly struct FilteredStream private readonly ImmutableSortedSet _has; private readonly ImmutableSortedSet _not; - // The above, projected to TypeExpressions once at construction for Archetype matching. - private readonly ImmutableSortedSet _hasExpressions; - private readonly ImmutableSortedSet _notExpressions; + // The above, compiled to bit-testable clauses once at construction for Archetype matching. + private readonly ClauseBits _hasBits; + private readonly ClauseBits _notBits; // Per-entity predicate for C0; null means unset (all entities pass). private readonly ComponentFilter? _filter0; @@ -103,8 +103,8 @@ internal FilteredStream(Stream stream, Stream = stream; _has = has; _not = not; - _hasExpressions = has.Select(comp => comp.Expression).ToImmutableSortedSet(); - _notExpressions = not.Select(comp => comp.Expression).ToImmutableSortedSet(); + _hasBits = ClauseBits.Of(has.Select(comp => comp.Expression)); + _notBits = ClauseBits.Of(not.Select(comp => comp.Expression)); _filter0 = filter0; } #endregion @@ -141,8 +141,7 @@ private bool Pass(in C0 c0) => // Archetype-level inclusion: must have ALL of Has, and NONE of Not. private bool Includes(Archetype candidate) => - candidate.MatchSignature.IsSupersetOf(_hasExpressions) && - !candidate.MatchSignature.Overlaps(_notExpressions); + candidate.Bits.IsSupersetOf(_hasBits) && !candidate.Bits.Overlaps(_notBits); private int CountPass(Span span0) { @@ -868,9 +867,9 @@ public readonly struct FilteredStream private readonly ImmutableSortedSet _has; private readonly ImmutableSortedSet _not; - // The above, projected to TypeExpressions once at construction for Archetype matching. - private readonly ImmutableSortedSet _hasExpressions; - private readonly ImmutableSortedSet _notExpressions; + // The above, compiled to bit-testable clauses once at construction for Archetype matching. + private readonly ClauseBits _hasBits; + private readonly ClauseBits _notBits; // Per-entity predicate for C0; null means unset (all entities pass). private readonly ComponentFilter? _filter0; @@ -936,8 +935,8 @@ internal FilteredStream(Stream stream, Stream = stream; _has = has; _not = not; - _hasExpressions = has.Select(comp => comp.Expression).ToImmutableSortedSet(); - _notExpressions = not.Select(comp => comp.Expression).ToImmutableSortedSet(); + _hasBits = ClauseBits.Of(has.Select(comp => comp.Expression)); + _notBits = ClauseBits.Of(not.Select(comp => comp.Expression)); _filter0 = filter0; _filter1 = filter1; } @@ -982,8 +981,7 @@ private bool Pass(in C0 c0, in C1 c1) => // Archetype-level inclusion: must have ALL of Has, and NONE of Not. private bool Includes(Archetype candidate) => - candidate.MatchSignature.IsSupersetOf(_hasExpressions) && - !candidate.MatchSignature.Overlaps(_notExpressions); + candidate.Bits.IsSupersetOf(_hasBits) && !candidate.Bits.Overlaps(_notBits); private int CountPass(Span span0, Span span1) { @@ -1722,9 +1720,9 @@ public readonly struct FilteredStream private readonly ImmutableSortedSet _has; private readonly ImmutableSortedSet _not; - // The above, projected to TypeExpressions once at construction for Archetype matching. - private readonly ImmutableSortedSet _hasExpressions; - private readonly ImmutableSortedSet _notExpressions; + // The above, compiled to bit-testable clauses once at construction for Archetype matching. + private readonly ClauseBits _hasBits; + private readonly ClauseBits _notBits; // Per-entity predicate for C0; null means unset (all entities pass). private readonly ComponentFilter? _filter0; @@ -1792,8 +1790,8 @@ internal FilteredStream(Stream stream, Stream = stream; _has = has; _not = not; - _hasExpressions = has.Select(comp => comp.Expression).ToImmutableSortedSet(); - _notExpressions = not.Select(comp => comp.Expression).ToImmutableSortedSet(); + _hasBits = ClauseBits.Of(has.Select(comp => comp.Expression)); + _notBits = ClauseBits.Of(not.Select(comp => comp.Expression)); _filter0 = filter0; _filter1 = filter1; _filter2 = filter2; @@ -1846,8 +1844,7 @@ private bool Pass(in C0 c0, in C1 c1, in C2 c2) => // Archetype-level inclusion: must have ALL of Has, and NONE of Not. private bool Includes(Archetype candidate) => - candidate.MatchSignature.IsSupersetOf(_hasExpressions) && - !candidate.MatchSignature.Overlaps(_notExpressions); + candidate.Bits.IsSupersetOf(_hasBits) && !candidate.Bits.Overlaps(_notBits); private int CountPass(Span span0, Span span1, Span span2) { @@ -2599,9 +2596,9 @@ public readonly struct FilteredStream private readonly ImmutableSortedSet _has; private readonly ImmutableSortedSet _not; - // The above, projected to TypeExpressions once at construction for Archetype matching. - private readonly ImmutableSortedSet _hasExpressions; - private readonly ImmutableSortedSet _notExpressions; + // The above, compiled to bit-testable clauses once at construction for Archetype matching. + private readonly ClauseBits _hasBits; + private readonly ClauseBits _notBits; // Per-entity predicate for C0; null means unset (all entities pass). private readonly ComponentFilter? _filter0; @@ -2671,8 +2668,8 @@ internal FilteredStream(Stream stream, Stream = stream; _has = has; _not = not; - _hasExpressions = has.Select(comp => comp.Expression).ToImmutableSortedSet(); - _notExpressions = not.Select(comp => comp.Expression).ToImmutableSortedSet(); + _hasBits = ClauseBits.Of(has.Select(comp => comp.Expression)); + _notBits = ClauseBits.Of(not.Select(comp => comp.Expression)); _filter0 = filter0; _filter1 = filter1; _filter2 = filter2; @@ -2733,8 +2730,7 @@ private bool Pass(in C0 c0, in C1 c1, in C2 c2, in C3 c3) => // Archetype-level inclusion: must have ALL of Has, and NONE of Not. private bool Includes(Archetype candidate) => - candidate.MatchSignature.IsSupersetOf(_hasExpressions) && - !candidate.MatchSignature.Overlaps(_notExpressions); + candidate.Bits.IsSupersetOf(_hasBits) && !candidate.Bits.Overlaps(_notBits); private int CountPass(Span span0, Span span1, Span span2, Span span3) { @@ -3499,9 +3495,9 @@ public readonly struct FilteredStream private readonly ImmutableSortedSet _has; private readonly ImmutableSortedSet _not; - // The above, projected to TypeExpressions once at construction for Archetype matching. - private readonly ImmutableSortedSet _hasExpressions; - private readonly ImmutableSortedSet _notExpressions; + // The above, compiled to bit-testable clauses once at construction for Archetype matching. + private readonly ClauseBits _hasBits; + private readonly ClauseBits _notBits; // Per-entity predicate for C0; null means unset (all entities pass). private readonly ComponentFilter? _filter0; @@ -3573,8 +3569,8 @@ internal FilteredStream(Stream stream, Stream = stream; _has = has; _not = not; - _hasExpressions = has.Select(comp => comp.Expression).ToImmutableSortedSet(); - _notExpressions = not.Select(comp => comp.Expression).ToImmutableSortedSet(); + _hasBits = ClauseBits.Of(has.Select(comp => comp.Expression)); + _notBits = ClauseBits.Of(not.Select(comp => comp.Expression)); _filter0 = filter0; _filter1 = filter1; _filter2 = filter2; @@ -3643,8 +3639,7 @@ private bool Pass(in C0 c0, in C1 c1, in C2 c2, in C3 c3, in C4 c4) => // Archetype-level inclusion: must have ALL of Has, and NONE of Not. private bool Includes(Archetype candidate) => - candidate.MatchSignature.IsSupersetOf(_hasExpressions) && - !candidate.MatchSignature.Overlaps(_notExpressions); + candidate.Bits.IsSupersetOf(_hasBits) && !candidate.Bits.Overlaps(_notBits); private int CountPass(Span span0, Span span1, Span span2, Span span3, Span span4) { diff --git a/src/fennecs/generators/FilteredStream.tt b/src/fennecs/generators/FilteredStream.tt index a149c4d9..be52fb60 100644 --- a/src/fennecs/generators/FilteredStream.tt +++ b/src/fennecs/generators/FilteredStream.tt @@ -87,9 +87,9 @@ namespace fennecs private readonly ImmutableSortedSet _has; private readonly ImmutableSortedSet _not; - // The above, projected to TypeExpressions once at construction for Archetype matching. - private readonly ImmutableSortedSet _hasExpressions; - private readonly ImmutableSortedSet _notExpressions; + // The above, compiled to bit-testable clauses once at construction for Archetype matching. + private readonly ClauseBits _hasBits; + private readonly ClauseBits _notBits; <# for (var i = 0; i < n; i++) { #> // Per-entity predicate for C<#= i #>; null means unset (all entities pass). @@ -155,8 +155,8 @@ namespace fennecs Stream = stream; _has = has; _not = not; - _hasExpressions = has.Select(comp => comp.Expression).ToImmutableSortedSet(); - _notExpressions = not.Select(comp => comp.Expression).ToImmutableSortedSet(); + _hasBits = ClauseBits.Of(has.Select(comp => comp.Expression)); + _notBits = ClauseBits.Of(not.Select(comp => comp.Expression)); <# for (var i = 0; i < n; i++) { #> _filter<#= i #> = filter<#= i #>; <# } #> @@ -197,8 +197,7 @@ namespace fennecs // Archetype-level inclusion: must have ALL of Has, and NONE of Not. private bool Includes(Archetype candidate) => - candidate.MatchSignature.IsSupersetOf(_hasExpressions) && - !candidate.MatchSignature.Overlaps(_notExpressions); + candidate.Bits.IsSupersetOf(_hasBits) && !candidate.Bits.Overlaps(_notBits); private int CountPass(<#= spanParams #>) { diff --git a/src/fennecs/generators/Stream.generated.cs b/src/fennecs/generators/Stream.generated.cs index 919714df..29ee838f 100644 --- a/src/fennecs/generators/Stream.generated.cs +++ b/src/fennecs/generators/Stream.generated.cs @@ -26,6 +26,10 @@ public readonly record struct Stream // The component TypeExpressions that this Stream operates on. internal readonly ImmutableArray StreamTypes; + // True if any stream type is a Family expression: components then come from covariant + // (derived-type) storages and are only accessible read-only. (see AssertNotFamily) + private readonly bool _family; + /// /// The Query this Stream is associated with. /// @@ -56,6 +60,11 @@ public Stream(Query query, Match match0) StreamTypes = ImmutableArray.Create( TypeExpression.Of(match0) ); + + var family = false; + foreach (var type in StreamTypes) family |= type.Key == Key.Family; + _family = family; + Query = query; } #endregion @@ -66,28 +75,38 @@ public Stream(Query query, Match match0) /// Creates a narrowed to Archetypes /// that have ALL of the given components. ///
- public FilteredStream Has(params Comp[] components) => - new(this, [.. components], [], null); + public FilteredStream Has(params Comp[] components) + { + AssertNotFamily(); + return new(this, [.. components], [], null); + } /// /// Creates a narrowed to Archetypes /// that have NONE of the given components. /// - public FilteredStream Not(params Comp[] components) => - new(this, [], [.. components], null); + public FilteredStream Not(params Comp[] components) + { + AssertNotFamily(); + return new(this, [], [.. components], null); + } /// /// Creates a with a per-entity /// predicate for Component C0. /// - public FilteredStream Where(ComponentFilter filter0) => - new(this, [], [], filter0); + public FilteredStream Where(ComponentFilter filter0) + { + AssertNotFamily(); + return new(this, [], [], filter0); + } #endregion #region For /// public void For(ComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -105,6 +124,7 @@ public void For(ComponentAction action) /// public void For(U uniform, UniformComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -122,6 +142,7 @@ public void For(U uniform, UniformComponentAction action) /// public void For(EntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -139,6 +160,7 @@ public void For(EntityComponentAction action) /// public void For(U uniform, UniformEntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -154,11 +176,78 @@ public void For(U uniform, UniformEntityComponentAction action) } #endregion + #region For (read-only) + /// + /// Read-only For: components arrive as in (readonly) references. Serves all Stream Types, + /// including , whose derived components arrive viewed as their base type. + /// Mutate class components through their members; component slots cannot be reassigned. + /// + public void ForRead(ComponentActionI action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopRead(join.Span(0), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformComponentActionI action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformRead(join.Span(0), action, uniform); + } while (join.Iterate()); + } + } + + /// + public void ForRead(EntityComponentActionI action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopEntityRead(table, join.Span(0), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformEntityComponentActionI action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformEntityRead(table, join.Span(0), action, uniform); + } while (join.Iterate()); + } + } + #endregion + #region Job /// [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(ComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -243,6 +332,7 @@ public void Job(ComponentAction action) [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(U uniform, UniformComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -330,6 +420,7 @@ public void Job(U uniform, UniformComponentAction action) /// public void Raw(MemoryAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -348,6 +439,7 @@ public void Raw(MemoryAction action) /// public void Raw(U uniform, MemoryUniformAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -367,7 +459,9 @@ public void Raw(U uniform, MemoryUniformAction action) #region IEnumerable /// - public IEnumerator<(Entity, C0)> GetEnumerator() + public IEnumerator<(Entity, C0)> GetEnumerator() => _family ? EnumerateRead() : Enumerate(); + + private IEnumerator<(Entity, C0)> Enumerate() { foreach (var table in Archetypes) { @@ -385,6 +479,25 @@ public void Raw(U uniform, MemoryUniformAction action) } while (join.Iterate()); } } + + // Family variant: elements come from covariant (derived-type) storages, one indirection per element. + private IEnumerator<(Entity, C0)> EnumerateRead() + { + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + var snapshot = table.Version; + do + { + for (var i=0; i(0, i)); + if (table.Version != snapshot) throw new InvalidOperationException("Collection was modified during iteration."); + } + } while (join.Iterate()); + } + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); #endregion @@ -395,6 +508,7 @@ public void Raw(U uniform, MemoryUniformAction action) ///
public void Blit(C0 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -437,6 +551,42 @@ private void LoopUniformEntity(Archetype table, Span span0, UniformEntity action(uniform, new EntityRef(table, i), ref span0[i]); } } + + private static void LoopRead(ReadOnlySpan span0, ComponentActionI action) + { + var length = span0.Length; + for (var i=0;i(ReadOnlySpan span0, UniformComponentActionI action, U uniform) + { + var length = span0.Length; + for (var i=0;i span0, EntityComponentActionI action) + { + var length = span0.Length; + for (var i=0;i(Archetype table, ReadOnlySpan span0, UniformEntityComponentActionI action, U uniform) + { + var length = span0.Length; + for (var i=0;i streamTypes throw new InvalidOperationException( $"Cannot run this operation on wildcard Stream Types (write destination Aliasing). {streamTypes}"); } + + private void AssertNotFamily() + { + if (!_family) return; + throw new InvalidOperationException( + "Match.Family Stream Types provide read-only component access: writable-ref runners, Raw, Blit, " + + "and FilteredStream views cannot serve them. Use the ForRead overloads (in-parameters) or enumeration."); + } #endregion #region Query Forwarding @@ -478,6 +636,10 @@ public readonly record struct Stream // The component TypeExpressions that this Stream operates on. internal readonly ImmutableArray StreamTypes; + // True if any stream type is a Family expression: components then come from covariant + // (derived-type) storages and are only accessible read-only. (see AssertNotFamily) + private readonly bool _family; + /// /// The Query this Stream is associated with. /// @@ -509,6 +671,11 @@ public Stream(Query query, Match match0, Match match1) TypeExpression.Of(match0), TypeExpression.Of(match1) ); + + var family = false; + foreach (var type in StreamTypes) family |= type.Key == Key.Family; + _family = family; + Query = query; } #endregion @@ -519,35 +686,48 @@ public Stream(Query query, Match match0, Match match1) /// Creates a narrowed to Archetypes /// that have ALL of the given components. ///
- public FilteredStream Has(params Comp[] components) => - new(this, [.. components], [], null, null); + public FilteredStream Has(params Comp[] components) + { + AssertNotFamily(); + return new(this, [.. components], [], null, null); + } /// /// Creates a narrowed to Archetypes /// that have NONE of the given components. /// - public FilteredStream Not(params Comp[] components) => - new(this, [], [.. components], null, null); + public FilteredStream Not(params Comp[] components) + { + AssertNotFamily(); + return new(this, [], [.. components], null, null); + } /// /// Creates a with a per-entity /// predicate for Component C0. /// - public FilteredStream Where(ComponentFilter filter0) => - new(this, [], [], filter0, null); + public FilteredStream Where(ComponentFilter filter0) + { + AssertNotFamily(); + return new(this, [], [], filter0, null); + } /// /// Creates a with a per-entity /// predicate for Component C1. /// - public FilteredStream Where(ComponentFilter filter1) => - new(this, [], [], null, filter1); + public FilteredStream Where(ComponentFilter filter1) + { + AssertNotFamily(); + return new(this, [], [], null, filter1); + } #endregion #region For /// public void For(ComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -565,6 +745,7 @@ public void For(ComponentAction action) /// public void For(U uniform, UniformComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -582,6 +763,7 @@ public void For(U uniform, UniformComponentAction action) /// public void For(EntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -599,6 +781,7 @@ public void For(EntityComponentAction action) /// public void For(U uniform, UniformEntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -614,11 +797,78 @@ public void For(U uniform, UniformEntityComponentAction action) } #endregion + #region For (read-only) + /// + /// Read-only For: components arrive as in (readonly) references. Serves all Stream Types, + /// including , whose derived components arrive viewed as their base type. + /// Mutate class components through their members; component slots cannot be reassigned. + /// + public void ForRead(ComponentActionII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopRead(join.Span(0), join.Span(1), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformComponentActionII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformRead(join.Span(0), join.Span(1), action, uniform); + } while (join.Iterate()); + } + } + + /// + public void ForRead(EntityComponentActionII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopEntityRead(table, join.Span(0), join.Span(1), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformEntityComponentActionII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformEntityRead(table, join.Span(0), join.Span(1), action, uniform); + } while (join.Iterate()); + } + } + #endregion + #region Job /// [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(ComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -705,6 +955,7 @@ public void Job(ComponentAction action) [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(U uniform, UniformComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -794,6 +1045,7 @@ public void Job(U uniform, UniformComponentAction action) /// public void Raw(MemoryAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -813,6 +1065,7 @@ public void Raw(MemoryAction action) /// public void Raw(U uniform, MemoryUniformAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -834,7 +1087,9 @@ public void Raw(U uniform, MemoryUniformAction action) #region IEnumerable /// - public IEnumerator<(Entity, C0, C1)> GetEnumerator() + public IEnumerator<(Entity, C0, C1)> GetEnumerator() => _family ? EnumerateRead() : Enumerate(); + + private IEnumerator<(Entity, C0, C1)> Enumerate() { foreach (var table in Archetypes) { @@ -852,6 +1107,25 @@ public void Raw(U uniform, MemoryUniformAction action) } while (join.Iterate()); } } + + // Family variant: elements come from covariant (derived-type) storages, one indirection per element. + private IEnumerator<(Entity, C0, C1)> EnumerateRead() + { + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + var snapshot = table.Version; + do + { + for (var i=0; i(0, i), join.Get(1, i)); + if (table.Version != snapshot) throw new InvalidOperationException("Collection was modified during iteration."); + } + } while (join.Iterate()); + } + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); #endregion @@ -862,6 +1136,7 @@ public void Raw(U uniform, MemoryUniformAction action) ///
public void Blit(C0 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -872,6 +1147,7 @@ public void Blit(C0 value, Match match = default) ///
public void Blit(C1 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -914,6 +1190,42 @@ private void LoopUniformEntity(Archetype table, Span span0,Span span1 action(uniform, new EntityRef(table, i), ref span0[i], ref span1[i]); } } + + private static void LoopRead(ReadOnlySpan span0,ReadOnlySpan span1, ComponentActionII action) + { + var length = span0.Length; + for (var i=0;i(ReadOnlySpan span0,ReadOnlySpan span1, UniformComponentActionII action, U uniform) + { + var length = span0.Length; + for (var i=0;i span0,ReadOnlySpan span1, EntityComponentActionII action) + { + var length = span0.Length; + for (var i=0;i(Archetype table, ReadOnlySpan span0,ReadOnlySpan span1, UniformEntityComponentActionII action, U uniform) + { + var length = span0.Length; + for (var i=0;i streamTypes throw new InvalidOperationException( $"Cannot run this operation on wildcard Stream Types (write destination Aliasing). {streamTypes}"); } + + private void AssertNotFamily() + { + if (!_family) return; + throw new InvalidOperationException( + "Match.Family Stream Types provide read-only component access: writable-ref runners, Raw, Blit, " + + "and FilteredStream views cannot serve them. Use the ForRead overloads (in-parameters) or enumeration."); + } #endregion #region Query Forwarding @@ -956,6 +1276,10 @@ public readonly record struct Stream // The component TypeExpressions that this Stream operates on. internal readonly ImmutableArray StreamTypes; + // True if any stream type is a Family expression: components then come from covariant + // (derived-type) storages and are only accessible read-only. (see AssertNotFamily) + private readonly bool _family; + /// /// The Query this Stream is associated with. /// @@ -988,6 +1312,11 @@ public Stream(Query query, Match match0, Match match1, Match match2) TypeExpression.Of(match1), TypeExpression.Of(match2) ); + + var family = false; + foreach (var type in StreamTypes) family |= type.Key == Key.Family; + _family = family; + Query = query; } #endregion @@ -998,42 +1327,58 @@ public Stream(Query query, Match match0, Match match1, Match match2) /// Creates a narrowed to Archetypes /// that have ALL of the given components. ///
- public FilteredStream Has(params Comp[] components) => - new(this, [.. components], [], null, null, null); + public FilteredStream Has(params Comp[] components) + { + AssertNotFamily(); + return new(this, [.. components], [], null, null, null); + } /// /// Creates a narrowed to Archetypes /// that have NONE of the given components. /// - public FilteredStream Not(params Comp[] components) => - new(this, [], [.. components], null, null, null); + public FilteredStream Not(params Comp[] components) + { + AssertNotFamily(); + return new(this, [], [.. components], null, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C0. /// - public FilteredStream Where(ComponentFilter filter0) => - new(this, [], [], filter0, null, null); + public FilteredStream Where(ComponentFilter filter0) + { + AssertNotFamily(); + return new(this, [], [], filter0, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C1. /// - public FilteredStream Where(ComponentFilter filter1) => - new(this, [], [], null, filter1, null); + public FilteredStream Where(ComponentFilter filter1) + { + AssertNotFamily(); + return new(this, [], [], null, filter1, null); + } /// /// Creates a with a per-entity /// predicate for Component C2. /// - public FilteredStream Where(ComponentFilter filter2) => - new(this, [], [], null, null, filter2); + public FilteredStream Where(ComponentFilter filter2) + { + AssertNotFamily(); + return new(this, [], [], null, null, filter2); + } #endregion #region For /// public void For(ComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1051,6 +1396,7 @@ public void For(ComponentAction action) /// public void For(U uniform, UniformComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1068,6 +1414,7 @@ public void For(U uniform, UniformComponentAction action) /// public void For(EntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1085,6 +1432,7 @@ public void For(EntityComponentAction action) /// public void For(U uniform, UniformEntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1100,11 +1448,78 @@ public void For(U uniform, UniformEntityComponentAction action } #endregion + #region For (read-only) + /// + /// Read-only For: components arrive as in (readonly) references. Serves all Stream Types, + /// including , whose derived components arrive viewed as their base type. + /// Mutate class components through their members; component slots cannot be reassigned. + /// + public void ForRead(ComponentActionIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopRead(join.Span(0), join.Span(1), join.Span(2), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformComponentActionIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformRead(join.Span(0), join.Span(1), join.Span(2), action, uniform); + } while (join.Iterate()); + } + } + + /// + public void ForRead(EntityComponentActionIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopEntityRead(table, join.Span(0), join.Span(1), join.Span(2), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformEntityComponentActionIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformEntityRead(table, join.Span(0), join.Span(1), join.Span(2), action, uniform); + } while (join.Iterate()); + } + } + #endregion + #region Job /// [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(ComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -1193,6 +1608,7 @@ public void Job(ComponentAction action) [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(U uniform, UniformComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -1284,6 +1700,7 @@ public void Job(U uniform, UniformComponentAction action) /// public void Raw(MemoryAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1304,6 +1721,7 @@ public void Raw(MemoryAction action) /// public void Raw(U uniform, MemoryUniformAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1325,21 +1743,42 @@ public void Raw(U uniform, MemoryUniformAction action) } #endregion - #region IEnumerable - /// - public IEnumerator<(Entity, C0, C1, C2)> GetEnumerator() + #region IEnumerable + /// + public IEnumerator<(Entity, C0, C1, C2)> GetEnumerator() => _family ? EnumerateRead() : Enumerate(); + + private IEnumerator<(Entity, C0, C1, C2)> Enumerate() + { + foreach (var table in Archetypes) + { + using var join = table.CrossJoin(StreamTypes.AsSpan()); + if (join.Empty) continue; + var snapshot = table.Version; + do + { + var (s0, s1, s2) = join.Select; + for (var i=0; i EnumerateRead() { foreach (var table in Archetypes) { - using var join = table.CrossJoin(StreamTypes.AsSpan()); + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); if (join.Empty) continue; var snapshot = table.Version; do { - var (s0, s1, s2) = join.Select; for (var i=0; i(0, i), join.Get(1, i), join.Get(2, i)); if (table.Version != snapshot) throw new InvalidOperationException("Collection was modified during iteration."); } } while (join.Iterate()); @@ -1355,6 +1794,7 @@ public void Raw(U uniform, MemoryUniformAction action) ///
public void Blit(C0 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1365,6 +1805,7 @@ public void Blit(C0 value, Match match = default) /// public void Blit(C1 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1375,6 +1816,7 @@ public void Blit(C1 value, Match match = default) /// public void Blit(C2 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1417,6 +1859,42 @@ private void LoopUniformEntity(Archetype table, Span span0,Span span1 action(uniform, new EntityRef(table, i), ref span0[i], ref span1[i], ref span2[i]); } } + + private static void LoopRead(ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2, ComponentActionIII action) + { + var length = span0.Length; + for (var i=0;i(ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2, UniformComponentActionIII action, U uniform) + { + var length = span0.Length; + for (var i=0;i span0,ReadOnlySpan span1,ReadOnlySpan span2, EntityComponentActionIII action) + { + var length = span0.Length; + for (var i=0;i(Archetype table, ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2, UniformEntityComponentActionIII action, U uniform) + { + var length = span0.Length; + for (var i=0;i streamTypes throw new InvalidOperationException( $"Cannot run this operation on wildcard Stream Types (write destination Aliasing). {streamTypes}"); } + + private void AssertNotFamily() + { + if (!_family) return; + throw new InvalidOperationException( + "Match.Family Stream Types provide read-only component access: writable-ref runners, Raw, Blit, " + + "and FilteredStream views cannot serve them. Use the ForRead overloads (in-parameters) or enumeration."); + } #endregion #region Query Forwarding @@ -1460,6 +1946,10 @@ public readonly record struct Stream // The component TypeExpressions that this Stream operates on. internal readonly ImmutableArray StreamTypes; + // True if any stream type is a Family expression: components then come from covariant + // (derived-type) storages and are only accessible read-only. (see AssertNotFamily) + private readonly bool _family; + /// /// The Query this Stream is associated with. /// @@ -1493,6 +1983,11 @@ public Stream(Query query, Match match0, Match match1, Match match2, Match match TypeExpression.Of(match2), TypeExpression.Of(match3) ); + + var family = false; + foreach (var type in StreamTypes) family |= type.Key == Key.Family; + _family = family; + Query = query; } #endregion @@ -1503,49 +1998,68 @@ public Stream(Query query, Match match0, Match match1, Match match2, Match match /// Creates a narrowed to Archetypes /// that have ALL of the given components. /// - public FilteredStream Has(params Comp[] components) => - new(this, [.. components], [], null, null, null, null); + public FilteredStream Has(params Comp[] components) + { + AssertNotFamily(); + return new(this, [.. components], [], null, null, null, null); + } /// /// Creates a narrowed to Archetypes /// that have NONE of the given components. /// - public FilteredStream Not(params Comp[] components) => - new(this, [], [.. components], null, null, null, null); + public FilteredStream Not(params Comp[] components) + { + AssertNotFamily(); + return new(this, [], [.. components], null, null, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C0. /// - public FilteredStream Where(ComponentFilter filter0) => - new(this, [], [], filter0, null, null, null); + public FilteredStream Where(ComponentFilter filter0) + { + AssertNotFamily(); + return new(this, [], [], filter0, null, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C1. /// - public FilteredStream Where(ComponentFilter filter1) => - new(this, [], [], null, filter1, null, null); + public FilteredStream Where(ComponentFilter filter1) + { + AssertNotFamily(); + return new(this, [], [], null, filter1, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C2. /// - public FilteredStream Where(ComponentFilter filter2) => - new(this, [], [], null, null, filter2, null); + public FilteredStream Where(ComponentFilter filter2) + { + AssertNotFamily(); + return new(this, [], [], null, null, filter2, null); + } /// /// Creates a with a per-entity /// predicate for Component C3. /// - public FilteredStream Where(ComponentFilter filter3) => - new(this, [], [], null, null, null, filter3); + public FilteredStream Where(ComponentFilter filter3) + { + AssertNotFamily(); + return new(this, [], [], null, null, null, filter3); + } #endregion #region For /// public void For(ComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1563,6 +2077,7 @@ public void For(ComponentAction action) /// public void For(U uniform, UniformComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1580,6 +2095,7 @@ public void For(U uniform, UniformComponentAction action) /// public void For(EntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1597,6 +2113,7 @@ public void For(EntityComponentAction action) /// public void For(U uniform, UniformEntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1612,11 +2129,78 @@ public void For(U uniform, UniformEntityComponentAction ac } #endregion + #region For (read-only) + /// + /// Read-only For: components arrive as in (readonly) references. Serves all Stream Types, + /// including , whose derived components arrive viewed as their base type. + /// Mutate class components through their members; component slots cannot be reassigned. + /// + public void ForRead(ComponentActionIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopRead(join.Span(0), join.Span(1), join.Span(2), join.Span(3), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformComponentActionIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformRead(join.Span(0), join.Span(1), join.Span(2), join.Span(3), action, uniform); + } while (join.Iterate()); + } + } + + /// + public void ForRead(EntityComponentActionIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopEntityRead(table, join.Span(0), join.Span(1), join.Span(2), join.Span(3), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformEntityComponentActionIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformEntityRead(table, join.Span(0), join.Span(1), join.Span(2), join.Span(3), action, uniform); + } while (join.Iterate()); + } + } + #endregion + #region Job /// [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(ComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -1707,6 +2291,7 @@ public void Job(ComponentAction action) [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(U uniform, UniformComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -1800,6 +2385,7 @@ public void Job(U uniform, UniformComponentAction action) /// public void Raw(MemoryAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1821,6 +2407,7 @@ public void Raw(MemoryAction action) /// public void Raw(U uniform, MemoryUniformAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -1846,7 +2433,9 @@ public void Raw(U uniform, MemoryUniformAction action) #region IEnumerable /// - public IEnumerator<(Entity, C0, C1, C2, C3)> GetEnumerator() + public IEnumerator<(Entity, C0, C1, C2, C3)> GetEnumerator() => _family ? EnumerateRead() : Enumerate(); + + private IEnumerator<(Entity, C0, C1, C2, C3)> Enumerate() { foreach (var table in Archetypes) { @@ -1864,6 +2453,25 @@ public void Raw(U uniform, MemoryUniformAction action) } while (join.Iterate()); } } + + // Family variant: elements come from covariant (derived-type) storages, one indirection per element. + private IEnumerator<(Entity, C0, C1, C2, C3)> EnumerateRead() + { + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + var snapshot = table.Version; + do + { + for (var i=0; i(0, i), join.Get(1, i), join.Get(2, i), join.Get(3, i)); + if (table.Version != snapshot) throw new InvalidOperationException("Collection was modified during iteration."); + } + } while (join.Iterate()); + } + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); #endregion @@ -1874,6 +2482,7 @@ public void Raw(U uniform, MemoryUniformAction action) /// public void Blit(C0 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1884,6 +2493,7 @@ public void Blit(C0 value, Match match = default) /// public void Blit(C1 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1894,6 +2504,7 @@ public void Blit(C1 value, Match match = default) /// public void Blit(C2 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1904,6 +2515,7 @@ public void Blit(C2 value, Match match = default) /// public void Blit(C3 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -1946,6 +2558,42 @@ private void LoopUniformEntity(Archetype table, Span span0,Span span1 action(uniform, new EntityRef(table, i), ref span0[i], ref span1[i], ref span2[i], ref span3[i]); } } + + private static void LoopRead(ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3, ComponentActionIIII action) + { + var length = span0.Length; + for (var i=0;i(ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3, UniformComponentActionIIII action, U uniform) + { + var length = span0.Length; + for (var i=0;i span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3, EntityComponentActionIIII action) + { + var length = span0.Length; + for (var i=0;i(Archetype table, ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3, UniformEntityComponentActionIIII action, U uniform) + { + var length = span0.Length; + for (var i=0;i streamTypes throw new InvalidOperationException( $"Cannot run this operation on wildcard Stream Types (write destination Aliasing). {streamTypes}"); } + + private void AssertNotFamily() + { + if (!_family) return; + throw new InvalidOperationException( + "Match.Family Stream Types provide read-only component access: writable-ref runners, Raw, Blit, " + + "and FilteredStream views cannot serve them. Use the ForRead overloads (in-parameters) or enumeration."); + } #endregion #region Query Forwarding @@ -1990,6 +2646,10 @@ public readonly record struct Stream // The component TypeExpressions that this Stream operates on. internal readonly ImmutableArray StreamTypes; + // True if any stream type is a Family expression: components then come from covariant + // (derived-type) storages and are only accessible read-only. (see AssertNotFamily) + private readonly bool _family; + /// /// The Query this Stream is associated with. /// @@ -2024,6 +2684,11 @@ public Stream(Query query, Match match0, Match match1, Match match2, Match match TypeExpression.Of(match3), TypeExpression.Of(match4) ); + + var family = false; + foreach (var type in StreamTypes) family |= type.Key == Key.Family; + _family = family; + Query = query; } #endregion @@ -2034,56 +2699,78 @@ public Stream(Query query, Match match0, Match match1, Match match2, Match match /// Creates a narrowed to Archetypes /// that have ALL of the given components. /// - public FilteredStream Has(params Comp[] components) => - new(this, [.. components], [], null, null, null, null, null); + public FilteredStream Has(params Comp[] components) + { + AssertNotFamily(); + return new(this, [.. components], [], null, null, null, null, null); + } /// /// Creates a narrowed to Archetypes /// that have NONE of the given components. /// - public FilteredStream Not(params Comp[] components) => - new(this, [], [.. components], null, null, null, null, null); + public FilteredStream Not(params Comp[] components) + { + AssertNotFamily(); + return new(this, [], [.. components], null, null, null, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C0. /// - public FilteredStream Where(ComponentFilter filter0) => - new(this, [], [], filter0, null, null, null, null); + public FilteredStream Where(ComponentFilter filter0) + { + AssertNotFamily(); + return new(this, [], [], filter0, null, null, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C1. /// - public FilteredStream Where(ComponentFilter filter1) => - new(this, [], [], null, filter1, null, null, null); + public FilteredStream Where(ComponentFilter filter1) + { + AssertNotFamily(); + return new(this, [], [], null, filter1, null, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C2. /// - public FilteredStream Where(ComponentFilter filter2) => - new(this, [], [], null, null, filter2, null, null); + public FilteredStream Where(ComponentFilter filter2) + { + AssertNotFamily(); + return new(this, [], [], null, null, filter2, null, null); + } /// /// Creates a with a per-entity /// predicate for Component C3. /// - public FilteredStream Where(ComponentFilter filter3) => - new(this, [], [], null, null, null, filter3, null); + public FilteredStream Where(ComponentFilter filter3) + { + AssertNotFamily(); + return new(this, [], [], null, null, null, filter3, null); + } /// /// Creates a with a per-entity /// predicate for Component C4. /// - public FilteredStream Where(ComponentFilter filter4) => - new(this, [], [], null, null, null, null, filter4); + public FilteredStream Where(ComponentFilter filter4) + { + AssertNotFamily(); + return new(this, [], [], null, null, null, null, filter4); + } #endregion #region For /// public void For(ComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -2101,6 +2788,7 @@ public void For(ComponentAction action) /// public void For(U uniform, UniformComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -2118,6 +2806,7 @@ public void For(U uniform, UniformComponentAction acti /// public void For(EntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -2135,6 +2824,7 @@ public void For(EntityComponentAction action) /// public void For(U uniform, UniformEntityComponentAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -2150,11 +2840,78 @@ public void For(U uniform, UniformEntityComponentAction + /// Read-only For: components arrive as in (readonly) references. Serves all Stream Types, + /// including , whose derived components arrive viewed as their base type. + /// Mutate class components through their members; component slots cannot be reassigned. + /// + public void ForRead(ComponentActionIIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopRead(join.Span(0), join.Span(1), join.Span(2), join.Span(3), join.Span(4), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformComponentActionIIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformRead(join.Span(0), join.Span(1), join.Span(2), join.Span(3), join.Span(4), action, uniform); + } while (join.Iterate()); + } + } + + /// + public void ForRead(EntityComponentActionIIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopEntityRead(table, join.Span(0), join.Span(1), join.Span(2), join.Span(3), join.Span(4), action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformEntityComponentActionIIIII action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformEntityRead(table, join.Span(0), join.Span(1), join.Span(2), join.Span(3), join.Span(4), action, uniform); + } while (join.Iterate()); + } + } + #endregion + #region Job /// [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(ComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -2247,6 +3004,7 @@ public void Job(ComponentAction action) [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(U uniform, UniformComponentAction action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -2342,6 +3100,7 @@ public void Job(U uniform, UniformComponentAction acti /// public void Raw(MemoryAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -2364,6 +3123,7 @@ public void Raw(MemoryAction action) /// public void Raw(U uniform, MemoryUniformAction action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -2391,7 +3151,9 @@ public void Raw(U uniform, MemoryUniformAction action) #region IEnumerable /// - public IEnumerator<(Entity, C0, C1, C2, C3, C4)> GetEnumerator() + public IEnumerator<(Entity, C0, C1, C2, C3, C4)> GetEnumerator() => _family ? EnumerateRead() : Enumerate(); + + private IEnumerator<(Entity, C0, C1, C2, C3, C4)> Enumerate() { foreach (var table in Archetypes) { @@ -2409,6 +3171,25 @@ public void Raw(U uniform, MemoryUniformAction action) } while (join.Iterate()); } } + + // Family variant: elements come from covariant (derived-type) storages, one indirection per element. + private IEnumerator<(Entity, C0, C1, C2, C3, C4)> EnumerateRead() + { + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + var snapshot = table.Version; + do + { + for (var i=0; i(0, i), join.Get(1, i), join.Get(2, i), join.Get(3, i), join.Get(4, i)); + if (table.Version != snapshot) throw new InvalidOperationException("Collection was modified during iteration."); + } + } while (join.Iterate()); + } + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); #endregion @@ -2419,6 +3200,7 @@ public void Raw(U uniform, MemoryUniformAction action) /// public void Blit(C0 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -2429,6 +3211,7 @@ public void Blit(C0 value, Match match = default) /// public void Blit(C1 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -2439,6 +3222,7 @@ public void Blit(C1 value, Match match = default) /// public void Blit(C2 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -2449,6 +3233,7 @@ public void Blit(C2 value, Match match = default) /// public void Blit(C3 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -2459,6 +3244,7 @@ public void Blit(C3 value, Match match = default) /// public void Blit(C4 value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -2501,6 +3287,42 @@ private void LoopUniformEntity(Archetype table, Span span0,Span span1 action(uniform, new EntityRef(table, i), ref span0[i], ref span1[i], ref span2[i], ref span3[i], ref span4[i]); } } + + private static void LoopRead(ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3,ReadOnlySpan span4, ComponentActionIIIII action) + { + var length = span0.Length; + for (var i=0;i(ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3,ReadOnlySpan span4, UniformComponentActionIIIII action, U uniform) + { + var length = span0.Length; + for (var i=0;i span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3,ReadOnlySpan span4, EntityComponentActionIIIII action) + { + var length = span0.Length; + for (var i=0;i(Archetype table, ReadOnlySpan span0,ReadOnlySpan span1,ReadOnlySpan span2,ReadOnlySpan span3,ReadOnlySpan span4, UniformEntityComponentActionIIIII action, U uniform) + { + var length = span0.Length; + for (var i=0;i streamTypes throw new InvalidOperationException( $"Cannot run this operation on wildcard Stream Types (write destination Aliasing). {streamTypes}"); } + + private void AssertNotFamily() + { + if (!_family) return; + throw new InvalidOperationException( + "Match.Family Stream Types provide read-only component access: writable-ref runners, Raw, Blit, " + + "and FilteredStream views cannot serve them. Use the ForRead overloads (in-parameters) or enumeration."); + } #endregion #region Query Forwarding diff --git a/src/fennecs/generators/Stream.tt b/src/fennecs/generators/Stream.tt index 1472e99b..ace09a3a 100644 --- a/src/fennecs/generators/Stream.tt +++ b/src/fennecs/generators/Stream.tt @@ -77,6 +77,10 @@ namespace fennecs // The component TypeExpressions that this Stream operates on. internal readonly ImmutableArray StreamTypes; + // True if any stream type is a Family expression: components then come from covariant + // (derived-type) storages and are only accessible read-only. (see AssertNotFamily) + private readonly bool _family; + /// /// The Query this Stream is associated with. /// @@ -107,6 +111,11 @@ namespace fennecs StreamTypes = ImmutableArray.Create( <# for (var i = 0; i < n; i++) { #> TypeExpression.Of>(match<#= i #>)<#= i < n - 1 ? "," : "" #> <# } #>); + + var family = false; + foreach (var type in StreamTypes) family |= type.Key == Key.Family; + _family = family; + Query = query; } #endregion @@ -117,23 +126,32 @@ namespace fennecs /// Creates a narrowed to Archetypes /// that have ALL of the given components. /// - public FilteredStream<<#= types #>> Has(params Comp[] components) => - new(this, [.. components], [], <#= string.Join(", ", Enumerable.Repeat("null", n)) #>); + public FilteredStream<<#= types #>> Has(params Comp[] components) + { + AssertNotFamily(); + return new(this, [.. components], [], <#= string.Join(", ", Enumerable.Repeat("null", n)) #>); + } /// /// Creates a narrowed to Archetypes /// that have NONE of the given components. /// - public FilteredStream<<#= types #>> Not(params Comp[] components) => - new(this, [], [.. components], <#= string.Join(", ", Enumerable.Repeat("null", n)) #>); + public FilteredStream<<#= types #>> Not(params Comp[] components) + { + AssertNotFamily(); + return new(this, [], [.. components], <#= string.Join(", ", Enumerable.Repeat("null", n)) #>); + } <# for (var i = 0; i < n; i++) { #> /// /// Creates a with a per-entity /// predicate for Component C<#= i #>. /// - public FilteredStream<<#= types #>> Where(ComponentFilter> filter<#= i #>) => - new(this, [], [], <#= string.Join(", ", Enumerable.Range(0, n).Select(j => j == i ? $"filter{j}" : "null")) #>); + public FilteredStream<<#= types #>> Where(ComponentFilter> filter<#= i #>) + { + AssertNotFamily(); + return new(this, [], [], <#= string.Join(", ", Enumerable.Range(0, n).Select(j => j == i ? $"filter{j}" : "null")) #>); + } <# } #> #endregion @@ -141,6 +159,7 @@ namespace fennecs /// public void For(ComponentAction<<#= types #>> action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -158,6 +177,7 @@ namespace fennecs /// public void For(U uniform, UniformComponentAction> action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -175,6 +195,7 @@ namespace fennecs /// public void For(EntityComponentAction<<#= types #>> action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -192,6 +213,7 @@ namespace fennecs /// public void For(U uniform, UniformEntityComponentAction> action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -207,11 +229,82 @@ namespace fennecs } #endregion + #region For (read-only) +<# + var inKey = new string('I', n); + var readSpanArgs = string.Join(", ", Enumerable.Range(0, n).Select(i => $"join.Span({i})")); +#> + /// + /// Read-only For: components arrive as in (readonly) references. Serves all Stream Types, + /// including , whose derived components arrive viewed as their base type. + /// Mutate class components through their members; component slots cannot be reassigned. + /// + public void ForRead(ComponentAction<#= inKey #><<#= types #>> action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopRead(<#= readSpanArgs #>, action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformComponentAction<#= inKey #>> action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformRead(<#= readSpanArgs #>, action, uniform); + } while (join.Iterate()); + } + } + + /// + public void ForRead(EntityComponentAction<#= inKey #><<#= types #>> action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopEntityRead(table, <#= readSpanArgs #>, action); + } while (join.Iterate()); + } + } + + /// + public void ForRead(U uniform, UniformEntityComponentAction<#= inKey #>> action) + { + using var worldLock = World.Lock(); + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + do + { + LoopUniformEntityRead(table, <#= readSpanArgs #>, action, uniform); + } while (join.Iterate()); + } + } + #endregion + #region Job /// [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(ComponentAction<<#= types #>> action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -297,6 +390,7 @@ namespace fennecs [UnsupportedOSPlatform("browser", "browser-wasm runtime is single-threaded")] public void Job(U uniform, UniformComponentAction> action) { + AssertNotFamily(); AssertNoWildcards(StreamTypes); using var worldLock = World.Lock(); var chunkSize = Math.Max(1, Count / Concurrency); @@ -385,6 +479,7 @@ namespace fennecs /// public void Raw(MemoryAction<<#= types #>> action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -404,6 +499,7 @@ namespace fennecs /// public void Raw(U uniform, MemoryUniformAction> action) { + AssertNotFamily(); using var worldLock = World.Lock(); foreach (var table in Archetypes) { @@ -424,7 +520,9 @@ namespace fennecs #region IEnumerable /// - public IEnumerator<(Entity, <#= types #>)> GetEnumerator() + public IEnumerator<(Entity, <#= types #>)> GetEnumerator() => _family ? EnumerateRead() : Enumerate(); + + private IEnumerator<(Entity, <#= types #>)> Enumerate() { foreach (var table in Archetypes) { @@ -442,6 +540,25 @@ namespace fennecs } while (join.Iterate()); } } + + // Family variant: elements come from covariant (derived-type) storages, one indirection per element. + private IEnumerator<(Entity, <#= types #>)> EnumerateRead() + { + foreach (var table in Archetypes) + { + using var join = table.CrossJoinRead(StreamTypes.AsSpan()); + if (join.Empty) continue; + var snapshot = table.Version; + do + { + for (var i=0; i$"join.Get({i}, i)")) #>); + if (table.Version != snapshot) throw new InvalidOperationException("Collection was modified during iteration."); + } + } while (join.Iterate()); + } + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); #endregion @@ -453,6 +570,7 @@ namespace fennecs /// public void Blit(C<#= i #> value, Match match = default) { + AssertNotFamily(); var typeExpression = TypeExpression.Of>(match); foreach (var table in Archetypes) table.Fill(typeExpression, value); @@ -496,6 +614,42 @@ namespace fennecs action(uniform, new EntityRef(table, i), <#= FormatList(n,"ref span{0}[i]") #>); } } + + private static void LoopRead(<#= FormatList(n, "ReadOnlySpan span{0}", ",") #>, ComponentAction<#= inKey #><<#= types #>> action) + { + var length = span0.Length; + for (var i=0;i); + } + } + + private static void LoopUniformRead(<#= FormatList(n, "ReadOnlySpan span{0}", ",") #>, UniformComponentAction<#= inKey #>> action, U uniform) + { + var length = span0.Length; + for (var i=0;i); + } + } + + private static void LoopEntityRead(Archetype table, <#= FormatList(n, "ReadOnlySpan span{0}", ",") #>, EntityComponentAction<#= inKey #><<#= types #>> action) + { + var length = span0.Length; + for (var i=0;i); + } + } + + private static void LoopUniformEntityRead(Archetype table, <#= FormatList(n, "ReadOnlySpan span{0}", ",") #>, UniformEntityComponentAction<#= inKey #>> action, U uniform) + { + var length = span0.Length; + for (var i=0;i); + } + } #endregion #region Assertions @@ -505,6 +659,14 @@ namespace fennecs throw new InvalidOperationException( $"Cannot run this operation on wildcard Stream Types (write destination Aliasing). {streamTypes}"); } + + private void AssertNotFamily() + { + if (!_family) return; + throw new InvalidOperationException( + "Match.Family Stream Types provide read-only component access: writable-ref runners, Raw, Blit, " + + "and FilteredStream views cannot serve them. Use the ForRead overloads (in-parameters) or enumeration."); + } #endregion #region Query Forwarding diff --git a/src/nuget/nuget.md b/src/nuget/nuget.md index 6dbda165..4502ef5f 100644 --- a/src/nuget/nuget.md +++ b/src/nuget/nuget.md @@ -1,4 +1,4 @@ -![fennecs logo](https://raw.githubusercontent.com/outfox/fennecs/main/nuget/fennecs-logo-nuget.svg) +![fennecs logo](https://raw.githubusercontent.com/outfox/fennecs/main/src/nuget/fennecs-logo-nuget.svg) [**fenn**ecs](https://fennecs.net) is a lightweight, performant, and expressive ECS library for game & simulations written in modern C#.