From a9f8775110756f596f09b05215a393b45340059e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 19:34:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20D8=20=E2=80=94=20RemoveCellsFor?= =?UTF-8?q?Landblock;=20cell=20transforms=20rebase=20per=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors RemoveBuildingsForLandblock (#146) for indoor CellPhysics entries. _cellStruct is first-wins (CacheCellStruct's ContainsKey guard); without eviction a dungeon's BSP WorldTransform is permanently locked to the _liveCenter value at first streaming — a teleport recenter leaves cells at a stale offset (~source↔dest distance), and foot-sphere collision queries miss the geometry that visually renders correctly. PhysicsDataCache.RemoveCellsForLandblock iterates _cellStruct.Keys and TryRemove-s every entry whose high-word matches the evicted landblock prefix. PhysicsEngine.RemoveLandblock now calls it alongside ShadowObjects.RemoveLandblock so cell BSPs rebase on the next CacheCellStruct pass, same as buildings. No divergence register row needed: this closes a gap introduced when the #146 building-eviction pattern was created without the symmetric cell eviction. Tests: RemoveCellsForLandblockTests (3 cases): evicts-matching-prefix, empty-cache no-throw, no-matching-cells leaves-others. Core suite: 1587 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/PhysicsDataCache.cs | 18 +++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 1 + .../Physics/RemoveCellsForLandblockTests.cs | 66 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index d0876495..f90808fa 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -459,6 +459,24 @@ public sealed class PhysicsDataCache _buildings.TryRemove(key, out _); } + /// + /// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so + /// the next re-bases its STREAMING-RELATIVE + /// WorldTransform against the current _liveCenter — symmetric with + /// (#146). Without this, the + /// _cellStruct first-wins guard LOCKS a dungeon cell's transform at its + /// first streaming frame; a teleport recenter then leaves the cell BSP at a stale + /// world offset (~the source↔dest landblock distance), so foot-sphere collision + /// queries walk through where the wall geometry is. + /// + public void RemoveCellsForLandblock(uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + foreach (var key in _cellStruct.Keys) + if ((key & 0xFFFF0000u) == prefix) + _cellStruct.TryRemove(key, out _); + } + public BuildingPhysics? GetBuilding(uint landcellId) => _buildings.TryGetValue(landcellId, out var b) ? b : null; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index e240fec0..1090559e 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -122,6 +122,7 @@ public sealed class PhysicsEngine { _landblocks.Remove(landblockId); ShadowObjects.RemoveLandblock(landblockId); + DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply // #145: if the player's current cell belonged to the landblock being removed (a teleport // drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell diff --git a/tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs b/tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs new file mode 100644 index 00000000..bd12f7c8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// D8 (2026-06-24): verify that +/// evicts every cached cell belonging to the given landblock prefix and leaves +/// cells belonging to other landblocks untouched — symmetric with +/// (#146). +/// +public class RemoveCellsForLandblockTests +{ + /// + /// Build the absolute minimum that satisfies the + /// required init property. BSP is left null because the test exercises + /// only the cache-eviction key logic, not BSP traversal. + /// + private static CellPhysics BuildMinimalCell() => new() + { + Resolved = new Dictionary(), + }; + + [Fact] + public void RemoveCellsForLandblock_EvictsOnlyMatchingPrefix() + { + var cache = new PhysicsDataCache(); + + // Two cells from landblock 0xAAAA0000 and one from 0xBBBB0000. + cache.RegisterCellStructForTest(0xAAAA0100u, BuildMinimalCell()); + cache.RegisterCellStructForTest(0xAAAA0200u, BuildMinimalCell()); + cache.RegisterCellStructForTest(0xBBBB0100u, BuildMinimalCell()); + + cache.RemoveCellsForLandblock(0xAAAA0000u); + + // Both AAAA cells must be gone. + Assert.Null(cache.GetCellStruct(0xAAAA0100u)); + Assert.Null(cache.GetCellStruct(0xAAAA0200u)); + + // The BBBB cell must survive. + Assert.NotNull(cache.GetCellStruct(0xBBBB0100u)); + } + + [Fact] + public void RemoveCellsForLandblock_EmptyCache_DoesNotThrow() + { + // Must be a no-op on an empty cache. + var cache = new PhysicsDataCache(); + cache.RemoveCellsForLandblock(0xAAAA0000u); + Assert.Equal(0, cache.CellStructCount); + } + + [Fact] + public void RemoveCellsForLandblock_NoMatchingCells_LeavesOthersIntact() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xBBBB0100u, BuildMinimalCell()); + + // Evicting a prefix that has no cached entries should not disturb others. + cache.RemoveCellsForLandblock(0xAAAA0000u); + + Assert.NotNull(cache.GetCellStruct(0xBBBB0100u)); + Assert.Equal(1, cache.CellStructCount); + } +}