From 493c5e5ff66fa44e5198372c7ceee133d652d80d Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 20 May 2026 16:14:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20A4=20=E2=80=94=20Transition.Ch?= =?UTF-8?q?eckOtherCells=20+=20ApplyOtherCellResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of retail's CTransition::check_other_cells at acclient_2013_pseudo_c.txt:272717-272798. Iterates every non-primary cell in a candidate set, runs BSPQuery.FindCollisions per cell with that cell's WorldTransform-derived rotation + origin, halts on first Collided/Adjusted/Slid. ApplyOtherCellResult is the combine-semantics helper extracted for unit testability — it pins the retail switch: - Collided/Adjusted → CollidedWithEnvironment = true (gated on !Contact), halt. - Slid → ContactPlaneValid + ContactPlaneIsWater = false, halt. - OK → continue. Not yet wired into FindEnvCollisions — see next commit. Probe gated on PhysicsDiagnostics.ProbeIndoorBspEnabled (ACDREAM_PROBE_INDOOR_BSP). Six new unit tests: five against the pure combine helper for each halt case + one direct CheckOtherCells call exercising the null-BSP guard. Spec: docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md Plan: docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 115 ++++++++++++++ .../Physics/TransitionCheckOtherCellsTests.cs | 142 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 977cd8a..11dbd7c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1387,6 +1387,121 @@ public sealed class Transition /// Ported from pseudocode section 4 (LandCell.FindEnvCollisions + ValidateWalkable). /// ACE: LandCell.FindEnvCollisions / ObjectInfo.ValidateWalkable. /// + + /// + /// Phase A4 (2026-05-20). Port of retail's + /// CTransition::check_other_cells at + /// acclient_2013_pseudo_c.txt:272717-272798. + /// + /// + /// After the primary cell's BSP collision returns OK, iterate every + /// other cell in the sphere's overlap set and run BSP collision + /// against each. Halt on the first Collided/Adjusted/Slid; OK + /// continues. Mirrors retail's behaviour exactly — no save/restore + /// of state between cells. + /// + /// + internal TransitionState CheckOtherCells( + PhysicsEngine engine, + Vector3 footCenter, + float sphereRadius, + System.Collections.Generic.IReadOnlyCollection cellSet) + { + if (engine.DataCache is null) return TransitionState.OK; + var sp = SpherePath; + + // Deterministic order for greppable probe logs. Skip the primary + // cell — caller has already run its BSP. + var ordered = new System.Collections.Generic.List(cellSet); + ordered.Sort(); + + foreach (uint cellId in ordered) + { + if (cellId == sp.CheckCellId) continue; + + var cell = engine.DataCache.GetCellStruct(cellId); + // R2 guard: stale CellPhysics loaded for render but not physics. + if (cell?.BSP?.Root is null) continue; + + // Transform sphere into THIS cell's local space. Mirrors the + // primary-cell pattern at TransitionTypes.cs (FindEnvCollisions, + // ~line 1413) AND the Bug B world-origin fix that decomposes + // WorldTransform per cell so BSP Path-3 + Path-4 land write + // world-space ContactPlanes. + var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform); + var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform); + + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = localCenter, + Radius = sphereRadius, + }; + DatReaderWriter.Types.Sphere? localSphere1 = null; + if (sp.NumSphere > 1) + { + localSphere1 = new DatReaderWriter.Types.Sphere + { + Origin = Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform), + Radius = sp.GlobalSphere[1].Radius, + }; + } + + System.Numerics.Quaternion cellRotation; + Vector3 cellOrigin; + if (!System.Numerics.Matrix4x4.Decompose(cell.WorldTransform, out _, + out cellRotation, out cellOrigin)) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] WARN cell 0x{cellId:X8} WorldTransform did not decompose — falling back to identity rotation")); + cellRotation = System.Numerics.Quaternion.Identity; + cellOrigin = cell.WorldTransform.Translation; + } + + var result = BSPQuery.FindCollisions( + cell.BSP.Root, cell.Resolved, this, + localSphere, localSphere1, localCurrCenter, + Vector3.UnitZ, 1.0f, cellRotation, engine, + worldOrigin: cellOrigin); + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} result={result}")); + } + + if (ApplyOtherCellResult(result, out var halted)) + return halted; + } + + return TransitionState.OK; + } + + /// + /// Phase A4 (2026-05-20). Combine helper for + /// . Mirrors retail's switch at + /// acclient_2013_pseudo_c.txt:272739-272752: + /// Collided/Adjusted halt with CollidedWithEnvironment; Slid + /// halts AND clears the contact-plane fields; OK continues. + /// + internal bool ApplyOtherCellResult(TransitionState result, out TransitionState finalState) + { + finalState = result; + switch (result) + { + case TransitionState.Collided: + case TransitionState.Adjusted: + if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) + CollisionInfo.CollidedWithEnvironment = true; + return true; + case TransitionState.Slid: + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + return true; + default: + return false; + } + } + private TransitionState FindEnvCollisions(PhysicsEngine engine) { var sp = SpherePath; diff --git a/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs new file mode 100644 index 0000000..fe69e15 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for the result-combine helper used by +/// . The iteration / per-cell +/// BSP-query parts are covered end-to-end by +/// ; this file pins the +/// retail-faithful halt semantics that +/// acclient_2013_pseudo_c.txt:272739-272752 spells out. +/// +public class TransitionCheckOtherCellsTests +{ + private static Transition MakeTransition(bool contactFlag = false) + { + var t = new Transition(); + t.SpherePath.InitPath(Vector3.Zero, Vector3.Zero, cellId: 0xA9B40100u, sphereRadius: 0.48f); + t.ObjectInfo.State = contactFlag ? ObjectInfoState.Contact : ObjectInfoState.None; + // Pre-set CP fields to non-default so the Slid-clears-CP assertion + // can detect the clear. + t.CollisionInfo.ContactPlaneValid = true; + t.CollisionInfo.ContactPlaneIsWater = true; + return t; + } + + [Fact] + public void OK_ContinuesIteration_DoesNotMutate() + { + var t = MakeTransition(); + + bool halt = t.ApplyOtherCellResult(TransitionState.OK, out var finalState); + + Assert.False(halt); + Assert.Equal(TransitionState.OK, finalState); + Assert.True(t.CollisionInfo.ContactPlaneValid); + Assert.True(t.CollisionInfo.ContactPlaneIsWater); + Assert.False(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Collided_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact() + { + var t = MakeTransition(contactFlag: false); + + bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Collided, finalState); + Assert.True(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Collided_DoesNotSetCollidedWithEnvironment_WhenInContact() + { + // Retail oracle gating: the CollidedWithEnvironment flip mirrors + // the existing primary-cell behavior in FindEnvCollisions — + // skipped when ObjectInfo.State has Contact bit set. + var t = MakeTransition(contactFlag: true); + + bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Collided, finalState); + Assert.False(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Adjusted_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact() + { + var t = MakeTransition(contactFlag: false); + + bool halt = t.ApplyOtherCellResult(TransitionState.Adjusted, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Adjusted, finalState); + Assert.True(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Slid_HaltsAndClearsContactPlaneFields() + { + // Retail oracle: acclient_2013_pseudo_c.txt:272746-272750 + // case 4: + // this->collision_info.contact_plane_valid = 0; + // this->collision_info.contact_plane_is_water = 0; + // return result; + var t = MakeTransition(); + Assert.True(t.CollisionInfo.ContactPlaneValid); // pre-condition + Assert.True(t.CollisionInfo.ContactPlaneIsWater); // pre-condition + + bool halt = t.ApplyOtherCellResult(TransitionState.Slid, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Slid, finalState); + Assert.False(t.CollisionInfo.ContactPlaneValid); + Assert.False(t.CollisionInfo.ContactPlaneIsWater); + } + + [Fact] + public void CheckOtherCells_CellWithNullBspRoot_IsSkippedNoCrash() + { + // Iteration safety: a CellPhysics in the candidate set with + // `BSP = null` (loaded for render but not physics) must be skipped, + // not crash. Matches the spec's R2 guard at design §Edge cases E2. + var cell = new CellPhysics + { + BSP = null, // <-- the guard target + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); // PhysicsEngine has nullable DataCache + // FindEnvCollisions has terrain probes downstream; populate a + // minimal landblock so the cache + engine are coherent. The cell + // we test against doesn't need a real landblock entry. + var heights = new byte[81]; + Array.Fill(heights, (byte)0); + var ht = new float[256]; + for (int i = 0; i < 256; i++) ht[i] = i * 1.0f; + engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, ht), + Array.Empty(), Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + engine.DataCache.RegisterCellStructForTest(0xA9B40157u, cell); + + var t = MakeTransition(); + var cellSet = new HashSet { 0xA9B40157u }; + + // Call CheckOtherCells directly via the internal seam. + var result = t.CheckOtherCells(engine, Vector3.Zero, 0.48f, cellSet); + + Assert.Equal(TransitionState.OK, result); + } +}