diff --git a/ROADMAP.md b/ROADMAP.md index 32d0b0e..28b8c90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Archevolt performance plan -Status: steps 0, 1, 2, 3, 4, and 5 done. Step 0's baselines are in `benchmarks/baseline.md`, recorded on .NET 10. Step 3's before and after numbers, on .NET 11, are in `benchmarks/step3-table-storage.md`. Later steps record fresh before and after comparisons on the same SDK. Step 3 met its gate except for named queries, whose regression came from enumerator codegen. Step 2 rewrote that enumerator and met its gate. Its numbers are in `benchmarks/step2-named-query-enumerator.md`. Step 4 replaced directory references with integers and met the median/range gate after an unchanged-code drift control. Its measurements and the remaining variance in large random reads are in `benchmarks/step4-integer-entity-directory.md`. Step 5 made reservation lock-free, added batch spawn, and met its gate. Its numbers are in `benchmarks/step5-lock-free-reservation.md`. +Status: steps 0, 1, 2, 3, 4, 5, and 6 done. Step 0's baselines are in `benchmarks/baseline.md`, recorded on .NET 10. Step 3's before and after numbers, on .NET 11, are in `benchmarks/step3-table-storage.md`. Later steps record fresh before and after comparisons on the same SDK. Step 3 met its gate except for named queries, whose regression came from enumerator codegen. Step 2 rewrote that enumerator and met its gate. Its numbers are in `benchmarks/step2-named-query-enumerator.md`. Step 4 replaced directory references with integers and met the median/range gate after an unchanged-code drift control. Its measurements and the remaining variance in large random reads are in `benchmarks/step4-integer-entity-directory.md`. Step 5 made reservation lock-free, added batch spawn, and met its gate. Its numbers are in `benchmarks/step5-lock-free-reservation.md`. Step 6 cached column maps, interned archetypes by bitmask, and met its gate. Its numbers are in `benchmarks/step6-cached-column-maps.md`. The v1 implementation plan is complete. Every stage passed its acceptance gate, and the evidence is in `.audit/acceptance.md`. The [v1 plan](https://github.com/Stephanvs/archevolt/blob/ebacf07059fefbf9b182e40a290812be584dd9ee/PLAN.md) remains in history as the record of the original design, its alternatives, and its gates. @@ -228,3 +228,12 @@ Follow-up, not yet a numbered step: [step3-partition-granularity.md](benchmarks/ - The owner flushes pending claims before command playback, before commands are discarded, and when a run exits. Idle-world reservation and release assume that no claim is pending. - `SpawnBatch` still stages each bundle and binary-searches each member's column, once per entity. Step 6 removes the search. Step 8 originally assumed that batch spawn already wrote in place, so its scope and gate now name `SpawnBatch` too. - At 100,000 entities, batch spawn takes 3.7 ms against Bevy's recorded 3.2 ms, and individual spawn 5.9 ms against 4.8 ms. Despawn is about twice as fast as Bevy's recorded figure at both sizes. + +## Amendments after step 6 + +- Step 6 asks for a dense component-to-column lookup for `Get` and bundle writes. `Archetype.Columns` serves every caller: `Get`, `TryGet`, `WriteBundle`, `ApplyInsert`, and query matching. The amendment after step 4 suggested that `BundleBinding` hold its destination columns. A dense lookup already costs one array read, so the binding does not hold them. +- Transition edges stay in the per-archetype dictionaries, now as `ArchetypeEdge` values that carry the target and its column map. Bundle insertions cache the same edge type per source archetype. A tag or wide round trip still pays two dictionary lookups and two row moves. +- Archetypes derive their sorted component IDs from their masks. A transition flips one bit of a cloned mask instead of building an ID list with LINQ. +- The world creates the empty archetype when it is built, so it always exists, at index 0. +- `TryGet` binds the directory entry once through `EntityTable.TryLocate`. `IsAlive` now calls `TryLocate` too. +- [The step 6 measurements](benchmarks/step6-cached-column-maps.md) interleave before and after process runs instead of running each side in turn. `Get` fell to 0.72 of its time at 1,000 entities and 0.77 at 100,000. Both round trips and both spawn cases improved at both sizes. At 1,000 entities, `Get` now costs about 5.7 ns per call against Bevy's recorded 4.2 ns. diff --git a/benchmarks/step6-cached-column-maps.md b/benchmarks/step6-cached-column-maps.md new file mode 100644 index 0000000..7bab5a1 --- /dev/null +++ b/benchmarks/step6-cached-column-maps.md @@ -0,0 +1,82 @@ +# Step 6: cached column maps and bitmask archetype keys + +This file records [ROADMAP.md](../ROADMAP.md) step 6. Structural moves, bundle writes, inserts, and `Get` no longer binary-search an archetype's component IDs. Archetypes are interned by their component bitmask instead of a joined string. + +## Change + +- **Dense column lookup.** Each `Archetype` holds `Columns`, one entry per registered component, with the component's table column or -1. `Get`, `TryGet`, `WriteBundle`, `ApplyInsert`, and query matching read it instead of calling `Array.BinarySearch`. +- **Column maps on transition edges.** An add or remove transition caches an `ArchetypeEdge`: the target archetype and, for each source column, the target column or -1. A bundle insertion caches the same edge per source archetype in `BundleBinding.Destinations`. `Table.CopyRowTo` copies through that map with no lookup. Swap-removal already copied index for index within one table, so it is unchanged. +- **Bitmask interning.** The world keys archetypes by their `ulong[]` component mask. A transition clones the source mask and sets or clears one bit. A bundle destination ORs two masks. An archetype derives its sorted IDs from its mask. The LINQ ID lists that transitions built on a cache miss are gone. +- **Empty archetype.** The world creates the empty archetype when it is built, so `Spawn()` no longer builds a key string on every call. +- **One directory read in `TryGet`.** `EntityTable.TryLocate` checks the handle and returns the archetype ID and row from one entry binding. `IsAlive` calls it and discards the location. + +## Conditions + +| Setting | Value | +| --- | --- | +| CPU | AMD Ryzen 9 5950X 16-Core Processor | +| OS | Windows 11 Pro 10.0.26300 | +| .NET | SDK 11.0.100-rc.3.26522.148, RyuJIT x86-64-v3, Release | +| BenchmarkDotNet | 0.16.0-preview.2, default job, one launch per process run | +| Power plan | BenchmarkDotNet selects High performance and restores the previous plan | +| Before | `8a9dd22`, the tip of step 5 | +| After | The same commit with this change applied | + +Both versions ran in the same dedicated worktree. Each process run used this command: + +```powershell +./scripts/baseline.ps1 -Runs 1 -Classes EntityStorageBenchmarks,EntityPopulationBenchmarks +``` + +The six process runs alternated in the order before, after, after, before, before, after, between 21:00 and 21:19 UTC on 29 October 2026. Earlier steps ran three launches of one side and then three of the other. Interleaving spreads machine drift across both sides. Each timing is the median of three per-process means in microseconds, with their range. `scripts/compare-baselines.ps1` classifies an after median outside the before range as faster or slower. Builds and tests did not run alongside the measurements. Bevy was not rerun. + +## Measurements + +| Case | Before µs [min, max] | After µs [min, max] | After/before | Verdict | +| --- | ---: | ---: | ---: | --- | +| `Get(Count=1000)` | 7.847 [7.695, 8.090] | 5.663 [5.629, 5.751] | 0.72 | faster | +| `Get(Count=100000)` | 1,702 [1,698, 1,801] | 1,316 [1,315, 1,324] | 0.77 | faster | +| `TagRoundTrip(Count=1000)` | 90.92 [90.11, 104.0] | 86.64 [85.62, 88.72] | 0.95 | faster | +| `TagRoundTrip(Count=100000)` | 9,095 [8,965, 9,364] | 8,700 [8,513, 8,707] | 0.96 | faster | +| `WideRoundTrip(Count=1000)` | 96.73 [96.32, 98.02] | 92.30 [92.14, 93.46] | 0.95 | faster | +| `WideRoundTrip(Count=100000)` | 10,210 [9,973, 10,250] | 9,182 [9,086, 9,446] | 0.90 | faster | +| `Spawn(Count=1000)` | 47.30 [46.20, 47.80] | 42.33 [41.23, 42.68] | 0.89 | faster | +| `Spawn(Count=100000)` | 5,856 [5,598, 6,046] | 5,140 [5,064, 5,179] | 0.88 | faster | +| `SpawnBatch(Count=1000)` | 29.47 [29.27, 31.01] | 26.39 [25.89, 27.26] | 0.90 | faster | +| `SpawnBatch(Count=100000)` | 3,647 [3,594, 3,713] | 3,227 [3,208, 3,240] | 0.88 | faster | +| `Despawn(Count=1000)` | 24.15 [23.66, 25.74] | 23.83 [23.21, 24.14] | 0.99 | within | +| `Despawn(Count=100000)` | 4,679 [3,573, 4,843] | 4,480 [3,563, 4,851] | 0.96 | within | + +`Get` gains the most, because it loses both a binary search and a second directory read. The round trips gain less, because each one still pays two row moves, two swap-removals, and two transition-dictionary lookups. Spawn and batch spawn gain from `WriteBundle`'s direct column lookup. Despawn does not touch any changed path. + +Managed allocation per operation did not change in any case. `Get`, `TagRoundTrip`, and `WideRoundTrip` allocate nothing. `Spawn` allocates 16.05 KB and 1,610.49 KB, `SpawnBatch` 16.05 KB and 1,571.09 KB, and `Despawn` 8.02 KB and 1,024.18 KB, at 1,000 and 100,000 entities. + +### Bevy reference, not rerun + +`benchmarks/baseline.md` records Bevy 0.20.0 on 22 October 2026 on the same machine. Those medians are historical context only. + +| Case | Archevolt after µs | Bevy reference µs | +| --- | ---: | ---: | +| `Get`, 1,000 | 5.663 | 4.171 | +| `Get`, 100,000 | 1,316 | 832.1 | +| Batch spawn, 100,000 | 3,227 | 3,222 | + +## Correctness + +The structural reference-model trace test, `Structural_trace_matches_dictionary_across_table_growth_migration_and_swap_removal`, passes unchanged. So does `Membership_extends_beyond_one_machine_word`, which interns archetypes whose masks span two words. `Warmed_structural_moves_allocate_no_managed_memory` still passes. + +One test is new. `Bundle_insertion_over_members_already_present_overwrites_them_without_moving_other_rows` inserts a bundle whose members the entity already has. That takes the edge whose target is its own source, writes in place, and must leave a second row in the same table untouched. + +`scripts/verify.ps1` passed every check. + +## Gate + +| Gate item | Result | +| --- | --- | +| Step 0a's tag and wide-component round trips improve | Met at both sizes. | +| `Get` improves | Met at both sizes. | +| The reference-model trace tests pass | Met. | + +## Saved evidence + +The interleaving script, its run list, the merged before and after summaries, and the comparison are preserved locally under `artifacts/step6-cached-column-maps/`. The six raw runs are in the measurement worktree under `artifacts/baseline/20261029-210050` through `20261029-211644`. diff --git a/docs/concepts/architecture.mdx b/docs/concepts/architecture.mdx index 2f44056..82e8f64 100644 --- a/docs/concepts/architecture.mdx +++ b/docs/concepts/architecture.mdx @@ -24,7 +24,7 @@ The runtime owns entity allocation, native storage, query caches, parameter bind An entity contains its world identity, slot, and generation. A paged entity table records free, reserved, alive, and retired states. -A frozen registry assigns world-local component IDs. Each archetype holds canonical sorted component IDs and one native structure-of-arrays table. A table is a single 64-byte-aligned allocation that doubles when full and keeps its capacity when rows leave. `World.TrimExcess()` releases that retained capacity. Moving an entity between archetypes copies retained values and ticks, initializes additions, and repairs the moved entity after swap removal. +A frozen registry assigns world-local component IDs. Each archetype holds canonical sorted component IDs, a component-to-column lookup, and one native structure-of-arrays table. The world interns archetypes by their component bitmask. Each cached add, remove, or bundle transition stores the column mapping for its move, so a structural change copies columns without searching for them. A table is a single 64-byte-aligned allocation that doubles when full and keeps its capacity when rows leave. `World.TrimExcess()` releases that retained capacity. Moving an entity between archetypes copies retained values and ticks, initializes additions, and repairs the moved entity after swap removal. ## Safe access diff --git a/src/Archevolt/Commands/Commands.cs b/src/Archevolt/Commands/Commands.cs index d4eadb5..a397870 100644 --- a/src/Archevolt/Commands/Commands.cs +++ b/src/Archevolt/Commands/Commands.cs @@ -229,14 +229,17 @@ private void ApplyBundle(Entity entity, BundleBinding binding, ReadOnlySpan payload, Archetype target, int row, Archetype? source, ulong tick) @@ -244,7 +247,7 @@ private static void WriteBundle(BundleBinding binding, ReadOnlySpan payloa int offset = 0; for (int i = 0; i < binding.Ids.Length; i++) { - int column = Array.BinarySearch(target.Ids, binding.Ids[i]); + int column = target.Columns[binding.Ids[i]]; var component = binding.Descriptor.Components[i]; if (!component.IsTag) payload.Slice(offset, component.Size).CopyTo(target.Table.ValueBytes(column, row)); @@ -263,11 +266,12 @@ private void ApplyInsert(Entity entity, int id, ReadOnlySpan payload, ulon bool added = !archetype.Has(id); if (added) { - archetype = Transition(archetype, id, add: true); - Move(entity, archetype); + var edge = Transition(archetype, id, add: true); + Move(entity, edge); + archetype = edge.Target; } var table = archetype.Table; - int column = Array.BinarySearch(archetype.Ids, id); + int column = archetype.Columns[id]; if (!Registry.Components[id].IsTag) payload.CopyTo(table.ValueBytes(column, entry.Row)); Stamp(table, column, entry.Row, tick, added); diff --git a/src/Archevolt/Entities/Entity.cs b/src/Archevolt/Entities/Entity.cs index cd4611b..839f81e 100644 --- a/src/Archevolt/Entities/Entity.cs +++ b/src/Archevolt/Entities/Entity.cs @@ -120,11 +120,17 @@ internal void Flush() Entry(Append()).State = EntityState.Reserved; } - internal bool IsAlive(Entity entity, long world) + internal bool IsAlive(Entity entity, long world) => TryLocate(entity, world, out _, out _); + + internal bool TryLocate(Entity entity, long world, out int archetypeId, out int row) { + archetypeId = 0; + row = 0; if (entity.WorldId != world || (uint)entity.Index >= (uint)Volatile.Read(ref _count)) return false; ref var entry = ref Entry(entity.Index); + archetypeId = entry.ArchetypeId; + row = entry.Row; return entry.Generation == entity.Generation && entry.State == EntityState.Alive; } diff --git a/src/Archevolt/Queries/QueryState.cs b/src/Archevolt/Queries/QueryState.cs index 005351a..23c494d 100644 --- a/src/Archevolt/Queries/QueryState.cs +++ b/src/Archevolt/Queries/QueryState.cs @@ -41,7 +41,7 @@ internal void Refresh() foreach (int id in _excluded) if (archetype.Has(id)) { matches = false; break; } if (matches) - Matches.Add(new QueryMatch(archetype, ComponentIds.Select(id => Array.BinarySearch(archetype.Ids, id)).ToArray())); + Matches.Add(new QueryMatch(archetype, ComponentIds.Select(id => archetype.Columns[id]).ToArray())); } } diff --git a/src/Archevolt/Storage/Tables.cs b/src/Archevolt/Storage/Tables.cs index eb3a3bf..70cecf7 100644 --- a/src/Archevolt/Storage/Tables.cs +++ b/src/Archevolt/Storage/Tables.cs @@ -1,10 +1,10 @@ +using System.Numerics; using System.Runtime.InteropServices; namespace Archevolt; internal sealed unsafe class Table : IDisposable { - private readonly int[] _ids; private readonly nint[] _values; private readonly nint[] _added; private readonly nint[] _changed; @@ -15,9 +15,8 @@ internal sealed unsafe class Table : IDisposable internal int Capacity { get; private set; } internal long Bytes { get; private set; } - internal Table(int[] ids, ComponentDescriptor[] components) + internal Table(ComponentDescriptor[] components) { - _ids = ids; Components = components; _values = new nint[components.Length]; _added = new nint[components.Length]; @@ -69,11 +68,11 @@ internal void SwapRemove(int row, EntityTable entities) Count--; } - internal void CopyRowTo(int source, Table target, int destination) + internal void CopyRowTo(int source, Table target, int destination, int[] targetColumns) { - for (int i = 0; i < _ids.Length; i++) + for (int i = 0; i < targetColumns.Length; i++) { - int targetColumn = Array.BinarySearch(target._ids, _ids[i]); + int targetColumn = targetColumns[i]; if (targetColumn < 0) continue; if (!Components[i].IsTag) @@ -153,22 +152,52 @@ internal sealed class Archetype internal readonly ulong[] Mask; internal readonly ComponentDescriptor[] Components; internal readonly Table Table; - internal readonly Dictionary AddTransitions = []; - internal readonly Dictionary RemoveTransitions = []; + internal readonly int[] Columns; + internal readonly Dictionary AddTransitions = []; + internal readonly Dictionary RemoveTransitions = []; - internal Archetype(int index, int[] ids, ComponentRegistry registry) + internal Archetype(int index, ulong[] mask, ComponentRegistry registry) { Index = index; - Ids = ids; - Mask = new ulong[(registry.Components.Length + 63) / 64]; - Components = new ComponentDescriptor[ids.Length]; - for (int i = 0; i < ids.Length; i++) + Mask = mask; + var ids = new List(); + for (int word = 0; word < mask.Length; word++) + for (ulong bits = mask[word]; bits != 0; bits &= bits - 1) + ids.Add(word * 64 + BitOperations.TrailingZeroCount(bits)); + Ids = ids.ToArray(); + Components = new ComponentDescriptor[Ids.Length]; + Columns = new int[registry.Components.Length]; + Array.Fill(Columns, -1); + for (int i = 0; i < Ids.Length; i++) { - Mask[ids[i] / 64] |= 1UL << (ids[i] % 64); - Components[i] = registry.Components[ids[i]]; + Components[i] = registry.Components[Ids[i]]; + Columns[Ids[i]] = i; } - Table = new Table(ids, Components); + Table = new Table(Components); } internal bool Has(int component) => (Mask[component / 64] & (1UL << (component % 64))) != 0; + + internal ArchetypeEdge EdgeTo(Archetype target) + { + var columns = new int[Ids.Length]; + for (int i = 0; i < Ids.Length; i++) + columns[i] = target.Columns[Ids[i]]; + return new ArchetypeEdge(target, columns); + } +} + +// TargetColumns[i] is the target table's column for this archetype's column i, or -1 when the target lacks it. +internal sealed record ArchetypeEdge(Archetype Target, int[] TargetColumns); + +internal sealed class ArchetypeMaskComparer : IEqualityComparer +{ + internal static readonly ArchetypeMaskComparer Instance = new(); + public bool Equals(ulong[]? x, ulong[]? y) => x.AsSpan().SequenceEqual(y); + public int GetHashCode(ulong[] mask) + { + var hash = new HashCode(); + hash.AddBytes(MemoryMarshal.AsBytes(mask.AsSpan())); + return hash.ToHashCode(); + } } diff --git a/src/Archevolt/World.cs b/src/Archevolt/World.cs index 59d3e1c..88839c9 100644 --- a/src/Archevolt/World.cs +++ b/src/Archevolt/World.cs @@ -8,8 +8,9 @@ public sealed partial class World : IDisposable private static readonly object s_identityGate = new(); private static long s_nextWorld; private readonly int _ownerThread = Environment.CurrentManagedThreadId; - private readonly Dictionary _archetypeByKey = new(StringComparer.Ordinal); + private readonly Dictionary _archetypeByMask = new(ArchetypeMaskComparer.Instance); private readonly Dictionary _bundles = []; + private readonly Archetype _empty; private ulong _tick; private bool _disposed; private bool _running; @@ -40,14 +41,16 @@ internal World(WorldBuilder builder) CommandCapacity = builder.CommandBytes; Registry = new ComponentRegistry(builder.Components); Entities = new EntityTable(builder.EntityCapacity); + _empty = GetArchetype(NewMask()); foreach (var descriptor in builder.Bundles) { if (_bundles.ContainsKey(descriptor.TypeKey)) continue; int[] ids = descriptor.Components.Select(c => Registry.Id(c.TypeKey)).ToArray(); - int[] sorted = (int[])ids.Clone(); - Array.Sort(sorted); - _bundles.Add(descriptor.TypeKey, new BundleBinding(descriptor, GetArchetype(sorted), ids)); + var mask = NewMask(); + foreach (int id in ids) + mask[id / 64] |= 1UL << (id % 64); + _bundles.Add(descriptor.TypeKey, new BundleBinding(descriptor, GetArchetype(mask), ids)); } InitializeResources(builder.Resources); InitializeMessages(builder.Messages, builder.MessageCapacity); @@ -60,9 +63,8 @@ public Entity Spawn() Entity entity = Entities.Reserve(Id); try { - var archetype = GetArchetype([]); - int row = archetype.Table.Append(new EntityKey(entity.Index, entity.Generation)); - Entities.Materialize(entity, archetype.Index, row); + int row = _empty.Table.Append(new EntityKey(entity.Index, entity.Generation)); + Entities.Materialize(entity, _empty.Index, row); return entity; } catch { Entities.Release(entity.Index); throw; } @@ -187,17 +189,16 @@ public bool TryGet(Entity entity, out T value) where T : unmanaged { RequireIdle(); value = default; - if (!Entities.IsAlive(entity, Id)) + if (!Entities.TryLocate(entity, Id, out int archetypeId, out int row)) return false; int id = Registry.Id(); if (Registry.Components[id].IsTag) throw new InvalidOperationException("Tags have no stored value. Use Has."); - ref var entry = ref Entities.Entry(entity.Index); - var archetype = Archetypes[entry.ArchetypeId]; - int column = Array.BinarySearch(archetype.Ids, id); + var archetype = Archetypes[archetypeId]; + int column = archetype.Columns[id]; if (column < 0) return false; - value = archetype.Table.Values(column)[entry.Row]; + value = archetype.Table.Values(column)[row]; return true; } @@ -283,40 +284,44 @@ internal WorkerPool Workers } } - private Archetype GetArchetype(int[] ids) + private ulong[] NewMask() => new ulong[(Registry.Components.Length + 63) / 64]; + + // The dictionary keeps the mask as its key, so callers pass a mask they no longer write to. + private Archetype GetArchetype(ulong[] mask) { - string key = string.Join(",", ids); - if (_archetypeByKey.TryGetValue(key, out var result)) + if (_archetypeByMask.TryGetValue(mask, out var result)) return result; - result = new Archetype(Archetypes.Count, ids, Registry); - _archetypeByKey.Add(key, result); + result = new Archetype(Archetypes.Count, mask, Registry); + _archetypeByMask.Add(mask, result); Archetypes.Add(result); return result; } - private Archetype Transition(Archetype source, int id, bool add) + private ArchetypeEdge Transition(Archetype source, int id, bool add) { var cache = add ? source.AddTransitions : source.RemoveTransitions; - if (cache.TryGetValue(id, out var result)) - return result; - result = GetArchetype(TransitionIds(source, id, add)); - cache.Add(id, result); - return result; + if (cache.TryGetValue(id, out var edge)) + return edge; + var mask = (ulong[])source.Mask.Clone(); + if (add) + mask[id / 64] |= 1UL << (id % 64); + else + mask[id / 64] &= ~(1UL << (id % 64)); + edge = source.EdgeTo(GetArchetype(mask)); + cache.Add(id, edge); + return edge; } - // Kept out of Transition: a lambda capturing id would allocate its closure on every call, including cache hits. - private static int[] TransitionIds(Archetype source, int id, bool add) => - add ? source.Ids.Append(id).Order().ToArray() : source.Ids.Where(i => i != id).ToArray(); - - private void Move(Entity entity, Archetype destination) + private void Move(Entity entity, ArchetypeEdge edge) { ref var entry = ref Entities.Entry(entity.Index); var source = Archetypes[entry.ArchetypeId].Table; + var target = edge.Target; int sourceRow = entry.Row; - int row = destination.Table.Append(new EntityKey(entity.Index, entity.Generation)); - source.CopyRowTo(sourceRow, destination.Table, row); + int row = target.Table.Append(new EntityKey(entity.Index, entity.Generation)); + source.CopyRowTo(sourceRow, target.Table, row, edge.TargetColumns); source.SwapRemove(sourceRow, Entities); - Entities.Relocate(entity.Index, destination.Index, row); + Entities.Relocate(entity.Index, target.Index, row); } private static void Stamp(Table table, int column, int row, ulong tick, bool added) @@ -345,7 +350,7 @@ public void Dispose() internal sealed record BundleBinding(BundleDescriptor Descriptor, Archetype Archetype, int[] Ids) { - internal readonly Dictionary Destinations = []; + internal readonly Dictionary Destinations = []; } } diff --git a/tests/Archevolt.Tests/StorageTests.cs b/tests/Archevolt.Tests/StorageTests.cs index 1ec2948..68cd064 100644 --- a/tests/Archevolt.Tests/StorageTests.cs +++ b/tests/Archevolt.Tests/StorageTests.cs @@ -359,6 +359,21 @@ public void Batch_spawn_that_fails_partway_leaves_no_row_or_reservation() Assert.Throws(() => world.SpawnBatch([new(new(1), new(1))], new Entity[2])); } + [Fact] + public void Bundle_insertion_over_members_already_present_overwrites_them_without_moving_other_rows() + { + using var world = new WorldBuilder().AddBundle().AddComponent().Build(); + Entity first = world.Spawn(new Particle(new Position(1), new Velocity(2))); + Entity second = world.Spawn(new Particle(new Position(3), new Velocity(4))); + world.Insert(first, new Extra(9)); + world.Insert(second, new Extra(10)); + + world.InsertBundle(first, new Particle(new Position(5), new Velocity(6))); + + Assert.Equal((5, 6, 9L), (world.Get(first).Value, world.Get(first).Value, world.Get(first).Value)); + Assert.Equal((3, 4, 10L), (world.Get(second).Value, world.Get(second).Value, world.Get(second).Value)); + } + [Fact] public void Membership_extends_beyond_one_machine_word() {