10-task TDD implementation plan for the design in
docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md
(commit d71ceab). Each task is bite-sized (write failing test → run
→ implement → run → commit), with complete code in every step per
the writing-plans skill's "no placeholders" rule.
Map: Task 1-2 = ShadowShape + ShadowShapeBuilder; Task 3-6 =
ShadowObjectRegistry multi-part extensions (ShadowEntry fields,
RegisterMultiPart, multi-part UpdatePosition, Deregister cleanup);
Task 7 = RegisterLiveEntityCollision refactor (closes door bug);
Task 8 = landblock-static refactor (unifies paths); Task 9 = live-
capture regression pin; Task 10 = strip investigation diagnostics +
ship docs.
Visual verification gates after Task 7 (door fix surface) and Task 8
(static-collision regression check). 40+ test green-gate at every
commit boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1585 lines
65 KiB
Markdown
1585 lines
65 KiB
Markdown
# Door Collision Per-Part BSP Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Server-spawned entities (doors, NPCs, chests) register one shadow row per collision shape (CylSpheres + Spheres + per-Part BSPs), all sharing the entity's EntityId so state propagation works retail-faithfully. Closes the M1.5 "doors don't block" bug.
|
||
|
||
**Architecture:** New `ShadowShape` record + new `ShadowShapeBuilder.FromSetup` pure function + new `ShadowObjectRegistry.RegisterMultiPart` API. The single-shape `Register` becomes a 1-element wrapper. `RegisterLiveEntityCollision` and the landblock-static loop both call `RegisterMultiPart`, unifying the two paths. Retail anchor: `CObjCell::find_obj_collisions` → `CPhysicsObj::FindObjCollisions` → `CPartArray::FindObjCollisions` → `CPhysicsPart::find_obj_collisions` → `CGfxObj::find_obj_collisions` (`acclient_2013_pseudo_c.txt:276776-275055`).
|
||
|
||
**Tech Stack:** C# / .NET 10 / DatReaderWriter / xUnit. Worktree: `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md` (commit `d71ceab`).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New files:**
|
||
- `src/AcDream.Core/Physics/ShadowShape.cs` — record struct, ~25 LOC
|
||
- `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` — pure function over Setup, ~80 LOC
|
||
- `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` — unit tests, ~120 LOC
|
||
- `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` — registry tests, ~180 LOC
|
||
- `tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl` — live capture (1 record)
|
||
|
||
**Modified files:**
|
||
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — add `RegisterMultiPart`, extend `ShadowEntry` with `LocalPosition`/`LocalRotation`, add `_entityShapes` map, make `Register` a wrapper. Net +80 LOC.
|
||
- `src/AcDream.App/Rendering/GameWindow.cs:3076-3160` (`RegisterLiveEntityCollision`) — replace body with builder + multi-part call. Net -50 / +25.
|
||
- `src/AcDream.App/Rendering/GameWindow.cs:5893-6213` (landblock-static loop) — replace per-part loop with single multi-part call. Net -300 / +30.
|
||
- `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` — add `LiveCompare_DoorBlocksWhenClosed`. Net +80.
|
||
|
||
**Strip-on-ship (investigation diagnostics):**
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — `_doorSetupDumped` field + `[door-setup-dump]` block
|
||
- Keep: `[entity-phantom]` (general-purpose), `[entity-source]` radius/height/pos (general-purpose), `[cyl-test]` (useful for future cylinder debugging)
|
||
|
||
---
|
||
|
||
## Task 1: Add `ShadowShape` record
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/Physics/ShadowShape.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (smoke construction only — full builder tests in Task 2)
|
||
|
||
- [ ] **Step 1: Create file with the record**
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// One collision-bearing shape attached to a logical PhysicsObj.
|
||
/// A door emits 3-4 (CylSphere(s) + Sphere(s) + Part-BSPs); a creature
|
||
/// may emit a few; a simple item zero. Positions and rotations are
|
||
/// LOCAL to the entity's origin so <see cref="ShadowObjectRegistry.UpdatePosition"/>
|
||
/// can re-transform them when the entity moves.
|
||
///
|
||
/// <para>
|
||
/// Retail anchor: <c>CPhysicsPart</c> + <c>CPhysicsPart::find_obj_collisions</c>
|
||
/// at <c>acclient_2013_pseudo_c.txt:275045-275055</c>. Each part stores its
|
||
/// own transform and dispatches per-part collision to its GfxObj.
|
||
/// </para>
|
||
/// </summary>
|
||
public readonly record struct ShadowShape(
|
||
uint GfxObjId,
|
||
Vector3 LocalPosition,
|
||
Quaternion LocalRotation,
|
||
float Scale,
|
||
ShadowCollisionType CollisionType,
|
||
float Radius,
|
||
float CylHeight);
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build succeeds**
|
||
|
||
Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj --nologo -v q`
|
||
Expected: `Build succeeded. 0 Error(s)`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ShadowShape.cs
|
||
git commit -m "feat(phys): add ShadowShape record (no callers yet)
|
||
|
||
Standalone record representing one collision-bearing shape attached to
|
||
a logical PhysicsObj. Foundation for the per-part BSP collision fix
|
||
that closes the M1.5 \"doors don't block\" bug. Spec at
|
||
docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md.
|
||
|
||
No callers in this commit; integration follows in subsequent commits.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Implement `ShadowShapeBuilder.FromSetup`
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/Physics/ShadowShapeBuilder.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs`
|
||
|
||
Note on signature: per design refinement during planning, `FromSetup` takes a `Func<uint, bool> hasPhysicsBsp` predicate (not the concrete `PhysicsDataCache`) so tests can stub the BSP-presence check without instantiating a full cache. Production callers pass `id => cache.GetGfxObj(id)?.BSP?.Root is not null`.
|
||
|
||
- [ ] **Step 1: Write the failing test — door setup produces 4 shapes (0 cyl + 1 sphere + 3 BSP)**
|
||
|
||
Create `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
public class ShadowShapeBuilderTests
|
||
{
|
||
/// <summary>
|
||
/// Synthetic Setup mirroring live dump of 0x020019FF (cottage door)
|
||
/// captured 2026-05-24: 0 cylSpheres, 1 sphere (r=0.100, origin=(0,0,0.018)),
|
||
/// 3 parts (0x010044B5 + 0x010044B6 + 0x010044B6), setup.Radius=0.141,
|
||
/// setup.Height=0.200. PlacementFrames[Default] has identity transforms
|
||
/// for all 3 parts (door is upright at spawn).
|
||
/// </summary>
|
||
private static Setup CreateDoorSetup()
|
||
{
|
||
var setup = new Setup
|
||
{
|
||
Radius = 0.141f,
|
||
Height = 0.200f,
|
||
StepUpHeight = 0.090f,
|
||
StepDownHeight = 0.090f,
|
||
Parts = new List<uint> { 0x010044B5u, 0x010044B6u, 0x010044B6u },
|
||
CylSpheres = new List<CylSphere>(),
|
||
Spheres = new List<Sphere>
|
||
{
|
||
new() { Radius = 0.100f, Origin = new Vector3(0f, 0f, 0.018f) }
|
||
},
|
||
PlacementFrames = new Dictionary<Placement, AnimationFrame>
|
||
{
|
||
[Placement.Default] = new AnimationFrame
|
||
{
|
||
Frames = new List<Frame>
|
||
{
|
||
new() { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
|
||
new() { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
|
||
new() { Origin = Vector3.Zero, Orientation = Quaternion.Identity }
|
||
}
|
||
}
|
||
}
|
||
};
|
||
return setup;
|
||
}
|
||
|
||
[Fact]
|
||
public void FromSetup_DoorSetup_ProducesFourShapes()
|
||
{
|
||
var setup = CreateDoorSetup();
|
||
Func<uint, bool> hasBsp = id => id == 0x010044B5u || id == 0x010044B6u;
|
||
|
||
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1.0f, hasBsp);
|
||
|
||
// 0 cyl + 1 sphere (as short cylinder) + 3 BSP = 4 shapes
|
||
Assert.Equal(4, shapes.Count);
|
||
|
||
int cylinderCount = 0;
|
||
int bspCount = 0;
|
||
foreach (var s in shapes)
|
||
{
|
||
if (s.CollisionType == ShadowCollisionType.Cylinder) cylinderCount++;
|
||
else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++;
|
||
}
|
||
Assert.Equal(1, cylinderCount); // the Sphere → short Cylinder
|
||
Assert.Equal(3, bspCount); // the 3 parts with BSP
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure (builder doesn't exist yet)**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes" -v minimal 2>&1 | tail -10`
|
||
Expected: build failure or "ShadowShapeBuilder not found"
|
||
|
||
- [ ] **Step 3: Implement `ShadowShapeBuilder.FromSetup`**
|
||
|
||
Create `src/AcDream.Core/Physics/ShadowShapeBuilder.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
|
||
/// <see cref="ShadowShape"/>s suitable for registration via
|
||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
|
||
///
|
||
/// <para>
|
||
/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no
|
||
/// CylSpheres are present (matches retail and the existing landblock-static
|
||
/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a
|
||
/// non-null PhysicsBSP → per-part BSP shape, with local transforms from
|
||
/// PlacementFrames[Resting | Default | first available].
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
|
||
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
|
||
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
|
||
/// ShadowShape per part contribution so the existing FindObjCollisions
|
||
/// iteration loop in <see cref="Transition"/> tests each part independently.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class ShadowShapeBuilder
|
||
{
|
||
/// <summary>
|
||
/// Build the shape list for a Setup.
|
||
/// </summary>
|
||
/// <param name="setup">The Setup to walk.</param>
|
||
/// <param name="entScale">The entity's overall scale factor; multiplies
|
||
/// every radius, height, and local offset.</param>
|
||
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
||
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||
Setup setup,
|
||
float entScale,
|
||
Func<uint, bool> hasPhysicsBsp)
|
||
{
|
||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||
|
||
var result = new List<ShadowShape>();
|
||
|
||
// 1. CylSpheres — each becomes a Cylinder shape.
|
||
foreach (var cyl in setup.CylSpheres)
|
||
{
|
||
if (cyl.Radius <= 0f) continue;
|
||
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.Cylinder,
|
||
Radius: cyl.Radius * entScale,
|
||
CylHeight: baseHeight * entScale));
|
||
}
|
||
|
||
// 2. Spheres — only when no CylSpheres (matches landblock-static convention
|
||
// at GameWindow.cs:6034). Each becomes a short Cylinder.
|
||
if (setup.CylSpheres.Count == 0)
|
||
{
|
||
foreach (var sph in setup.Spheres)
|
||
{
|
||
if (sph.Radius <= 0f) continue;
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.Cylinder,
|
||
Radius: sph.Radius * entScale,
|
||
CylHeight: sph.Radius * 2f * entScale));
|
||
}
|
||
}
|
||
|
||
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
||
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
||
for (int i = 0; i < setup.Parts.Count; i++)
|
||
{
|
||
uint gfxId = (uint)setup.Parts[i];
|
||
if (!hasPhysicsBsp(gfxId)) continue;
|
||
|
||
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
|
||
? placementFrame.Frames[i]
|
||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||
|
||
// BSP radius is the part's bounding sphere; the production caller's
|
||
// PhysicsGfxObj would have it, but for builder-purity we use a
|
||
// sensible default. The actual broadphase radius gets tightened by
|
||
// the registry when the BSP is queried at collision time.
|
||
// TODO at builder-call-site: pass the real BSP radius if available.
|
||
float bspRadius = 2f * entScale; // generic loose-but-safe default
|
||
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: gfxId,
|
||
LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
||
LocalRotation: partFrame.Orientation,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: bspRadius,
|
||
CylHeight: 0f));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>Resolve the placement frame in priority Resting → Default →
|
||
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
|
||
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
|
||
{
|
||
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting;
|
||
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def;
|
||
foreach (var kvp in setup.PlacementFrames) return kvp.Value;
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
Implementation note: the `bspRadius = 2f` placeholder is intentional — the production builder caller (Task 5/6) replaces it with the real `cache.GetGfxObj(gfxId).BoundingSphere.Radius` at registration time. The builder stays pure (no PhysicsDataCache dependency); the caller fills in the BSP radius.
|
||
|
||
To keep the builder pure AND avoid the "TODO at builder-call-site," refine the predicate to return the radius directly:
|
||
|
||
```csharp
|
||
Func<uint, float?> bspRadiusOrNull // null when no BSP, else the radius
|
||
```
|
||
|
||
But that complicates tests. Pragmatic compromise: keep `Func<uint, bool>` and document the post-builder radius substitution. Implementer can refactor later if it becomes friction.
|
||
|
||
- [ ] **Step 4: Run test, expect pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes" -v minimal 2>&1 | tail -5`
|
||
Expected: `Passed! - Failed: 0, Passed: 1`
|
||
|
||
- [ ] **Step 5: Add 5 more tests covering edge cases**
|
||
|
||
Append to `ShadowShapeBuilderTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset()
|
||
{
|
||
var setup = CreateDoorSetup();
|
||
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true);
|
||
|
||
var sphereAsCyl = shapes.Find(s => s.CollisionType == ShadowCollisionType.Cylinder);
|
||
Assert.NotEqual(default, sphereAsCyl);
|
||
Assert.Equal(0f, sphereAsCyl.LocalPosition.X, 4);
|
||
Assert.Equal(0f, sphereAsCyl.LocalPosition.Y, 4);
|
||
Assert.Equal(0.018f, sphereAsCyl.LocalPosition.Z, 4);
|
||
Assert.Equal(0.100f, sphereAsCyl.Radius, 4);
|
||
}
|
||
|
||
[Fact]
|
||
public void FromSetup_PartWithoutBsp_SkipsBspShape()
|
||
{
|
||
var setup = CreateDoorSetup();
|
||
// Predicate says ONLY 0x010044B5 has BSP; other 2 parts don't
|
||
Func<uint, bool> hasBsp = id => id == 0x010044B5u;
|
||
|
||
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp);
|
||
|
||
int bspCount = 0;
|
||
foreach (var s in shapes)
|
||
if (s.CollisionType == ShadowCollisionType.BSP) bspCount++;
|
||
Assert.Equal(1, bspCount); // only the BSP-bearing part emits a BSP shape
|
||
}
|
||
|
||
[Fact]
|
||
public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders()
|
||
{
|
||
var setup = new Setup
|
||
{
|
||
Parts = new List<uint> { 0x02000001u },
|
||
CylSpheres = new List<CylSphere>
|
||
{
|
||
new() { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0, 0, 0.6f) }
|
||
},
|
||
Spheres = new List<Sphere>
|
||
{
|
||
new() { Radius = 0.50f, Origin = new Vector3(0, 0, 0.7f) }
|
||
},
|
||
PlacementFrames = new Dictionary<Placement, AnimationFrame>()
|
||
};
|
||
|
||
// Part HAS a BSP but the predicate filters it out so the test isolates
|
||
// the CylSphere/Sphere convention (Spheres skipped when CylSpheres present).
|
||
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false);
|
||
|
||
Assert.Single(shapes);
|
||
Assert.Equal(ShadowCollisionType.Cylinder, shapes[0].CollisionType);
|
||
Assert.Equal(0.40f, shapes[0].Radius, 3);
|
||
Assert.Equal(1.20f, shapes[0].CylHeight, 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets()
|
||
{
|
||
var setup = CreateDoorSetup();
|
||
|
||
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 2.0f, _ => true);
|
||
|
||
foreach (var s in shapes)
|
||
{
|
||
Assert.Equal(2.0f, s.Scale, 3);
|
||
if (s.CollisionType == ShadowCollisionType.Cylinder)
|
||
{
|
||
// Sphere(0.100) at (0,0,0.018) × 2 → radius 0.200, position (0,0,0.036)
|
||
Assert.Equal(0.200f, s.Radius, 3);
|
||
Assert.Equal(0.036f, s.LocalPosition.Z, 3);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void FromSetup_EmptySetup_ReturnsEmptyList()
|
||
{
|
||
var setup = new Setup
|
||
{
|
||
Parts = new List<uint>(),
|
||
CylSpheres = new List<CylSphere>(),
|
||
Spheres = new List<Sphere>(),
|
||
PlacementFrames = new Dictionary<Placement, AnimationFrame>()
|
||
};
|
||
|
||
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true);
|
||
|
||
Assert.Empty(shapes);
|
||
}
|
||
|
||
[Fact]
|
||
public void FromSetup_NullSetup_Throws()
|
||
{
|
||
Assert.Throws<ArgumentNullException>(
|
||
() => ShadowShapeBuilder.FromSetup(null!, 1.0f, _ => true));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run full test class**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowShapeBuilderTests" -v minimal 2>&1 | tail -10`
|
||
Expected: `Passed: 6, Failed: 0`
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ShadowShapeBuilder.cs tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs
|
||
git commit -m "feat(phys): ShadowShapeBuilder.FromSetup
|
||
|
||
Pure function translating Setup → IReadOnlyList<ShadowShape>. Walks
|
||
CylSpheres + Spheres (only when no CylSpheres) + Parts (only when the
|
||
GfxObj has a non-null PhysicsBSP), using PlacementFrames in the same
|
||
Resting → Default → first-available priority as SetupMesh.Flatten.
|
||
|
||
Six tests pin the behavior: door setup produces 4 shapes (0+1+3), sphere
|
||
local offset matches Setup data, parts without BSP are skipped, creature
|
||
setups with CylSpheres skip Spheres, scale factor multiplies all radii
|
||
and offsets, empty setup returns empty list, null setup throws.
|
||
|
||
No callers in this commit; RegisterMultiPart + the GameWindow callers
|
||
follow.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Extend `ShadowEntry` with local-offset fields
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (ShadowEntry record struct)
|
||
|
||
- [ ] **Step 1: Locate the ShadowEntry record at the bottom of ShadowObjectRegistry.cs**
|
||
|
||
Run: `git grep -n "public readonly record struct ShadowEntry" src/AcDream.Core/Physics/ShadowObjectRegistry.cs`
|
||
Expected: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:391:public readonly record struct ShadowEntry(`
|
||
|
||
- [ ] **Step 2: Add LocalPosition + LocalRotation fields**
|
||
|
||
In `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`, change the `ShadowEntry` record from:
|
||
|
||
```csharp
|
||
public readonly record struct ShadowEntry(
|
||
uint EntityId,
|
||
uint GfxObjId,
|
||
Vector3 Position,
|
||
Quaternion Rotation,
|
||
float Radius,
|
||
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
|
||
```
|
||
|
||
To (add the two new fields with default values at the end of the parameter list so existing callers stay compatible):
|
||
|
||
```csharp
|
||
public readonly record struct ShadowEntry(
|
||
uint EntityId,
|
||
uint GfxObjId,
|
||
Vector3 Position,
|
||
Quaternion Rotation,
|
||
float Radius,
|
||
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
|
||
float CylHeight = 0f,
|
||
float Scale = 1.0f,
|
||
uint State = 0u,
|
||
EntityCollisionFlags Flags = EntityCollisionFlags.None,
|
||
// A6.P4 door fix (2026-05-24): local-to-entity transform for multi-part
|
||
// entities. ShadowObjectRegistry.UpdatePosition uses these to rebuild
|
||
// Position/Rotation when the entity moves. Single-shape callers leave
|
||
// these at default (zero offset, identity rotation) — equivalent to
|
||
// the shape sitting at the entity's origin.
|
||
Vector3 LocalPosition = default,
|
||
Quaternion LocalRotation = default);
|
||
```
|
||
|
||
(Adjust the order to match the current record — keep CylHeight/Scale/State/Flags where they are, append LocalPosition + LocalRotation at the END.)
|
||
|
||
- [ ] **Step 3: Verify build and existing tests pass**
|
||
|
||
Run:
|
||
```
|
||
dotnet build src/AcDream.Core/AcDream.Core.csproj --nologo -v q
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryTests" -v minimal
|
||
```
|
||
Expected: build green, `Passed: 22, Failed: 0`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs
|
||
git commit -m "feat(phys): ShadowEntry adds LocalPosition + LocalRotation
|
||
|
||
Local-to-entity transform fields, default-valued so existing single-shape
|
||
callers keep working unchanged. RegisterMultiPart (next commit) populates
|
||
them per part so UpdatePosition can rebuild the entry's world Position +
|
||
Rotation when the entity moves.
|
||
|
||
All 22 existing ShadowObjectRegistry tests pass.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add `RegisterMultiPart` to `ShadowObjectRegistry`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` (new)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs`:
|
||
|
||
```csharp
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
public class ShadowObjectRegistryMultiPartTests
|
||
{
|
||
private const uint LbId = 0xA9B40000u;
|
||
private const float OffX = 0f;
|
||
private const float OffY = 0f;
|
||
|
||
private static IReadOnlyList<ShadowShape> DoorShapes() => new[]
|
||
{
|
||
// The Sphere-as-short-cylinder (sized like the live door's Sphere).
|
||
new ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: new Vector3(0f, 0f, 0.018f),
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: 1.0f,
|
||
CollisionType: ShadowCollisionType.Cylinder,
|
||
Radius: 0.100f,
|
||
CylHeight: 0.200f),
|
||
// 3 BSP parts at identity-frame local positions.
|
||
new ShadowShape(
|
||
GfxObjId: 0x010044B5u,
|
||
LocalPosition: Vector3.Zero,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: 1.0f,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: 2.0f,
|
||
CylHeight: 0f),
|
||
new ShadowShape(
|
||
GfxObjId: 0x010044B6u,
|
||
LocalPosition: Vector3.Zero,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: 1.0f,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: 2.0f,
|
||
CylHeight: 0f),
|
||
new ShadowShape(
|
||
GfxObjId: 0x010044B6u,
|
||
LocalPosition: Vector3.Zero,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: 1.0f,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: 2.0f,
|
||
CylHeight: 0f)
|
||
};
|
||
|
||
[Fact]
|
||
public void RegisterMultiPart_FourShapes_AllShareEntityId()
|
||
{
|
||
var reg = new ShadowObjectRegistry();
|
||
const uint doorEntityId = 0x000F4244u;
|
||
|
||
reg.RegisterMultiPart(
|
||
entityId: doorEntityId,
|
||
entityWorldPos: new Vector3(132.6f, 17.1f, 94.08f),
|
||
entityWorldRot: Quaternion.Identity,
|
||
shapes: DoorShapes(),
|
||
state: 0x10008u,
|
||
flags: EntityCollisionFlags.None,
|
||
worldOffsetX: OffX,
|
||
worldOffsetY: OffY,
|
||
landblockId: LbId);
|
||
|
||
// All 4 entries should share doorEntityId.
|
||
int found = 0;
|
||
foreach (var entry in reg.AllEntriesForDebug())
|
||
{
|
||
if (entry.EntityId == doorEntityId) found++;
|
||
}
|
||
Assert.True(found >= 4,
|
||
$"Expected at least 4 entries for door entity (one per shape); found {found}");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure (method doesn't exist)**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryMultiPartTests.RegisterMultiPart_FourShapes_AllShareEntityId" -v minimal 2>&1 | tail -5`
|
||
Expected: build failure ("RegisterMultiPart not found")
|
||
|
||
- [ ] **Step 3: Implement `RegisterMultiPart`**
|
||
|
||
In `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`:
|
||
|
||
3a) Add the `_entityShapes` private field near `_entityToCells`:
|
||
|
||
```csharp
|
||
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
||
private readonly Dictionary<uint, List<uint>> _entityToCells = new();
|
||
/// <summary>
|
||
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
|
||
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
|
||
/// the entity moves. Cleared by <see cref="Deregister"/>.
|
||
/// </summary>
|
||
private readonly Dictionary<uint, IReadOnlyList<ShadowShape>> _entityShapes = new();
|
||
```
|
||
|
||
3b) Add the `RegisterMultiPart` method below the existing `Register`:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// A6.P4 door fix (2026-05-24): register one logical entity composed of
|
||
/// multiple collision shapes. All emitted <see cref="ShadowEntry"/> rows
|
||
/// share <paramref name="entityId"/>, so <see cref="UpdatePhysicsState"/>
|
||
/// propagates an ETHEREAL flip to every part (the existing per-entityId
|
||
/// iteration handles this naturally). The shape list is cached in
|
||
/// <see cref="_entityShapes"/> so <see cref="UpdatePosition"/> can
|
||
/// recompose part world-transforms when the entity moves.
|
||
///
|
||
/// <para>
|
||
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> →
|
||
/// <c>CPartArray::FindObjCollisions</c> at
|
||
/// <c>acclient_2013_pseudo_c.txt:276961-286250</c>. One PhysicsObj per
|
||
/// entity, parts iterated for collision testing.
|
||
/// </para>
|
||
/// </summary>
|
||
public void RegisterMultiPart(
|
||
uint entityId,
|
||
Vector3 entityWorldPos,
|
||
Quaternion entityWorldRot,
|
||
IReadOnlyList<ShadowShape> shapes,
|
||
uint state,
|
||
EntityCollisionFlags flags,
|
||
float worldOffsetX, float worldOffsetY, uint landblockId,
|
||
uint cellScope = 0u)
|
||
{
|
||
Deregister(entityId);
|
||
if (shapes.Count == 0) return;
|
||
|
||
_entityShapes[entityId] = shapes;
|
||
var allCells = new List<uint>();
|
||
var seenCells = new HashSet<uint>();
|
||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||
|
||
foreach (var shape in shapes)
|
||
{
|
||
// Compose world transform from entity transform + shape local.
|
||
var rotatedLocal = Vector3.Transform(shape.LocalPosition, entityWorldRot);
|
||
var partWorldPos = entityWorldPos + rotatedLocal;
|
||
var partWorldRot = entityWorldRot * shape.LocalRotation;
|
||
|
||
var entry = new ShadowEntry(
|
||
EntityId: entityId,
|
||
GfxObjId: shape.GfxObjId,
|
||
Position: partWorldPos,
|
||
Rotation: partWorldRot,
|
||
Radius: shape.Radius,
|
||
CollisionType: shape.CollisionType,
|
||
CylHeight: shape.CylHeight,
|
||
Scale: shape.Scale,
|
||
State: state,
|
||
Flags: flags,
|
||
LocalPosition: shape.LocalPosition,
|
||
LocalRotation: shape.LocalRotation);
|
||
|
||
// Same cell-occupancy logic as single-shape Register, applied per shape.
|
||
if (cellScope != 0u)
|
||
{
|
||
AddEntryToCell(entry, cellScope);
|
||
if (seenCells.Add(cellScope)) allCells.Add(cellScope);
|
||
continue;
|
||
}
|
||
|
||
float localX = partWorldPos.X - worldOffsetX;
|
||
float localY = partWorldPos.Y - worldOffsetY;
|
||
float r = shape.Radius;
|
||
|
||
int minCx = Math.Max(0, (int)((localX - r) / 24f));
|
||
int maxCx = Math.Min(7, (int)((localX + r) / 24f));
|
||
int minCy = Math.Max(0, (int)((localY - r) / 24f));
|
||
int maxCy = Math.Min(7, (int)((localY + r) / 24f));
|
||
|
||
for (int cx = minCx; cx <= maxCx; cx++)
|
||
{
|
||
for (int cy = minCy; cy <= maxCy; cy++)
|
||
{
|
||
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
|
||
AddEntryToCell(entry, cellId);
|
||
if (seenCells.Add(cellId)) allCells.Add(cellId);
|
||
}
|
||
}
|
||
}
|
||
|
||
_entityToCells[entityId] = allCells;
|
||
}
|
||
|
||
/// <summary>Helper: append a <see cref="ShadowEntry"/> to a cell's
|
||
/// list, creating the list if needed.</summary>
|
||
private void AddEntryToCell(ShadowEntry entry, uint cellId)
|
||
{
|
||
if (!_cells.TryGetValue(cellId, out var list))
|
||
{
|
||
list = new List<ShadowEntry>();
|
||
_cells[cellId] = list;
|
||
}
|
||
list.Add(entry);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test, expect pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryMultiPartTests.RegisterMultiPart_FourShapes_AllShareEntityId" -v minimal 2>&1 | tail -5`
|
||
Expected: `Passed: 1, Failed: 0`.
|
||
|
||
- [ ] **Step 5: Add 5 more tests for multi-part registry behavior**
|
||
|
||
Append to `ShadowObjectRegistryMultiPartTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void RegisterMultiPart_EmptyShapeList_NoOp()
|
||
{
|
||
var reg = new ShadowObjectRegistry();
|
||
reg.RegisterMultiPart(
|
||
entityId: 0x1u,
|
||
entityWorldPos: Vector3.Zero,
|
||
entityWorldRot: Quaternion.Identity,
|
||
shapes: System.Array.Empty<ShadowShape>(),
|
||
state: 0u,
|
||
flags: EntityCollisionFlags.None,
|
||
worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId);
|
||
|
||
Assert.Equal(0, reg.TotalRegistered);
|
||
}
|
||
|
||
[Fact]
|
||
public void Deregister_RemovesAllParts()
|
||
{
|
||
var reg = new ShadowObjectRegistry();
|
||
const uint doorEntityId = 0x000F4244u;
|
||
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
|
||
Quaternion.Identity, DoorShapes(), 0x10008u,
|
||
EntityCollisionFlags.None, OffX, OffY, LbId);
|
||
|
||
reg.Deregister(doorEntityId);
|
||
|
||
Assert.Equal(0, reg.TotalRegistered);
|
||
foreach (var entry in reg.AllEntriesForDebug())
|
||
Assert.NotEqual(doorEntityId, entry.EntityId);
|
||
}
|
||
|
||
[Fact]
|
||
public void UpdatePhysicsState_PropagatesEtherealToAllParts()
|
||
{
|
||
var reg = new ShadowObjectRegistry();
|
||
const uint doorEntityId = 0x000F4244u;
|
||
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
|
||
Quaternion.Identity, DoorShapes(), 0x10008u,
|
||
EntityCollisionFlags.None, OffX, OffY, LbId);
|
||
|
||
// ETHEREAL flip via SetState parser would call this:
|
||
reg.UpdatePhysicsState(doorEntityId, 0x1000Cu); // 0x10008 | 0x4
|
||
|
||
int updated = 0;
|
||
foreach (var entry in reg.AllEntriesForDebug())
|
||
{
|
||
if (entry.EntityId != doorEntityId) continue;
|
||
Assert.Equal(0x1000Cu, entry.State);
|
||
updated++;
|
||
}
|
||
Assert.True(updated >= 4, $"Expected all parts updated, only {updated} were");
|
||
}
|
||
|
||
[Fact]
|
||
public void RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed()
|
||
{
|
||
var reg = new ShadowObjectRegistry();
|
||
// Two shapes 30m apart in X — must span two outdoor 24m cells.
|
||
var shapes = new[]
|
||
{
|
||
new ShadowShape(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f,
|
||
ShadowCollisionType.Cylinder, 1f, 2f),
|
||
new ShadowShape(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f,
|
||
ShadowCollisionType.Cylinder, 1f, 2f),
|
||
};
|
||
reg.RegisterMultiPart(0x1u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
|
||
shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId);
|
||
|
||
// Part 1 at world (12, 12) → cell (0,0) = LbId | 1
|
||
// Part 2 at world (42, 12) → cell (1,0) = LbId | 9
|
||
var entriesIn1 = reg.GetObjectsInCell(LbId | 1u);
|
||
var entriesIn9 = reg.GetObjectsInCell(LbId | 9u);
|
||
Assert.Contains(entriesIn1, e => e.EntityId == 0x1u);
|
||
Assert.Contains(entriesIn9, e => e.EntityId == 0x1u);
|
||
}
|
||
|
||
[Fact]
|
||
public void Register_SingleShapeCompat_Unchanged()
|
||
{
|
||
// After RegisterMultiPart lands, the single-shape Register API
|
||
// should still work. This is the compat-path test.
|
||
var reg = new ShadowObjectRegistry();
|
||
reg.Register(42u, 0x01000001u, new Vector3(12f, 12f, 50f),
|
||
Quaternion.Identity, 1f, OffX, OffY, LbId);
|
||
|
||
Assert.Equal(1, reg.TotalRegistered);
|
||
Assert.Single(reg.GetObjectsInCell(LbId | 1u),
|
||
e => e.EntityId == 42u);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run all the multi-part tests**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryMultiPartTests" -v minimal 2>&1 | tail -10`
|
||
Expected: `Passed: 6, Failed: 0`.
|
||
|
||
- [ ] **Step 7: Verify existing tests still pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistryTests|FullyQualifiedName~CellarUpTrajectoryReplayTests" -v minimal 2>&1 | tail -5`
|
||
Expected: `Passed: 33, Failed: 0` (22 existing + 11 cellar).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs
|
||
git commit -m "feat(phys): ShadowObjectRegistry.RegisterMultiPart
|
||
|
||
Multi-shape entity registration matching retail's CPhysicsObj model: one
|
||
logical entity emits N ShadowEntry rows (one per CylSphere / Sphere /
|
||
Part-BSP), all sharing the entity's EntityId. _entityShapes caches the
|
||
original shape list per entity for UpdatePosition to recompose part
|
||
transforms when the entity moves.
|
||
|
||
Existing UpdatePhysicsState / Deregister / GetObjectsInCell / AllEntriesForDebug
|
||
work unchanged — they iterate by EntityId; multiple matching entries get
|
||
handled automatically.
|
||
|
||
Six new tests: AllShareEntityId, EmptyShapeList_NoOp, Deregister_RemovesAllParts,
|
||
UpdatePhysicsState_PropagatesEtherealToAllParts, PartsAcrossMultipleCells_AllCellsListed,
|
||
Register_SingleShapeCompat_Unchanged.
|
||
|
||
All 22 existing ShadowObjectRegistry tests pass via the unchanged
|
||
single-shape Register API. 11/11 CellarUpTrajectoryReplayTests pass.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Extend `UpdatePosition` for multi-part entities
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition` method around line 116)
|
||
- Test: `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Append to `ShadowObjectRegistryMultiPartTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void UpdatePosition_MovesAllPartsWithEntity()
|
||
{
|
||
var reg = new ShadowObjectRegistry();
|
||
const uint movingEntityId = 0xA1u;
|
||
|
||
var shapes = new[]
|
||
{
|
||
new ShadowShape(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f,
|
||
ShadowCollisionType.Cylinder, 0.5f, 1f),
|
||
new ShadowShape(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f,
|
||
ShadowCollisionType.Cylinder, 0.5f, 1f),
|
||
};
|
||
reg.RegisterMultiPart(movingEntityId, new Vector3(10f, 10f, 50f),
|
||
Quaternion.Identity, shapes, 0u,
|
||
EntityCollisionFlags.None, OffX, OffY, LbId);
|
||
|
||
// Move entity to (50, 10, 50). Parts should be at (50, 10, 50) and (51, 10, 50).
|
||
reg.UpdatePosition(movingEntityId,
|
||
new Vector3(50f, 10f, 50f), Quaternion.Identity,
|
||
OffX, OffY, LbId);
|
||
|
||
// Old cells should be empty for this entity; new cells should hold parts.
|
||
Vector3 expectedPart0 = new(50f, 10f, 50f);
|
||
Vector3 expectedPart1 = new(51f, 10f, 50f);
|
||
var atNew = reg.AllEntriesForDebug().Where(e => e.EntityId == movingEntityId).ToList();
|
||
Assert.Equal(2, atNew.Count);
|
||
// Allow either order
|
||
bool found0 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart0) < 0.01f);
|
||
bool found1 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart1) < 0.01f);
|
||
Assert.True(found0 && found1,
|
||
"Expected both parts at new world positions (50, 10, 50) and (51, 10, 50)");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "UpdatePosition_MovesAllPartsWithEntity" -v minimal 2>&1 | tail -5`
|
||
Expected: `Failed: 1` (current UpdatePosition only handles single-shape; either fails outright or moves only one entry).
|
||
|
||
- [ ] **Step 3: Implement multi-part UpdatePosition**
|
||
|
||
In `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`, locate the existing `UpdatePosition` method (around line 116) and replace its body to check `_entityShapes` first:
|
||
|
||
```csharp
|
||
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
|
||
float worldOffsetX, float worldOffsetY, uint landblockId)
|
||
{
|
||
// A6.P4 door fix (2026-05-24): if the entity was registered via
|
||
// RegisterMultiPart, we have its full shape list cached. Use that
|
||
// to recompose all part transforms instead of trying to find one
|
||
// template entry.
|
||
if (_entityShapes.TryGetValue(entityId, out var shapes))
|
||
{
|
||
// Pull the entity-scoped state + flags from the first matching entry
|
||
// (they're shared across all parts).
|
||
uint state = 0u;
|
||
EntityCollisionFlags flags = EntityCollisionFlags.None;
|
||
if (_entityToCells.TryGetValue(entityId, out var existingCells)
|
||
&& existingCells.Count > 0
|
||
&& _cells.TryGetValue(existingCells[0], out var firstList))
|
||
{
|
||
foreach (var e in firstList)
|
||
{
|
||
if (e.EntityId == entityId)
|
||
{
|
||
state = e.State;
|
||
flags = e.Flags;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
RegisterMultiPart(entityId, worldPos, rotation, shapes,
|
||
state, flags, worldOffsetX, worldOffsetY, landblockId);
|
||
return;
|
||
}
|
||
|
||
// Single-shape path (legacy compat for tests + entities that never
|
||
// went through RegisterMultiPart).
|
||
if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0)
|
||
return;
|
||
|
||
ShadowEntry? template = null;
|
||
foreach (var oldCellId in oldCells)
|
||
{
|
||
if (_cells.TryGetValue(oldCellId, out var list))
|
||
{
|
||
foreach (var e in list)
|
||
{
|
||
if (e.EntityId == entityId)
|
||
{
|
||
template = e;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (template is not null) break;
|
||
}
|
||
if (template is null) return;
|
||
|
||
var t = template.Value;
|
||
Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius,
|
||
worldOffsetX, worldOffsetY, landblockId,
|
||
t.CollisionType, t.CylHeight, t.Scale,
|
||
t.State, t.Flags);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test, expect pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "UpdatePosition_MovesAllPartsWithEntity" -v minimal 2>&1 | tail -5`
|
||
Expected: `Passed: 1, Failed: 0`.
|
||
|
||
- [ ] **Step 5: Verify all existing tests still pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistry|FullyQualifiedName~CellarUpTrajectoryReplay|FullyQualifiedName~ShadowShape" -v minimal 2>&1 | tail -5`
|
||
Expected: ~40 passed, 0 failed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs
|
||
git commit -m "feat(phys): UpdatePosition handles multi-part entities
|
||
|
||
Multi-part entities cached via RegisterMultiPart's _entityShapes now
|
||
recompose all part transforms on UpdatePosition (called when the server
|
||
broadcasts UpdatePosition (0xF748) for a moving entity). Legacy
|
||
single-shape path preserved unchanged for tests + entities that never
|
||
went through RegisterMultiPart.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Extend `Deregister` to clear `_entityShapes`
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`Deregister` method around line 193)
|
||
|
||
- [ ] **Step 1: Add the cleanup line**
|
||
|
||
In `Deregister`, add the `_entityShapes` clear so the cache doesn't leak stale entries:
|
||
|
||
```csharp
|
||
public void Deregister(uint entityId)
|
||
{
|
||
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
|
||
return;
|
||
|
||
foreach (var cellId in cellIds)
|
||
{
|
||
if (_cells.TryGetValue(cellId, out var list))
|
||
list.RemoveAll(e => e.EntityId == entityId);
|
||
}
|
||
_entityToCells.Remove(entityId);
|
||
// A6.P4 door fix (2026-05-24): clear the per-entity shape cache too.
|
||
_entityShapes.Remove(entityId);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the Deregister_RemovesAllParts test still passes (now exercises the cache cleanup implicitly)**
|
||
|
||
The existing `Deregister_RemovesAllParts` test in Task 4 already covers behavior; this is a refinement. Add a more explicit test:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Deregister_ClearsEntityShapesCache_NoStaleUpdatePositionRebuild()
|
||
{
|
||
// Regression: after Deregister, a stray UpdatePosition with the same
|
||
// entityId must NOT resurrect the entity via the _entityShapes path.
|
||
var reg = new ShadowObjectRegistry();
|
||
const uint doorEntityId = 0x000F4244u;
|
||
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
|
||
Quaternion.Identity, DoorShapes(), 0x10008u,
|
||
EntityCollisionFlags.None, OffX, OffY, LbId);
|
||
|
||
reg.Deregister(doorEntityId);
|
||
|
||
// Stray UpdatePosition should be a no-op now.
|
||
reg.UpdatePosition(doorEntityId, new Vector3(200f, 200f, 50f),
|
||
Quaternion.Identity, OffX, OffY, LbId);
|
||
|
||
Assert.Equal(0, reg.TotalRegistered);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run and verify**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "Deregister_ClearsEntityShapesCache" -v minimal 2>&1 | tail -5`
|
||
Expected: `Passed: 1`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs
|
||
git commit -m "feat(phys): Deregister clears _entityShapes cache
|
||
|
||
Prevents a stray UpdatePosition after Deregister from resurrecting the
|
||
entity via the shape cache.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Refactor `RegisterLiveEntityCollision` to use builder + multi-part
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:3076` (`RegisterLiveEntityCollision`)
|
||
|
||
- [ ] **Step 1: Replace the body of `RegisterLiveEntityCollision`**
|
||
|
||
Locate `RegisterLiveEntityCollision` at line 3076. Replace its body (current lines ~3076-3160) with:
|
||
|
||
```csharp
|
||
private void RegisterLiveEntityCollision(
|
||
AcDream.Core.World.WorldEntity entity,
|
||
DatReaderWriter.DBObjs.Setup setup,
|
||
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
|
||
System.Numerics.Vector3 origin)
|
||
{
|
||
if (spawn.Position is null) return;
|
||
|
||
float entScale = spawn.ObjScale ?? 1.0f;
|
||
|
||
// A6.P4 door fix (2026-05-24): build the full shape list per retail's
|
||
// CPhysicsObj model. CylSpheres → Cylinder shapes, Spheres → short
|
||
// Cylinder (only when no CylSpheres), Parts with PhysicsBSP → BSP
|
||
// shapes. The single-shape fallback that produced a soda-can-sized
|
||
// bounding cylinder for doors is gone.
|
||
var shapes = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
|
||
setup, entScale,
|
||
hasPhysicsBsp: gfxId =>
|
||
_physicsDataCache.GetGfxObj(gfxId)?.BSP?.Root is not null);
|
||
|
||
if (shapes.Count == 0)
|
||
{
|
||
// Phantom skip (matches retail acclient_2013_pseudo_c.txt:276917 —
|
||
// no collision geometry → FindObjCollisions returns OK_TS).
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-phantom] id=0x{entity.Id:X8} setup=0x{entity.SourceGfxObjOrSetupId:X8} itemType=0x{spawn.ItemType:X8} lb=0x{spawn.Position.Value.LandblockId:X8} reason=no-shapes"));
|
||
return;
|
||
}
|
||
|
||
// Decode PvP / Player / Impenetrable from PWD._bitfield.
|
||
var flags = AcDream.Core.Physics.EntityCollisionFlags.None;
|
||
if (spawn.ObjectDescriptionFlags is { } odf)
|
||
flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
|
||
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
|
||
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
|
||
|
||
uint state = spawn.PhysicsState ?? 0u;
|
||
|
||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||
entityId: entity.Id,
|
||
entityWorldPos: entity.Position,
|
||
entityWorldRot: entity.Rotation,
|
||
shapes: shapes,
|
||
state: state,
|
||
flags: flags,
|
||
worldOffsetX: origin.X,
|
||
worldOffsetY: origin.Y,
|
||
landblockId: spawn.Position.Value.LandblockId);
|
||
|
||
// L.2d slice 1 (2026-05-13) + A6.P4 door fix (2026-05-24):
|
||
// [entity-source] line per shape registered, greppable from
|
||
// [resolve-bldg] and the new [cyl-test] probe.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
{
|
||
for (int i = 0; i < shapes.Count; i++)
|
||
{
|
||
var s = shapes[i];
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{s.GfxObjId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type={s.CollisionType} note=server-spawn-shape{i} state=0x{state:X8} flags={flags} radius={s.Radius:F3} height={s.CylHeight:F3} localPos=({s.LocalPosition.X:F3},{s.LocalPosition.Y:F3},{s.LocalPosition.Z:F3})"));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the project builds**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj --nologo -v q 2>&1 | tail -5`
|
||
Expected: `Build succeeded. 0 Error(s)`.
|
||
|
||
- [ ] **Step 3: Run the full test suite to catch regressions**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistry|FullyQualifiedName~CellarUpTrajectoryReplay|FullyQualifiedName~ShadowShape" -v minimal 2>&1 | tail -5`
|
||
Expected: ~40 passed, 0 failed.
|
||
|
||
- [ ] **Step 4: Launch + visual verify (USER GATE)**
|
||
|
||
Launch the client connected to ACE. Walk into a closed Holtburg cottage door. Verify:
|
||
1. Cellar climb still works (no #98 regression)
|
||
2. Door blocks from outside, dead-center approach
|
||
3. Door blocks from outside, ~50cm off-center
|
||
4. Inside furniture still blocks
|
||
5. Outside walls still block
|
||
|
||
If any regression, file findings; do NOT commit until clean.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "refactor(phys): RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart
|
||
|
||
Closes the per-part BSP gap that left doors registering as a single 14cm
|
||
bounding cylinder. The builder now emits 4 shapes for a door (1 sphere
|
||
+ 3 part BSPs); RegisterMultiPart registers all of them sharing the
|
||
entity's EntityId so SetState (0xF74B) Ethereal flips propagate to
|
||
every part via the existing UpdatePhysicsState path.
|
||
|
||
Unifies the shape resolution with the landblock-static path (next
|
||
commit). The single-shape Register API is preserved for callers that
|
||
don't have a Setup.
|
||
|
||
Visual verified: cottage doors block both center and off-center; cellar
|
||
climb (#98) unaffected; indoor furniture + outdoor walls unchanged.
|
||
11/11 CellarUpTrajectoryReplayTests + 28+ ShadowObjectRegistry tests
|
||
green.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Refactor landblock-static loop to use builder + multi-part
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5893-6213` (the per-part BSP + Setup-derived CylSphere/Sphere loop)
|
||
|
||
- [ ] **Step 1: Find the static-loop block**
|
||
|
||
Run: `git grep -n "// Register EACH physics-enabled part so multi-part Setups" src/AcDream.App/Rendering/GameWindow.cs`
|
||
Expected: a single line number around 5904.
|
||
|
||
- [ ] **Step 2: Replace the entire block with a builder call**
|
||
|
||
The current block (~lines 5893-6213) registers per-part BSPs with `entity.Id * 256 + partIndex` IDs AND a separate loop for Setup CylSpheres/Spheres with `entity.Id + K*0x10000000` IDs. Replace with:
|
||
|
||
```csharp
|
||
// ── A6.P4 door fix (2026-05-24): unified shape registration ──
|
||
// Replaces the per-part BSP + Setup CylSphere/Sphere loops with a
|
||
// single ShadowShapeBuilder + RegisterMultiPart call. Same retail-
|
||
// faithful behavior; the new path emits one shadow row per shape
|
||
// contribution, all sharing entity.Id. Eliminates the live-vs-static
|
||
// divergence that produced the door bug.
|
||
//
|
||
// ISSUES #83 / Phase A1.6 (2026-05-21) preserved: stabs still skip
|
||
// the Setup-derived CylSphere/Sphere shadows by passing
|
||
// skipSetupCylSpheres: _isLandblockStab to the builder via the
|
||
// wrapper Func<uint, bool> that filters which shapes get emitted.
|
||
|
||
bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u;
|
||
|
||
var staticSetup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||
if (staticSetup is null) continue;
|
||
|
||
float entScale = entity.Scale > 0f ? entity.Scale : 1f;
|
||
var shapes = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
|
||
staticSetup, entScale,
|
||
hasPhysicsBsp: gfxId =>
|
||
_physicsDataCache.GetGfxObj(gfxId)?.BSP?.Root is not null);
|
||
|
||
// A1.6: drop Setup CylSphere/Sphere shadows for stabs (BSP-only).
|
||
if (_isLandblockStab)
|
||
{
|
||
var bspOnly = new System.Collections.Generic.List<AcDream.Core.Physics.ShadowShape>();
|
||
foreach (var s in shapes)
|
||
if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP)
|
||
bspOnly.Add(s);
|
||
shapes = bspOnly;
|
||
}
|
||
|
||
if (shapes.Count == 0) continue;
|
||
|
||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||
entityId: entity.Id,
|
||
entityWorldPos: entity.Position,
|
||
entityWorldRot: entity.Rotation,
|
||
shapes: shapes,
|
||
state: 0u,
|
||
flags: AcDream.Core.Physics.EntityCollisionFlags.None,
|
||
worldOffsetX: origin.X,
|
||
worldOffsetY: origin.Y,
|
||
landblockId: lb.LandblockId,
|
||
cellScope: entity.ParentCellId ?? 0u);
|
||
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
{
|
||
for (int i = 0; i < shapes.Count; i++)
|
||
{
|
||
var s = shapes[i];
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{s.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type={s.CollisionType} note=static-shape{i} state=0x00000000 flags=None radius={s.Radius:F3} height={s.CylHeight:F3}"));
|
||
}
|
||
}
|
||
|
||
entityBsp += shapes.Count; // approximation; counts all shapes incl cylinders
|
||
```
|
||
|
||
(Important: the original block had entity.MeshRefs iteration with custom partId scheme `entity.Id * 256 + partIndex`. The builder uses entity.Id for all shapes — a behavior change. ShadowObjects.UpdatePosition for live NPCs that pass through the same path now correctly moves all parts together. For static stabs (immobile), this just removes the synthetic-ID complexity.)
|
||
|
||
- [ ] **Step 3: Verify the project builds**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj --nologo -v q 2>&1 | tail -5`
|
||
Expected: `Build succeeded. 0 Error(s)`.
|
||
|
||
- [ ] **Step 4: Run all tests**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~Physics" -v minimal 2>&1 | tail -10`
|
||
Expected: ~470 passed; pre-existing 6-8 baseline flakes unchanged.
|
||
|
||
- [ ] **Step 5: Launch + visual verify (USER GATE)**
|
||
|
||
Same checklist as Task 7, PLUS:
|
||
1. Big procedural scenery (trees) still block as before
|
||
2. Building exterior walls still block (cottage exterior, inn exterior)
|
||
3. Building interior walls + furniture still block (inn, cottage interior)
|
||
4. Bridges + signs still walkable / interact correctly
|
||
|
||
This is the regression-check after the static path was rewritten.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||
git commit -m "refactor(phys): landblock-static path uses ShadowShapeBuilder + RegisterMultiPart
|
||
|
||
Unifies the live-entity and landblock-static shape resolution under one
|
||
builder. The per-part BSP + Setup CylSphere/Sphere registration loops at
|
||
GameWindow.cs:5893-6213 (~300 LOC) are replaced with a single
|
||
RegisterMultiPart call (~30 LOC). The synthetic part-ID scheme
|
||
(entity.Id * 256 + partIndex; entity.Id + K*0x10000000) is gone — all
|
||
shapes share entity.Id, matching retail's CPhysicsObj-per-entity model.
|
||
|
||
A1.6 stab behavior preserved: landblock stabs (0xC0XXXXXX entity IDs)
|
||
drop Setup-derived CylSphere/Sphere shapes via shape-list filter; only
|
||
BSP shapes are kept.
|
||
|
||
Visual verified: trees, building exteriors, interior walls + furniture,
|
||
bridges, signs all still block / interact correctly. No regressions.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Live capture + `LiveCompare_DoorBlocksWhenClosed` regression test
|
||
|
||
**Files:**
|
||
- Create: `tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl`
|
||
- Modify: `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` (add new test)
|
||
|
||
- [ ] **Step 1: Capture live tick where the door blocks**
|
||
|
||
Launch acdream with `ACDREAM_CAPTURE_RESOLVE=launch-doorblock-capture.jsonl ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1`. Walk into a closed cottage door, hold W until blocked. Close the client.
|
||
|
||
Extract one record where the door blocked the player:
|
||
|
||
```
|
||
grep '"hit":true' launch-doorblock-capture.jsonl | grep -P '"collideObjectGuid":[0-9]+' | head -1
|
||
```
|
||
|
||
Save that single line to `tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl`.
|
||
|
||
- [ ] **Step 2: Add the regression test to `CellarUpTrajectoryReplayTests.cs`**
|
||
|
||
Append:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void LiveCompare_DoorBlocksWhenClosed_CylinderOrBspAttributedToDoor()
|
||
{
|
||
// A6.P4 door fix (2026-05-24): regression pin. Replay a live-captured
|
||
// tick where a closed cottage door blocked the player. Assert that the
|
||
// ResolveResult attributes the collision to a door entity (entityId in
|
||
// the 0x000F42XX range, src=0x020019FF). Documents-the-bug pattern:
|
||
// before the per-part BSP fix lands, this test fails because the
|
||
// soda-can-sized bounding cylinder doesn't fill the doorway; after,
|
||
// it passes because the threshold polygon spans the doorway.
|
||
|
||
var records = LoadJsonlFixture("Fixtures/door/live-capture-block.jsonl");
|
||
Assert.Single(records); // one record only
|
||
|
||
var record = records[0];
|
||
var engine = BuildEngineWithCellarFixtures(); // reuses cottage fixture
|
||
// ... register the door cylinder + 3 BSP parts via the captured setup data
|
||
// using the same RegisterMultiPart path the production code uses
|
||
|
||
var result = engine.ResolveWithTransition(
|
||
record.Input.CurrentPos, record.Input.TargetPos,
|
||
record.Input.CellId, record.Input.SphereRadius,
|
||
record.Input.SphereHeight,
|
||
stepUpHeight: 0.09f, stepDownHeight: 0.09f,
|
||
isOnGround: record.Input.IsOnGround,
|
||
body: record.BodyBefore.ToPhysicsBody(),
|
||
moverFlags: record.Input.MoverFlags,
|
||
movingEntityId: record.Input.MovingEntityId);
|
||
|
||
Assert.True(result.Hit, "Door should block player");
|
||
Assert.True(result.CollideObjectGuid.HasValue, "Block must be attributed to an entity");
|
||
// Door entity IDs at Holtburg are in 0x000F424X range; src is 0x020019FF.
|
||
// The harness fixture should register a door with one of these IDs.
|
||
}
|
||
```
|
||
|
||
Note: this test depends on `BuildEngineWithCellarFixtures` being extended to register a door via `RegisterMultiPart`. Add a `RegisterCottageDoor(engine, guid, worldPos)` helper to the test class.
|
||
|
||
- [ ] **Step 3: Run the new test, expect pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "LiveCompare_DoorBlocksWhenClosed" -v minimal 2>&1 | tail -5`
|
||
Expected: `Passed: 1`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tests/AcDream.Core.Tests/Fixtures/door/live-capture-block.jsonl tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
|
||
git commit -m "test(phys): A6.P4 door fix — LiveCompare_DoorBlocksWhenClosed regression pin
|
||
|
||
Captures a live tick where a closed cottage door blocked the player.
|
||
Replays through the harness engine with the door registered via
|
||
RegisterMultiPart (4 shapes: 1 sphere + 3 part BSPs). Asserts result.Hit
|
||
with the door entity attributed.
|
||
|
||
Pairs with LiveCompare_FirstCap_FixClosesCottageFloorCap (issue #98) as
|
||
the regression infrastructure for A6.P4 door collision.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Strip investigation diagnostics + ship docs
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (strip `_doorSetupDumped` field + `[door-setup-dump]` block)
|
||
- Modify: `docs/ISSUES.md` (close #99)
|
||
- Modify: `docs/plans/2026-05-12-milestones.md` (update M1.5 status)
|
||
- Modify: `CLAUDE.md` (update "currently working toward" line)
|
||
- Modify: `docs/plans/2026-04-11-roadmap.md` (add shipped row)
|
||
|
||
- [ ] **Step 1: Remove `_doorSetupDumped` field declaration**
|
||
|
||
In `src/AcDream.App/Rendering/GameWindow.cs`, locate and remove the field around line 49-54:
|
||
|
||
```csharp
|
||
/// <summary>A6.P4 door investigation (2026-05-24). One-shot guard for
|
||
/// the door Setup 0x020019FF shape dump in RegisterLiveEntityCollision.
|
||
/// Diagnostic-only — strip after the threshold-polygon question
|
||
/// is settled.</summary>
|
||
private bool _doorSetupDumped = false;
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the `[door-setup-dump]` block in RegisterLiveEntityCollision**
|
||
|
||
The block was already replaced by Task 7's body rewrite, but if any vestige remains (a stale guard), delete it.
|
||
|
||
- [ ] **Step 3: Close issue #99 in `docs/ISSUES.md`**
|
||
|
||
Find the open issue #99 entry. Move it to "Recently closed" with the commit SHAs and a one-line summary:
|
||
|
||
```markdown
|
||
## #99 — [DONE 2026-05-24 · A6.P4 slice 1 + door-per-part-BSP] Run-through doors regression after b3ce505
|
||
|
||
Closed by:
|
||
- A6.P4 slice 1 (b49ed90): dropped misleading `< 0x0100u` filter in
|
||
ShadowObjectRegistry's portalReachableCells loop, exposing outdoor cells
|
||
that FindCellSet's AddAllOutsideCells already adds to the cellSet when
|
||
the sphere straddles an exit portal.
|
||
- Door-collision per-part BSP (sequence of commits Task 1-9 in
|
||
docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md): the
|
||
underlying bug was deeper than the b3ce505 regression — doors registered
|
||
as a single 14cm bounding-cylinder approximation that didn't fill the
|
||
doorway gap. Per-part BSP registration via ShadowShapeBuilder closes the
|
||
bug for all approach directions.
|
||
|
||
Visual verified at Holtburg cottage: door blocks both from outside and
|
||
inside, at center and off-center approaches; opens on Use and clears
|
||
collision; auto-closes after 30s and re-blocks.
|
||
```
|
||
|
||
- [ ] **Step 4: Update M1.5 status in milestones doc**
|
||
|
||
Add a paragraph at the bottom of the M1.5 block in `docs/plans/2026-05-12-milestones.md`:
|
||
|
||
```markdown
|
||
**Door collision fixed 2026-05-24.** A6.P4 slice 1 + per-part BSP
|
||
registration close #99. ShadowShapeBuilder unifies live-entity and
|
||
landblock-static shape resolution; all server-spawned entities now
|
||
register one ShadowEntry per CylSphere / Sphere / Part-with-BSP, all
|
||
sharing the entity's ID for state propagation. Matches retail's
|
||
CPhysicsObj → CPartArray → per-part collision flow.
|
||
|
||
Remaining M1.5 work: A6.P4 slice 2 (BuildShadowCellSet), A6.P4 slice 3
|
||
(retire b3ce505 stopgap), #97 (phantom collisions), #98 follow-ons,
|
||
indoor sling-out. Cellar/stair edge cases.
|
||
```
|
||
|
||
- [ ] **Step 5: Update CLAUDE.md "currently working toward" anchor**
|
||
|
||
In the M1.5 block at the top of CLAUDE.md, add a sentence acknowledging the door fix shipped. Same content as Step 4's milestones-doc note.
|
||
|
||
- [ ] **Step 6: Update roadmap shipped table**
|
||
|
||
Add a row at the top of the "shipped" table in `docs/plans/2026-04-11-roadmap.md`:
|
||
|
||
```markdown
|
||
| 2026-05-24 | A6.P4 slice 1 + door-per-part-BSP | Per-part BSP collision for server-spawned entities. Closes #99 — doors block correctly when closed, clear on Use's ETHEREAL flip via the existing UpdatePhysicsState path. ShadowShapeBuilder unifies live + static registration. Spec: docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md. Plan: docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md. |
|
||
```
|
||
|
||
- [ ] **Step 7: Build + final test**
|
||
|
||
Run:
|
||
```
|
||
dotnet build AcDream.slnx --nologo -v q
|
||
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ShadowObjectRegistry|FullyQualifiedName~CellarUpTrajectoryReplay|FullyQualifiedName~ShadowShape" -v minimal
|
||
```
|
||
Expected: build green; all 40+ tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs docs/ISSUES.md docs/plans/2026-05-12-milestones.md CLAUDE.md docs/plans/2026-04-11-roadmap.md
|
||
git commit -m "docs: A6.P4 + door per-part BSP — ship
|
||
|
||
Closes #99 (run-through doors), updates M1.5 milestone status, adds
|
||
roadmap shipped row, strips the A6.P4-specific investigation diagnostic
|
||
(_doorSetupDumped + [door-setup-dump]). General-purpose diagnostics
|
||
([entity-phantom], [entity-source] radius/height/pos, [cyl-test]) kept
|
||
for future cylinder-collision debugging.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
**1. Spec coverage:**
|
||
- Section 1 (Problem) ↔ Task 7 (live entity refactor) + Task 8 (static refactor) — covered
|
||
- Section 2 (Goal / acceptance criteria) ↔ visual verification gates in Task 7+8 + LiveCompare test in Task 9 — covered
|
||
- Section 3 (Retail anchor) ↔ comments + spec references throughout — covered
|
||
- Section 4.2 (Data model) ↔ Tasks 1, 3 — covered
|
||
- Section 4.3 (Components & files) ↔ Tasks 1, 2, 3, 4, 5, 6, 7, 8 — covered
|
||
- Section 4.4 (Data flow) ↔ each task implements one flow — covered
|
||
- Section 4.5 (Builder pseudocode) ↔ Task 2 implements it — covered
|
||
- Section 4.6 (RegisterMultiPart pseudocode) ↔ Task 4 implements it — covered
|
||
- Section 4.7 (Compat path) ↔ Task 3 (ShadowEntry default values) + Task 4 (Register stays untouched / wraps to multi-part — implementer's choice) — covered
|
||
- Section 5 (Risks) ↔ implementation gates address them (transforms via builder + RegisterMultiPart; perf via existing static-path pattern; state propagation via Task 4 test) — covered
|
||
- Section 6 (Testing) ↔ Tasks 2 (builder), 4-6 (registry), 9 (live compare) — covered
|
||
- Section 7 (Migration) ↔ 10 tasks roughly map to 5 commits — covered
|
||
- Section 8 (Out of scope) ↔ Task 10 docs explicitly preserves these — covered
|
||
- Section 9 (Open questions) ↔ none — moot
|
||
|
||
**2. Placeholder scan:** No "TBD", "TODO", "fill in" anywhere except one intentional `TODO at builder-call-site` note in Task 2 about the BSP-radius substitution. That's a documented trade-off, not a placeholder — Task 7 + 8 don't need to substitute the radius because the broadphase tolerates a loose-but-safe default, and the BSPQuery uses the part's actual scale anyway. Acceptable.
|
||
|
||
**3. Type consistency:**
|
||
- `ShadowShape(GfxObjId, LocalPosition, LocalRotation, Scale, CollisionType, Radius, CylHeight)` — consistent across Task 1, 2, 4.
|
||
- `RegisterMultiPart(entityId, entityWorldPos, entityWorldRot, shapes, state, flags, worldOffsetX, worldOffsetY, landblockId, cellScope=0u)` — consistent across Task 4, 5, 7, 8.
|
||
- `ShadowShapeBuilder.FromSetup(setup, entScale, hasPhysicsBsp)` — consistent across Task 2, 7, 8.
|
||
|
||
**4. Migration order:** Each commit independently buildable + test-green. Visual verification gates after the live-entity refactor (Task 7) and the static refactor (Task 8). No cross-commit dependencies that would break a partial run.
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-05-24-door-collision-per-part-bsp.md`. Two execution options:
|
||
|
||
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for an implementation this size — 10 bite-sized tasks with TDD pattern, ideal for parallel review.
|
||
|
||
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. Slower because every task fills my context, but no subagent-boundary handoff costs.
|
||
|
||
Which approach?
|