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
// -----------------------------------------------------------------------