Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/docs/Advanced/Expressions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ var textureLinks = Comp<Texture>.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:

Expand Down
49 changes: 48 additions & 1 deletion doc/docs/Basic/Queries/Matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -162,6 +164,51 @@ var followersOfBob = world.Query<Position>()
Wildcards aren't just for matching – [`Remove<C>(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<Animal>(Match.Family)
.Compile();

// Works in Not/Any clauses and stream filters, too.
var noAnimals = world.Query<Active>().Stream()
.Not(Comp<Animal>.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<Animal>(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<Fennec>` 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:
Expand Down Expand Up @@ -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 |
| Static, unchanging criteria | Hard-baked Query is fine |
9 changes: 9 additions & 0 deletions doc/misc/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Base>(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.
Expand Down
1 change: 1 addition & 0 deletions doc/skills/fennecs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
19 changes: 19 additions & 0 deletions doc/skills/fennecs/references/queries-and-streams.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,29 @@ Every `Has/Not/Any` accepts an optional `Match` (see SKILL.md table) or a
.Has<Damage>(Match.Entity) // any entity relation
.Has(Link.With(sharedTexture)) // specific object link
.Not<Owes>(Entity.Any) // no entity relation of this type at all
.Has<Animal>(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<T>.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<Derived>` could store a non-derived instance:

```csharp
var animals = world.Query<Animal>(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).
Expand Down
197 changes: 197 additions & 0 deletions src/fennecs.benchmarks/ECS/ArchetypeMatchingBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.Collections.Immutable;

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;

using fennecs;

namespace Benchmark.ECS;

/// <summary>
/// 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 <see cref="Fragments"/> distinct Archetypes.
/// </summary>
[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<TypeExpression>[] _expanded = null!;

private FilteredStream<Position> _filtered;

private MaskBits _compositeBits;
private MaskBits _specificBits;
private MaskBits _familyBits;

private SortedSet<TypeExpression> _referenceHas = null!;
private SortedSet<TypeExpression> _referenceNot = null!;
private SortedSet<TypeExpression> _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<Position>().Stream()
.Has(Comp<Tagged>.Plain)
.Not(Comp<DerivedThing>.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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Setup() leaks the three Mask instances because Mask implements IDisposable and must return pooled resources through Dispose(). Dispose each mask after creating its MaskBits.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/fennecs.benchmarks/ECS/ArchetypeMatchingBenchmarks.cs, line 82:

<comment>`Setup()` leaks the three `Mask` instances because `Mask` implements `IDisposable` and must return pooled resources through `Dispose()`. Dispose each mask after creating its `MaskBits`.</comment>

<file context>
@@ -0,0 +1,197 @@
+        _expanded = _archetypes.Select(archetype => Expand(archetype.Signature)).ToArray();
+
+        // Composite: plain Has + plain Not + Entity-wildcard Has.
+        var composite = new Mask()
+            .Has(TypeExpression.Of<Position>(Match.Plain))
+            .Has(TypeExpression.Of<Grouping>(Match.Entity))
</file context>

.Has(TypeExpression.Of<Position>(Match.Plain))
.Has(TypeExpression.Of<Grouping>(Match.Entity))
.Not(TypeExpression.Of<Tagged>(Match.Plain));
_compositeBits = MaskBits.Of(composite);
_referenceHas = [TypeExpression.Of<Position>(Match.Plain), TypeExpression.Of<Grouping>(Match.Entity)];
_referenceNot = [TypeExpression.Of<Tagged>(Match.Plain)];

// Specific relation: exercises the keyed bloom (mass rejection) + precise confirm (one hit).
var specific = new Mask()
.Has(TypeExpression.Of<Position>(Match.Plain))
.Has(TypeExpression.Of<Grouping>(Match.Relation(targets[0])));
_specificBits = MaskBits.Of(specific);
_referenceSpecificHas =
[TypeExpression.Of<Position>(Match.Plain), TypeExpression.Of<Grouping>(Match.Relation(targets[0]))];

// Inheritance-aware: no reference exists, the retired machinery could not express this.
var family = new Mask().Has(TypeExpression.Of<BaseThing>(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<TypeExpression> Expand(Signature signature)
{
var builder = ImmutableSortedSet.CreateBuilder<TypeExpression>();

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<TypeExpression> expanded,
SortedSet<TypeExpression> has, SortedSet<TypeExpression> 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;
}
1 change: 1 addition & 0 deletions src/fennecs.benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
[
//typeof(DorakuBenchmarks),
//typeof(FilterBenchmarks),
typeof(ArchetypeMatchingBenchmarks),
typeof(AspectFragmentationBenchmarks),
typeof(MicrocodeBenchmarks),
typeof(SimpleEntityBenchmarks),
Expand Down
4 changes: 2 additions & 2 deletions src/fennecs.tests/ArchetypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public void Matches_Requires_At_Least_One_Any_Type()
mask.Any(TypeExpression.Of<string>(Match.Plain));
mask.Any(TypeExpression.Of<double>(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)));
}


Expand Down
23 changes: 23 additions & 0 deletions src/fennecs.tests/ExhaustionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -47,9 +53,26 @@ public void TypeId_Space_Exhaustion_Throws()
}


[Fact]
public void Registering_Derived_Component_Consumes_One_TypeId()
{
Assert.Equal(1, RegistryProbe.RegisterAndCount<RegistrationLeaf>());
}


// Grants test access to the protected registry internals of LanguageType.
private class RegistryProbe : LanguageType
{
internal static int RegisterAndCount<T>()
{
lock (RegistryLock)
{
var before = Counter;
_ = LanguageType<T>.Id;
return Counter - before;
}
}

internal static TypeInitializationException ProvokeExhaustion()
{
lock (RegistryLock)
Expand Down
Loading