From 4f1c067a214d50d48fc83665a23e62cab9a68c39 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 06:12:34 +0200 Subject: [PATCH] perf(physics): pool collision cell snapshots Preserve the fixed per-cell collision walk across nested registry mutations while replacing the repeated List allocation with an explicitly owned pooled snapshot. Capture cardinality once, return storage on every exit, and pin live-list mutation behavior with a focused regression test. Co-authored-by: Codex --- .../Physics/ShadowEntrySnapshot.cs | 56 +++++++++++++++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 5 +- .../Physics/ShadowObjectRegistryTests.cs | 19 +++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/AcDream.Core/Physics/ShadowEntrySnapshot.cs diff --git a/src/AcDream.Core/Physics/ShadowEntrySnapshot.cs b/src/AcDream.Core/Physics/ShadowEntrySnapshot.cs new file mode 100644 index 00000000..18c95fea --- /dev/null +++ b/src/AcDream.Core/Physics/ShadowEntrySnapshot.cs @@ -0,0 +1,56 @@ +using System.Buffers; + +namespace AcDream.Core.Physics; + +/// +/// Pooled, mutation-stable snapshot of a cell's live shadow entries. +/// Nested collision resolution can update the registry while the outer +/// transition is still walking the cell, so the captured count and entries +/// must remain fixed for the lifetime of that walk. +/// +internal ref struct ShadowEntrySnapshot +{ + private ShadowEntry[]? _buffer; + private readonly int _count; + + private ShadowEntrySnapshot(ShadowEntry[] buffer, int count) + { + _buffer = buffer; + _count = count; + } + + public readonly ReadOnlySpan Entries + => _buffer.AsSpan(0, _count); + + public static ShadowEntrySnapshot Capture(IReadOnlyList source) + { + int count = source.Count; + ShadowEntry[] buffer = ArrayPool.Shared.Rent(count); + + try + { + for (int i = 0; i < count; i++) + buffer[i] = source[i]; + + return new ShadowEntrySnapshot(buffer, count); + } + catch + { + ArrayPool.Shared.Return(buffer, clearArray: false); + throw; + } + } + + public void Dispose() + { + ShadowEntry[]? buffer = _buffer; + _buffer = null; + + if (buffer is not null) + { + // ShadowEntry is a value-only record struct, so the pooled buffer + // cannot retain references to live entities or renderer state. + ArrayPool.Shared.Return(buffer, clearArray: false); + } + } +} diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d39421ed..c161d681 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2752,9 +2752,8 @@ public sealed class Transition // Snapshot the LIVE per-cell list: a nested step-up // (DoStepUp → TransitionalInsert → this) must not observe // registry mutations through the same reference mid-iteration. - var nearbyObjs = new List(objsInCell); - - foreach (var obj in nearbyObjs) + using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); + foreach (ShadowEntry obj in nearbyObjs.Entries) { // Self-skip — fix #42 (2026-05-05). Mirrors retail // CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 9c22ad6e..b42045d2 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -16,6 +16,25 @@ public class ShadowObjectRegistryTests private const float OffX = 0f; private const float OffY = 0f; + [Fact] + public void ShadowEntrySnapshot_LiveListMutation_DoesNotChangeCapturedWalk() + { + var first = new ShadowEntry(1u, 0u, Vector3.Zero, Quaternion.Identity, 1f); + var second = new ShadowEntry(2u, 0u, Vector3.One, Quaternion.Identity, 1f); + var replacement = new ShadowEntry(3u, 0u, Vector3.UnitX, Quaternion.Identity, 1f); + var liveEntries = new List { first, second }; + + using var snapshot = ShadowEntrySnapshot.Capture(liveEntries); + + liveEntries.RemoveAt(0); + liveEntries.Add(replacement); + liveEntries.Add(first); + + Assert.Equal( + new uint[] { first.EntityId, second.EntityId }, + snapshot.Entries.ToArray().Select(entry => entry.EntityId)); + } + // ----------------------------------------------------------------------- // Register / TotalRegistered // -----------------------------------------------------------------------