acdream/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md
Erik 4f26067755 docs(physics): implementation plan — unified verbatim-collision (5 slices)
Task-by-task plan for the unified collision-inclusion fix: D1 DAT-only shape
authority + delete mesh-AABB, D3 sphere primitive, D2+D5 obstruction_ethereal
port, D8 RemoveCellsForLandblock, D4+W1 entry-restrictions + dispatch wiring.
Each task: grep-named -> pseudocode -> port -> conformance-test, build+test
green, commit; visual gates batched to the end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:51:01 +02:00

27 KiB

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. Audit: docs/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

// 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<CylSphere>(),
            Spheres    = new List<Sphere>(),
            Parts      = new List<uint> { 0x01000ABCu }, // a part whose GfxObj has NO physics BSP
            PlacementFrames = new System.Collections.Generic.Dictionary<Placement, AnimationFrame>(),
        };

        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.

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) <noreply@anthropic.com>"

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)
// 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
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) <noreply@anthropic.com>"

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

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) <noreply@anthropic.com>"

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

// 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
// PhysicsDataCache.cs — symmetric with RemoveBuildingsForLandblock (:472)
/// <summary>
/// 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.
/// </summary>
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
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) <noreply@anthropic.com>"

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.

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) <noreply@anthropic.com>"

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. ✓