diff --git a/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md b/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md new file mode 100644 index 00000000..96816091 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md @@ -0,0 +1,411 @@ +# Unified Verbatim-Retail Collision-Inclusion — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This is the **DO-NOT-RETRY collision area** — every task MUST follow grep-named-retail → pseudocode → port → conformance-test. **No guess-patches.** If a port step resists, STOP and report; do not improvise. + +**Goal:** Make acdream's "which objects collide, and how the engine decides that" verbatim with retail — one DAT-only collision-shape authority + one query predicate + four faithful channels + one anti-staleness invariant. + +**Architecture:** Complete the units that already implement retail's two-layer model (`ShadowShapeBuilder` = shape authority; `CollisionExemption` + dispatch = query predicate; per-cell `ShadowObjectRegistry` = registration; per-apply rebase). Delete the non-faithful mesh-AABB synthesis. Keep terrain/EnvCell/building/object as distinct channels (retail does). Five sequential slices, each build+test green and committed. + +**Tech Stack:** C# .NET 10, xUnit, Silk.NET. DAT via `DatReaderWriter`/`DatCollection`. Named-retail decomp at `docs/research/named-retail/` (main repo). + +**Spec:** [`docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md`](../specs/2026-06-24-unified-collision-inclusion-design.md). **Audit:** [`docs/research/2026-06-24-collision-inclusion-audit.md`](../../research/2026-06-24-collision-inclusion-audit.md). + +--- + +## Pre-flight (every task) + +- **Read first:** the spec (above), `claude-memory/project_physics_collision_digest.md` (DO-NOT-RETRY table), and the audit row for the deviation you're closing. +- **Grep named-retail FIRST** for the cited `Class::method` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (main repo `C:\Users\erikn\source\repos\acdream\`). Confirm the behavior against the decomp before writing C#. Cross-ref `references/ACE/Source/ACE.Server/Physics/` and `references/ACViewer/Physics/Common/`. +- **Build:** `dotnet build` must be green before any commit. +- **Tests:** `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` (Core), plus the named replay harnesses per task. **The replay harnesses (`CellarUpTrajectoryReplayTests`, `HouseExitWalk*`, `CornerFlood*`, `Issue147ArwicBuildingsDumpTests`) must stay green for EVERY task** — they pin the shadow-list/building/cell machinery. +- **Register:** any deviation retired deletes its row in `docs/architecture/retail-divergence-register.md` in the SAME commit. +- **Visual gates are batched to the end** (user request) — do NOT stop mid-plan for them. Land all five slices, then hand off for testing. + +--- + +## Task 1 — DAT-only shape authority + delete mesh-AABB (D1, W3) + +**Retail oracle:** `CPartArray::InitParts@0x00517F40` (parts from Setup only), `CGfxObj::Serialize@0x00534970` (physics_bsp gated on serialized-flags bit-0), `CPhysicsPart::find_obj_collisions@0x0050D8D0` (null physics_bsp ⇒ returns OK = passable). Render-mesh bounds never enter collision. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — delete the mesh-AABB synthesis block (begins at the comment block `:7397-7425`, the `if (!isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0)) && entity.MeshRefs.Count > 0)` body, through the `ShadowObjects.Register(... ShadowCollisionType.Cylinder ...)` call that closes it ~`:7565`). Also remove the now-dead `isPhantomGfxObj`/`isPhantomSetup` locals (`:7384-7406`) and the scenery `_isOutdoorMesh`-driven synthesis. Route every object-registration site through `ShadowShapeBuilder` for its collision shape. +- Modify (if needed for the unified call): `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` — ensure it is the single shape authority; the registration radius comes from the shape's bounding sphere, never a synthetic constant (kills the `2f` default at `:97` and the `1f`/`2f` W3 split). +- Possibly delete: `PhysicsDataCache.IsPhantomGfxObjSource` (`:399-403`) becomes dead once the fallback it fenced is gone — remove it and its only call site (`GameWindow.cs:7406`). +- Test: `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs` (new). + +- [ ] **Step 1: Write the failing test — shapeless Setup yields NO collision shape** + +```csharp +// tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs +using System.Collections.Generic; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +public class ShadowShapeBuilderShapeSourceTests +{ + // Retail: an object with no DAT physics shape (no CylSphere, no Sphere, + // no part with a PhysicsBSP) emits NO collision shape and is passable. + // CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null. + [Fact] + public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList() + { + var setup = new Setup + { + CylSpheres = new List(), + Spheres = new List(), + Parts = new List { 0x01000ABCu }, // a part whose GfxObj has NO physics BSP + PlacementFrames = new System.Collections.Generic.Dictionary(), + }; + + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); + + Assert.Empty(shapes); // no synthetic mesh-AABB, no fallback cylinder + } +} +``` + +- [ ] **Step 2: Run it — expect PASS already (authority is already DAT-only); this is the GUARD that locks the invariant** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShadowShapeBuilderShapeSourceTests"` +Expected: PASS. (If FAIL, `ShadowShapeBuilder` itself synthesizes a fallback — fix it to be DAT-only first.) This test exists to keep it that way after the App-layer deletion. + +- [ ] **Step 3: Delete the mesh-AABB synthesis in GameWindow** + +Open `src/AcDream.App/Rendering/GameWindow.cs`. Read `:7370-7575` to find the exact block boundaries. Delete the entire `VISUAL mesh-bounds collision` synthesis (the `if (!isPhantomSetup && ...)` block and the `worldMin/worldMax` AABB computation and the `ShadowObjects.Register(..., ShadowCollisionType.Cylinder, ...)` it culminates in). Remove `isPhantomGfxObj`/`isPhantomSetup` locals and `PhysicsDataCache.IsPhantomGfxObjSource` (now dead). Ensure each registration site still registers the **real** shapes from `ShadowShapeBuilder.FromSetup(...)` / the existing BSP/CylSphere path — only the synthetic-from-mesh path is removed. **Do not** remove `ComputeVisualBounds`/`GetVisualBounds` usage that feeds CULLING; only the collision use. + +- [ ] **Step 4: Build + run the guard test + the replay harnesses** + +Run: `dotnet build` +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShadowShapeBuilderShapeSourceTests|FullyQualifiedName~CellarUp|FullyQualifiedName~HouseExitWalk|FullyQualifiedName~CornerFlood|FullyQualifiedName~Issue147Arwic"` +Expected: build green; all PASS. (The replay harnesses prove statics/buildings/cells with REAL DAT shapes still collide — only shape-less objects became passable.) + +- [ ] **Step 5: Retire the register row + commit** + +Delete the **AP-2** row from `docs/architecture/retail-divergence-register.md` (the mesh-AABB fallback divergence is gone). If an AP-row also covers the scenery `GameWindow.cs:7408` site separately, delete that too. + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.Core/Physics/ShadowShapeBuilder.cs src/AcDream.Core/Physics/PhysicsDataCache.cs tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs docs/architecture/retail-divergence-register.md +git commit -m "fix(physics): D1 — collision shape is DAT-only; delete mesh-AABB synthesis + +Retail gets collision shape from physics_bsp (CGfxObj::Serialize flags bit-0) +or CSetup cylsphere/sphere only; no shape => passable (CPhysicsPart:: +find_obj_collisions@0x0050D8D0). Deletes the scenery/outdoor mesh-AABB +synthetic-cylinder fallback (GameWindow.cs) and the IsPhantomGfxObjSource +gate that fenced it. Shape-less objects now register no shape and are +passable, verbatim with retail. Retires register row AP-2. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2 — true Sphere collision primitive (D3) + +**Retail oracle:** `CSphere::intersects_sphere` (pc:276917) — the swept sphere-vs-sphere test on the `HAS_PHYSICS_BSP` clear → CylSphere-then-Sphere branch. `CPartArray::GetSphere@0x00518070`. **Port the intersection math from the decomp; conformance-test against golden values — do NOT eyeball.** + +**Files:** +- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:537` — add `Sphere` to the enum: `public enum ShadowCollisionType : byte { BSP, Cylinder, Sphere }`. +- Modify: `src/AcDream.Core/Physics/ShadowShapeBuilder.cs:68-81` — emit `ShadowCollisionType.Sphere` (not a coerced short cylinder) for `Setup.Spheres`. +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` — narrow-phase dispatch: add a `ShadowCollisionType.Sphere` branch at the broad-phase (`:2503`, use 3-D `Length()` like the BSP/else branch) and at the test dispatch (`:2540` / the cylinder else-branch ~`:2734`) calling the new sphere primitive. +- Create/Modify: `src/AcDream.Core/Physics/CollisionPrimitives.cs` — add a swept-sphere-vs-sphere intersection ported from `CSphere::intersects_sphere`. +- Test: `tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs` (new). + +- [ ] **Step 1: Grep the oracle, write pseudocode** + +Grep `acclient_2013_pseudo_c.txt` for `CSphere::intersects_sphere` (and `CSphere::collide`). Write the pseudocode to `docs/research/2026-06-24-sphere-intersects-pseudocode.md` (closest-approach of the moving sphere center to the static sphere center over the sweep; collision when min distance ≤ sum of radii; return the time/normal retail returns). Cross-ref ACE `Physics/Common/Sphere.cs`. + +- [ ] **Step 2: Write the failing conformance test (golden values from the decomp/ACE)** + +```csharp +// tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +public class SphereIntersectsSphereConformanceTests +{ + // Golden cases derived from CSphere::intersects_sphere (pc:276917) / + // ACE Physics/Common/Sphere.cs. Replace EXPECTED with the values the + // oracle produces for each case (head-on hit, glancing miss, already-touching). + [Theory] + // moverCenter, moverRadius, sweep(delta), targetCenter, targetRadius, expectHit + [InlineData(0,0,0, 0.5f, /*sweep*/2,0,0, /*target*/1.0f,0,0, 0.5f, true)] // head-on + [InlineData(0,0,0, 0.5f, 0,2,0, 1.0f,0,0, 0.5f, false)] // perpendicular miss + public void SweptSphere_MatchesRetail( + float mx,float my,float mz, float mr, + float dx,float dy,float dz, + float tx,float ty,float tz, float tr, + bool expectHit) + { + bool hit = CollisionPrimitives.SweptSphereHitsSphere( + new Vector3(mx,my,mz), mr, new Vector3(dx,dy,dz), + new Vector3(tx,ty,tz), tr, out _); + Assert.Equal(expectHit, hit); + } +} +``` + +- [ ] **Step 3: Run it — expect FAIL (method does not exist)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~SphereIntersectsSphereConformanceTests"` +Expected: FAIL — compile error / `SweptSphereHitsSphere` not defined. + +- [ ] **Step 4: Port the primitive + the enum + builder emission + dispatch** + +Implement `CollisionPrimitives.SweptSphereHitsSphere(...)` from the pseudocode. Add `Sphere` to `ShadowCollisionType`. Emit `Sphere` in `ShadowShapeBuilder` for `Setup.Spheres`. Add the `Sphere` dispatch branches in `TransitionTypes` so dispatch order matches retail: `HAS_PHYSICS_BSP` clear ⇒ CylSphere then Sphere. + +- [ ] **Step 5: Run conformance + replay harnesses + build** + +Run: `dotnet build` +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~SphereIntersectsSphereConformanceTests|FullyQualifiedName~CellarUp|FullyQualifiedName~CornerFlood"` +Expected: build green; all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.Core/Physics/CollisionPrimitives.cs src/AcDream.Core/Physics/ShadowObjectRegistry.cs src/AcDream.Core/Physics/ShadowShapeBuilder.cs src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs docs/research/2026-06-24-sphere-intersects-pseudocode.md +git commit -m "feat(physics): D3 — true Sphere collision primitive (CSphere::intersects_sphere) + +Adds ShadowCollisionType.Sphere + a swept sphere-vs-sphere primitive ported +from CSphere::intersects_sphere (pc:276917); ShadowShapeBuilder emits Sphere +for Setup.Spheres instead of coercing to a short cylinder. Dispatch order +matches retail (HAS_PHYSICS_BSP clear => CylSphere then Sphere). Conformance- +tested vs golden values. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3 — `obstruction_ethereal` verbatim port (D2 + D5) — HIGHEST CARE + +**Retail oracle:** `CPhysicsObj::FindObjCollisions@0x0050f050` Gate-1 (pc:276782, instant-skip requires `0x4 AND 0x10`), ETHEREAL-alone branch (pc:276795-276806, sets `obstruction_ethereal=1` and CONTINUES), the per-call clear in `CEnvCell::find_env_collisions@0x0052c144` (pc:309580), and the consume sites in the BSP solid-containment gate (pc:321692 / 323742 / 324573: `if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT)`). **Port set + clear + consume TOGETHER** — a partial port hardens or softens walls unpredictably. + +**Files:** +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:378-383` (SpherePath "Misc flags") — add `public bool ObstructionEthereal;`. +- Modify: `src/AcDream.Core/Physics/CollisionExemption.cs:59-131` — change `ShouldSkip` so ETHEREAL-alone no longer returns `true`; instead the caller sets `SpherePath.ObstructionEthereal=true` and continues. (Instant-skip only when `0x4 AND 0x10`.) Update the call site at `TransitionTypes.cs:2519` to honor the new contract (set the flag, do not `continue` on ETHEREAL-alone). +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:2064` (top of `FindEnvCollisions`) — clear `sp.ObstructionEthereal = false;` before any dispatch (matches retail's per-call zero). This is **D5**. +- Modify: `src/AcDream.Core/Physics/BSPQuery.cs:1717` — the placement/solid-containment gate currently reads `if (path.InsertType == InsertType.Placement || obj.Ethereal)`; extend it to consume `path.ObstructionEthereal` exactly where retail's `obstruction_ethereal` weakens solid containment (pc:321692/323742/324573). +- Test: `tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs` (new); reuse `DoorCollisionApparatusTests`. + +- [ ] **Step 1: Grep the oracle + write pseudocode** + +Grep `FindObjCollisions` Gate-1 + the ETHEREAL-alone branch + `CEnvCell::find_env_collisions` clear + the three BSP consume sites. Write the set/clear/consume contract to `docs/research/2026-06-24-obstruction-ethereal-pseudocode.md`. Confirm: ETHEREAL-only target (ACE door open `0x0001000C`) → `obstruction_ethereal=1`, shape test still runs, solid containment weakened → **passable the retail way** (this subsumes the AD-7 shim; no wire-layer compat is added). + +- [ ] **Step 2: Write the failing tests (door passable on ETHEREAL-alone; wall unchanged)** + +```csharp +// tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs +using AcDream.Core.Physics; +using Xunit; + +public class ObstructionEtherealTests +{ + // Gate-1: instant-skip requires BOTH ETHEREAL (0x4) AND IGNORE_COLLISIONS (0x10). + [Fact] + public void ShouldSkip_RequiresBothEtherealAndIgnoreCollisions() + { + // both bits → skip + Assert.True(CollisionExemption.ShouldSkip(0x14u, EntityCollisionFlags.None, ObjectInfoState.IsPlayer)); + // ETHEREAL alone → NOT an instant skip (handled via ObstructionEthereal instead) + Assert.False(CollisionExemption.ShouldSkip(0x4u, EntityCollisionFlags.None, ObjectInfoState.IsPlayer)); + } +} +``` + +Plus a `SpherePath`-level test (or extend `DoorCollisionApparatusTests`): an ETHEREAL-only door target sets `ObstructionEthereal` and yields a passable transition; a non-ethereal wall in the same cell still blocks (`CollidedWithEnvironment`). + +- [ ] **Step 3: Run — expect FAIL** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ObstructionEthereal"` +Expected: FAIL (`ShouldSkip(0x4...)` currently returns `true`; `ObstructionEthereal` field missing). + +- [ ] **Step 4: Port set + clear + consume (one slice)** + +Add `SpherePath.ObstructionEthereal`. Change `CollisionExemption.ShouldSkip` (remove the `0x4`-alone early return; keep the `0x4 AND 0x10` instant-skip). At `TransitionTypes.cs:2519`, on ETHEREAL-alone set `sp.ObstructionEthereal = true` and continue to the shape test. Clear `sp.ObstructionEthereal=false` at the top of `FindEnvCollisions`. Consume it in `BSPQuery.cs:1717`'s solid-containment gate per the decomp. + +- [ ] **Step 5: Run tests + replay harnesses + build** + +Run: `dotnet build` +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ObstructionEthereal|FullyQualifiedName~DoorCollision|FullyQualifiedName~CellarUp|FullyQualifiedName~CornerFlood"` +Expected: build green; all PASS — **crucially the CellarUp/CornerFlood wall-collision harnesses unchanged** (the consume site must not weaken normal walls). + +- [ ] **Step 6: Retire AD-7 + commit** + +Delete the **AD-7** row from `docs/architecture/retail-divergence-register.md` (ETHEREAL-alone shim retired). + +```bash +git add src/AcDream.Core/Physics/TransitionTypes.cs src/AcDream.Core/Physics/CollisionExemption.cs src/AcDream.Core/Physics/BSPQuery.cs tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs docs/research/2026-06-24-obstruction-ethereal-pseudocode.md docs/architecture/retail-divergence-register.md +git commit -m "fix(physics): D2+D5 — verbatim obstruction_ethereal; retire ETHEREAL-alone shim + +Gate-1 instant-skip now requires ETHEREAL(0x4) AND IGNORE_COLLISIONS(0x10) +(pc:276782). ETHEREAL-alone sets SpherePath.ObstructionEthereal and continues +the shape test (pc:276795-276806); FindEnvCollisions clears it per call +(pc:309580); BSPQuery consumes it in the solid-containment gate (pc:321692/ +323742/324573). ACE's ETHEREAL-only opened doors become passable the retail +way, subsuming the AD-7 shim (deleted) with no wire-layer compat. Walls on +non-ethereal cells unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4 — `RemoveCellsForLandblock` (D8) — the anti-staleness invariant + +**Retail oracle:** the #146 pattern (`PhysicsDataCache.RemoveBuildingsForLandblock`, `:472-478`); retail rebuilds cell transforms from live cell pointers (eviction analog `CEnvCell::release`). Invariant: *every cached collision transform rebases to the current streaming center on each landblock apply.* + +**Files:** +- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` — add `RemoveCellsForLandblock(uint landblockId)` symmetric with `RemoveBuildingsForLandblock`. +- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs:121-141` (`RemoveLandblock`) — call `DataCache?.RemoveCellsForLandblock(landblockId);`. +- Test: `tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs` (new). + +- [ ] **Step 1: Write the failing test** + +```csharp +// tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs +using AcDream.Core.Physics; +using Xunit; + +public class RemoveCellsForLandblockTests +{ + [Fact] + public void RemoveCellsForLandblock_EvictsOnlyMatchingPrefix() + { + var cache = new PhysicsDataCache(); + // Register two cell structs via the test seam (one in lb 0xAAAA, one in 0xBBBB). + cache.RegisterCellStructForTest(0xAAAA0100u, TestCells.MinimalCellPhysics()); + cache.RegisterCellStructForTest(0xBBBB0100u, TestCells.MinimalCellPhysics()); + + cache.RemoveCellsForLandblock(0xAAAA0000u); + + Assert.Null(cache.GetCellStruct(0xAAAA0100u)); // evicted + Assert.NotNull(cache.GetCellStruct(0xBBBB0100u)); // untouched + } +} +``` + +(If no `MinimalCellPhysics` helper exists, build a `CellPhysics` with a non-null `BSP.Root` inline or via the existing test fixtures used by `CellarUpTrajectoryReplayTests`.) + +- [ ] **Step 2: Run — expect FAIL (method missing)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RemoveCellsForLandblock"` +Expected: FAIL — `RemoveCellsForLandblock` not defined. + +- [ ] **Step 3: Implement** + +```csharp +// PhysicsDataCache.cs — symmetric with RemoveBuildingsForLandblock (:472) +/// +/// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so +/// the next CacheCellStruct re-bases its STREAMING-RELATIVE WorldTransform +/// against the current _liveCenter — symmetric with RemoveBuildingsForLandblock +/// (#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. +/// +public void RemoveCellsForLandblock(uint landblockId) +{ + uint prefix = landblockId & 0xFFFF0000u; + foreach (var key in _cellStruct.Keys) + if ((key & 0xFFFF0000u) == prefix) + _cellStruct.TryRemove(key, out _); + // Mirror the building-channel symmetry: CellGraph eviction is already done + // by PhysicsEngine.RemoveLandblock -> CellGraph.RemoveLandblock. +} +``` + +Then in `PhysicsEngine.RemoveLandblock` add `DataCache?.RemoveCellsForLandblock(landblockId);` alongside the existing clears (near `:124`). + +- [ ] **Step 4: Run test + replay harnesses + build** + +Run: `dotnet build` +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RemoveCellsForLandblock|FullyQualifiedName~CellarUp"` +Expected: build green; all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Physics/PhysicsDataCache.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs +git commit -m "fix(physics): D8 — RemoveCellsForLandblock; cell transforms rebase per apply + +Symmetric with RemoveBuildingsForLandblock (#146): PhysicsEngine.RemoveLandblock +now evicts _cellStruct entries for the landblock so the next CacheCellStruct +re-bases the cell BSP WorldTransform against the current streaming center. Closes +the #146-class stale-frame asymmetry for indoor cells (D8). Completes the +invariant: every cached collision transform rebases on each landblock apply. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5 — entry-restrictions hook + predicate wiring (D4 + W1) — conservative + +**Retail oracle:** `CEnvCell::find_env_collisions@0x0052c130` calls `check_entry_restrictions` FIRST (pc:309576); `CObjCell::check_entry_restrictions@0x0052b6d0` returns COLLIDED when an access-locked cell's `restriction_obj` rejects the mover. Dispatch terms at pc:276861 (`HAS_PHYSICS_BSP_PS AND ebp_1==0 AND missile_ignore==0`). + +**Files:** +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` — add a `check_entry_restrictions`-shaped gate at the front of `FindEnvCollisions` (`:2062`). Inert today (returns OK when the cell has no `restriction_obj`; current dev content has none) but structurally present. (D4) +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:669` `BspOnlyDispatch` + the call at `:2633` — wire the real `pvpExempt`/`missile_ignore` terms (currently hardcoded false at `:2629`) so dispatch matches `pc:276861`. Keep behavior identical in M1.5 (no PK/missiles) but remove the hardcoded-false smell. (W1) +- Test: extend `tests/AcDream.Core.Tests/Physics/` with a dispatch test pinning the door BSP-only case (must not regress A6.P7) and an entry-restriction no-op test. + +- [ ] **Step 1: Write the failing/guard tests** + +A test that `FindEnvCollisions` returns OK for a cell with no `restriction_obj` (entry-restrictions inert), and a dispatch test that a `HAS_PHYSICS_BSP_PS` door still dispatches BSP-only after the W1 wiring (pin A6.P7 behavior). + +- [ ] **Step 2: Run — expect FAIL or PASS-as-guard** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntryRestriction|FullyQualifiedName~BspOnlyDispatch"` + +- [ ] **Step 3: Implement the entry-restrictions gate (inert) + W1 wiring** + +Add the gate; wire `pvpExempt`/`missile_ignore` (still resolve to false in M1.5, but via the real predicate terms, not a hardcoded literal). + +- [ ] **Step 4: Run tests + the FULL Core suite + replay harnesses + build** + +Run: `dotnet build` +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: build green; full Core suite PASS (no behavior change in dev content). + +- [ ] **Step 5: Register note + commit** + +In `docs/architecture/retail-divergence-register.md`: add a one-line D4 row "entry-restrictions ported but inert until access-locked content" if the gate is a stub, OR no row if fully faithful; remove the W1 "hardcoded false" note if one exists. + +```bash +git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/ docs/architecture/retail-divergence-register.md +git commit -m "feat(physics): D4+W1 — entry-restrictions gate + wired PvP/missile dispatch terms + +Adds CObjCell::check_entry_restrictions gate at the front of FindEnvCollisions +(pc:309576), inert until access-locked content. Wires the real pvpExempt/ +missile_ignore dispatch terms (pc:276861) replacing the hardcoded-false at +TransitionTypes.cs:2629 — behavior identical in M1.5, smell removed. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final — batched visual-gate handoff (STOP here, hand to user) + +After all five tasks land green, run the full suites once more and hand off the three feel-gates for the user to test in one session: + +Run: `dotnet build && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: build + full Core suite green. + +Then present the launch command (CLAUDE.md "Running the client") and the three things to check: +1. **Slice 1 (scenery feel):** walk Holtburg + open world — objects that became passable must be exactly those retail walks through (no DAT physics shape); real statics/walls/buildings still solid. +2. **Slice 3 (doors):** open a door (ACE), walk through (passable), close it, walk into it (solid); confirm no house wall went soft. +3. **Slice 4 (dungeon re-entry):** teleport into a dungeon → out → back into the same dungeon; indoor walls collide correctly each time (no stale-offset clip). + +Update `docs/ISSUES.md` / the physics digest with the phase outcome after the user's gate passes. + +--- + +## Self-review notes (filled by plan author) + +- **Spec coverage:** D1→T1, D3→T2, D2+D5→T3, D8→T4, D4+W1→T5. D6/D7 refuted (no task). All 6 confirmed deviations + W1 covered. ✓ +- **Type consistency:** `ShadowCollisionType { BSP, Cylinder, Sphere }` (T2) used consistently; `SpherePath.ObstructionEthereal` (T3) set in CollisionExemption call site, cleared in FindEnvCollisions, consumed in BSPQuery — same name throughout; `RemoveCellsForLandblock` (T4) named identically at definition + call site. ✓ +- **Sequencing:** T1→T2 (Sphere extends the now-sole authority), T2→T3 (predicate completion after shapes), T3→T4 (independent), T5 last (conservative). Serial — shared files (ShadowShapeBuilder, TransitionTypes, SpherePath, BSPQuery). Do NOT parallelize. ✓ +- **Port-sensitive steps** (sphere math T2; obstruction_ethereal consume T3) are oracle-driven + conformance-tested, not pre-written — the DO-NOT-RETRY no-guess rule. The implementer derives the exact C# from the decomp. ✓