Captures the brainstorm session 2026-05-24 evening after A6.P4 slice 1
(b49ed90) shipped without closing #99. Investigation surfaced the actual
root cause: doors register as a single 14cm × 20cm bounding-cylinder
approximation derived from Setup.Radius/Height fallback. Their real
collision-bearing geometry lives in per-part GfxObj BSPs (3 parts for
Setup 0x020019FF), including the threshold polygon spanning the doorway.
Retail-faithful design: every server-spawned entity registers N shadow
entries (one per CylSphere + one per Sphere + one per Part-with-BSP),
all sharing the same EntityId. UpdatePhysicsState propagates ETHEREAL
flips to all entries via the existing EntityId-iteration path. Unifies
the live-entity and landblock-static registration code paths under one
ShadowShapeBuilder.
Retail anchor: CObjCell::find_obj_collisions → CPhysicsObj::FindObjCollisions
→ CPartArray::FindObjCollisions → CPhysicsPart::find_obj_collisions →
CGfxObj::find_obj_collisions. One PhysicsObj per entity, parts iterated
internally for collision (acclient_2013_pseudo_c.txt:276776-275055).
Five-commit migration sequence; tests at three layers (builder unit tests,
registry behavior tests, live-capture regression pin). Approach A approved
by user 2026-05-24.
Spec stands on its own as M1.5 work; not formally assigned a phase letter
per CLAUDE.md's "don't invent phase numbers on the fly" rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
553 lines
24 KiB
Markdown
553 lines
24 KiB
Markdown
# Per-part BSP collision for server-spawned entities — design
|
||
|
||
**Status:** Drafted 2026-05-24 evening. Ready to start once the user approves the
|
||
spec.
|
||
**Milestone:** M1.5 — "Indoor world feels right" (active).
|
||
**Predecessor work:**
|
||
- A6.P4 slice 1 (commit `b49ed90`) — dropped the misleading `< 0x0100u` filter
|
||
in `ShadowObjectRegistry.GetNearbyObjects` and renamed `indoorCellIds` →
|
||
`portalReachableCells`. Real cleanup; did not close #99 on its own.
|
||
- L.2g slice 1+1b+1c (2026-05-12/13) — wired `SetState (0xF74B)` → `UpdatePhysicsState`
|
||
→ `CollisionExemption.ShouldSkip` for ETHEREAL flip. State propagation works;
|
||
only the collision-shape registration is wrong.
|
||
**Closes:** #99 (run-through doors) — the part of it caused by the door registering
|
||
as a single 14 cm × 20 cm bounding-cylinder approximation that doesn't fill the
|
||
doorway gap.
|
||
**Out of scope (file follow-ups if surfaced):** A6.P4 slices 2-3 (BuildShadowCellSet,
|
||
b3ce505 stopgap retirement); issue #100 (transparent ground); reverse-portal-map.
|
||
|
||
---
|
||
|
||
## 1. Problem
|
||
|
||
Server-spawned entities (doors, chests, signs, NPCs, items) all flow through
|
||
`RegisterLiveEntityCollision` in `src/AcDream.App/Rendering/GameWindow.cs:3076`.
|
||
The function inspects `setup.CylSpheres`, `setup.Spheres`, and `setup.Radius`
|
||
and registers ONE `ShadowCollisionType.Cylinder` entry using whichever of the
|
||
three is present. Doors (Setup `0x020019FF`) have zero CylSpheres + one tiny
|
||
Sphere; the fallback picks `setup.Radius = 0.141 m` × `setup.Height = 0.200 m`.
|
||
That's a soda-can-sized cylinder at the doorway floor, easily walked around in
|
||
a 2 m doorway.
|
||
|
||
The L.2g 2026-05-12 design assumed Cylinder collision was retail's door
|
||
blocker. The trace it was based on showed the cylinder firing, but only when
|
||
the player approached dead-center. Off-center walks bypass it. The L.2g
|
||
visual verification was deferred and the assumption was never validated.
|
||
|
||
Retail's actual mechanism: the door's three `Setup.Parts` (GfxObjs
|
||
`0x010044B5`, `0x010044B6`, `0x010044B6`) each carry their own PhysicsBSP,
|
||
including the threshold polygon that spans the doorway. Retail's
|
||
`CPhysicsObj::FindObjCollisions` calls into `CPartArray::FindObjCollisions`
|
||
which iterates parts and dispatches to `CPhysicsPart::find_obj_collisions` →
|
||
`CGfxObj::find_obj_collisions` (per-part BSP test).
|
||
|
||
acdream has no equivalent path for live entities. Static landblock-baked
|
||
objects (`GameWindow.cs:5907+`) DO register per-part BSP shadows correctly, but
|
||
under a separate code path with different ID schemes, so doors don't benefit.
|
||
|
||
---
|
||
|
||
## 2. Goal
|
||
|
||
Register every server-spawned entity's collision shapes the way retail
|
||
`CPhysicsObj` does: one logical entity, multiple per-part collision
|
||
contributions (CylSpheres + Spheres + per-Part BSPs), all sharing the entity's
|
||
PhysicsState so `ETHEREAL` flips propagate everywhere. Unify the live-entity
|
||
and landblock-static registration paths around one shape-builder so the
|
||
divergence that caused this bug can't recur for other entity classes.
|
||
|
||
**Acceptance criteria:**
|
||
- Walking dead-center at a closed Holtburg cottage door: blocked.
|
||
- Walking ~50 cm off-center at the same door: still blocked (the threshold
|
||
polygon spans the gap).
|
||
- Clicking the door → swing animation → walking through: passes through.
|
||
- 30 s auto-close → re-blocked from both sides.
|
||
- `LiveCompare_FirstCap_FixClosesCottageFloorCap` (issue #98 regression pin)
|
||
still passes.
|
||
- 11/11 `CellarUpTrajectoryReplayTests` still pass.
|
||
- All 22 existing `ShadowObjectRegistryTests` still pass via the single-shape
|
||
compat path.
|
||
- New `ShadowShapeBuilderTests` (6+ cases) green.
|
||
- New `ShadowObjectRegistryMultiPartTests` (7+ cases) green.
|
||
- New `LiveCompare_DoorBlocksWhenClosed` live-capture regression pin: green
|
||
post-fix, documents-the-bug pre-fix.
|
||
|
||
---
|
||
|
||
## 3. Retail anchor
|
||
|
||
```
|
||
CObjCell::find_obj_collisions (acclient_2013_pseudo_c.txt:308916)
|
||
iterates this->shadow_object_list
|
||
→ CPhysicsObj::FindObjCollisions (276776)
|
||
→ CPartArray::FindObjCollisions (276961 → 286236)
|
||
→ CPhysicsPart::find_obj_collisions (286250 → 275045)
|
||
→ CGfxObj::find_obj_collisions (275055)
|
||
per-part BSP polygon test in part-local frame
|
||
```
|
||
|
||
One `CPhysicsObj` per entity. Parts iterated internally. Each part tests its
|
||
GfxObj BSP. State (`ETHEREAL_PS = 0x4`, etc.) lives on the PhysicsObj — one
|
||
state check at the top of `FindObjCollisions`, applies to all parts uniformly.
|
||
|
||
---
|
||
|
||
## 4. Design (Approach A — multi-entry per logical entity, same EntityId)
|
||
|
||
### 4.1 Inversion summary
|
||
|
||
**Today:**
|
||
|
||
```
|
||
RegisterLiveEntityCollision
|
||
pick ONE shape via the CylSphere → Sphere → Setup.Radius cascade
|
||
→ ShadowObjectRegistry.Register(entityId, shape) → 1 ShadowEntry
|
||
FindObjCollisions iterates that 1 entry. Cylinder math runs against the fallback.
|
||
```
|
||
|
||
**After A6.P4 door fix:**
|
||
|
||
```
|
||
RegisterLiveEntityCollision
|
||
→ shapes = ShadowShapeBuilder.FromSetup(setup, entScale, cache)
|
||
[returns N shapes: every CylSphere + every Sphere + every Part-with-BSP]
|
||
→ ShadowObjectRegistry.RegisterMultiPart(entityId, shapes, ...)
|
||
→ N ShadowEntry rows added to _cells, all sharing EntityId, each with its
|
||
LocalPosition/LocalRotation captured for UpdatePosition replay
|
||
FindObjCollisions iterates each row independently. Per-part BSP test for BSP rows;
|
||
Cylinder math for Cylinder rows. ETHEREAL flip propagates via UpdatePhysicsState
|
||
walking _entityToCells[entityId] and updating every entry with matching EntityId.
|
||
```
|
||
|
||
### 4.2 Data model
|
||
|
||
New record:
|
||
|
||
```csharp
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// One collision-bearing shape attached to a logical PhysicsObj.
|
||
/// Doors emit 3-4 shapes (CylSpheres + Spheres + Part-BSPs); creatures
|
||
/// may emit a few; simple items may emit one or zero.
|
||
/// Positions/rotations are LOCAL to the entity origin so UpdatePosition
|
||
/// can re-transform them when the entity moves.
|
||
/// </summary>
|
||
public readonly record struct ShadowShape(
|
||
uint GfxObjId,
|
||
Vector3 LocalPosition,
|
||
Quaternion LocalRotation,
|
||
float Scale,
|
||
ShadowCollisionType CollisionType,
|
||
float Radius,
|
||
float CylHeight);
|
||
```
|
||
|
||
`ShadowEntry` gains two fields:
|
||
|
||
```csharp
|
||
public readonly record struct ShadowEntry(
|
||
uint EntityId,
|
||
uint GfxObjId,
|
||
Vector3 Position, // world-space
|
||
Quaternion Rotation, // world-space
|
||
float Radius,
|
||
ShadowCollisionType CollisionType,
|
||
float CylHeight,
|
||
float Scale,
|
||
uint State,
|
||
EntityCollisionFlags Flags,
|
||
// NEW — needed by UpdatePosition to recompute Position/Rotation
|
||
Vector3 LocalPosition = default,
|
||
Quaternion LocalRotation = default);
|
||
```
|
||
|
||
`ShadowObjectRegistry` gains one private map:
|
||
|
||
```csharp
|
||
private readonly Dictionary<uint, IReadOnlyList<ShadowShape>> _entityShapes = new();
|
||
```
|
||
|
||
Stores the original shape list per EntityId so UpdatePosition can rebuild
|
||
ShadowEntries when the entity moves. Cleared by Deregister.
|
||
|
||
`_cells` and `_entityToCells` are unchanged in structure — `_entityToCells[entityId]`
|
||
just holds more cells when an entity's parts span multiple landcells.
|
||
|
||
### 4.3 Components & files
|
||
|
||
| File | Action | Approx LOC |
|
||
|---|---|---|
|
||
| `src/AcDream.Core/Physics/ShadowShape.cs` | **NEW** — record (Section 4.2) | ~25 |
|
||
| `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | **NEW** — `FromSetup(Setup, float entScale, PhysicsDataCache cache) → IReadOnlyList<ShadowShape>`. Walks CylSpheres → Spheres → Parts. For each Part, look up its GfxObj; only emit a BSP shape if `cache.GetGfxObj(partId)?.BSP?.Root` is non-null. Pure function. | ~80 |
|
||
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | Add `RegisterMultiPart`. `Register` (single-shape API) becomes a 1-element wrapper calling `RegisterMultiPart`. Add `_entityShapes` map. Extend `UpdatePosition` to re-transform shapes from `_entityShapes`. `UpdatePhysicsState` and `Deregister` unchanged in behavior (already iterate by EntityId; Deregister now clears `_entityShapes[entityId]` too). | net +~80 |
|
||
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (ShadowEntry struct) | Add `LocalPosition` + `LocalRotation` (default-value-compat). | ~6 |
|
||
| `src/AcDream.App/Rendering/GameWindow.cs:3076` (`RegisterLiveEntityCollision`) | Replace body: build shapes via `ShadowShapeBuilder.FromSetup`, call `RegisterMultiPart`. Remove the CylSphere/Sphere/Radius cascade — now in the builder. | -50 / +25 |
|
||
| `src/AcDream.App/Rendering/GameWindow.cs:5907+` (static landblock per-part loop) | Replace with a single `RegisterMultiPart` call using `ShadowShapeBuilder.FromSetup`. Unifies live + static. | -300 / +30 |
|
||
| `tests/AcDream.Core.Tests/Fixtures/door/0x020019FF.setup.json` | **NEW** fixture — dumped via extended `ACDREAM_DUMP_SETUPS=0x020019FF`. | n/a |
|
||
| `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` | **NEW** — 6+ tests (see §6). | ~100 |
|
||
| `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` | **NEW** — 7+ tests (see §6). | ~150 |
|
||
| `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` | **NEW** test `LiveCompare_DoorBlocksWhenClosed` from a live capture. | ~80 |
|
||
| Existing `ShadowObjectRegistryTests` (22 tests) | Audit. Verify all pass unchanged via the compat path. | 0 |
|
||
|
||
Net: ~500 new LOC, ~350 deleted from `GameWindow.cs`. Ownership shifts to
|
||
`AcDream.Core.Physics` per code-structure rule #1 in CLAUDE.md.
|
||
|
||
### 4.4 Data flow (four critical scenarios)
|
||
|
||
**4.4.a Door spawn**
|
||
```
|
||
CreateObject(0xF745) guid=0x7A9B4015 setup=0x020019FF pos=(132.6,17.1,94.1)
|
||
→ GameWindow.OnEntitySpawn
|
||
→ RegisterLiveEntityCollision
|
||
→ shapes = ShadowShapeBuilder.FromSetup(setup, 1.0, _physicsDataCache)
|
||
returns [
|
||
ShadowShape(0x020019FF, (0,0,0.018), Identity, 1.0, Sphere/Cyl-approx, r=0.100, h=0.200),
|
||
ShadowShape(0x010044B5, frame[0].Pos, frame[0].Rot, 1.0, BSP, r=bspR0, h=0),
|
||
ShadowShape(0x010044B6, frame[1].Pos, frame[1].Rot, 1.0, BSP, r=bspR1, h=0),
|
||
ShadowShape(0x010044B6, frame[2].Pos, frame[2].Rot, 1.0, BSP, r=bspR2, h=0)
|
||
]
|
||
(CylSpheres list empty → no cylinder shapes; if any existed they'd be emitted first.)
|
||
→ RegisterMultiPart(entityId=0x000F4244, worldPos, worldRot, shapes, state=0x10008, ...)
|
||
→ 4 ShadowEntry rows in _cells, all EntityId=0x000F4244
|
||
→ _entityShapes[0x000F4244] = shapes
|
||
→ _entityToCells[0x000F4244] = [all cells touched by the 4 entries, deduped]
|
||
```
|
||
|
||
**4.4.b Collision check (player walks into closed door)**
|
||
```
|
||
PhysicsEngine.ResolveWithTransition
|
||
→ Transition.FindObjCollisions
|
||
→ CellTransit.FindCellSet → portalReachableCells
|
||
→ ShadowObjects.GetNearbyObjects → returns 4 entries for door
|
||
→ for each entry:
|
||
- CollisionExemption.ShouldSkip(state=0x10008, ...) → not ethereal → proceed
|
||
- if entry.CollisionType == BSP:
|
||
BSPQuery.FindCollisions(gfxObjId BSP, sphere transformed to entry's local space)
|
||
→ threshold polygon hit → Collided/Adjusted/Slid → CollideObjectGuids += entityId
|
||
else Cylinder:
|
||
CylinderCollision(entry, sp) (existing math)
|
||
```
|
||
|
||
Threshold polygon lives in one of the door's Parts' GfxObj BSP. BSPQuery finds
|
||
it. Player blocks. No matter where the player approaches from (any direction,
|
||
any offset within the doorway), the threshold polygon spans the doorway and
|
||
catches the sphere.
|
||
|
||
**4.4.c Door Use → server flips Ethereal**
|
||
```
|
||
ACE Door.ActOnUse(player)
|
||
→ GameMessageSetState(0xF74B): guid=0x7A9B4015 state=0x1000C (ETHEREAL set)
|
||
→ GameWindow.OnLiveStateUpdated
|
||
→ ShadowObjects.UpdatePhysicsState(0x000F4244, 0x1000C)
|
||
→ walk _entityToCells[0x000F4244] (4 cells)
|
||
→ in each cell, rewrite each entry with EntityId==0x000F4244 to State=0x1000C
|
||
→ next FindObjCollisions: CollisionExemption.ShouldSkip sees ETHEREAL → skips every entry → no collision
|
||
→ player walks through.
|
||
```
|
||
|
||
Auto-close after 30 s reverses the flip: state=0x10008 → all parts re-block.
|
||
|
||
**4.4.d Position update** (mostly NPCs walking, not doors)
|
||
```
|
||
UpdatePosition(entityId, newWorldPos, newWorldRot, ...)
|
||
→ look up _entityShapes[entityId]
|
||
→ for each shape, recompute world position:
|
||
partWorldPos = newWorldPos + Vector3.Transform(shape.LocalPosition * shape.Scale, newWorldRot)
|
||
partWorldRot = newWorldRot * shape.LocalRotation
|
||
→ effectively: Deregister + RegisterMultiPart with cached shape list
|
||
```
|
||
|
||
For doors specifically, this path rarely triggers — doors animate (the leaves
|
||
swing) but their PhysicsObj origin stays put. The Cylinder/Sphere/Parts move
|
||
with the door's entity transform, which is constant. The leaf swing is a
|
||
visual animation only; the threshold polygon is in the static frame.
|
||
|
||
### 4.5 ShadowShapeBuilder details
|
||
|
||
Pseudocode:
|
||
|
||
```csharp
|
||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||
Setup setup, float entScale, PhysicsDataCache cache)
|
||
{
|
||
var result = new List<ShadowShape>();
|
||
AnimationFrame? frame = ResolvePlacementFrame(setup);
|
||
|
||
// CylSpheres
|
||
foreach (var cyl in setup.CylSpheres)
|
||
{
|
||
if (cyl.Radius <= 0f) continue;
|
||
float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f;
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: 0, // unused for Cylinder
|
||
LocalPosition: cyl.Origin * entScale,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.Cylinder,
|
||
Radius: cyl.Radius * entScale,
|
||
CylHeight: baseHeight * entScale));
|
||
}
|
||
|
||
// Spheres (only when CylSpheres absent; retail uses one or the other, not both,
|
||
// for the "body" cylinder. Spheres-without-CylSpheres is the door pattern.)
|
||
if (setup.CylSpheres.Count == 0)
|
||
{
|
||
foreach (var sph in setup.Spheres)
|
||
{
|
||
if (sph.Radius <= 0f) continue;
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: 0,
|
||
LocalPosition: sph.Origin * entScale,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.Cylinder, // sphere ≈ short cylinder
|
||
Radius: sph.Radius * entScale,
|
||
CylHeight: sph.Radius * 2f * entScale));
|
||
}
|
||
}
|
||
|
||
// Parts — one BSP shape per part that has a non-null PhysicsBSP.
|
||
for (int i = 0; i < setup.Parts.Count; i++)
|
||
{
|
||
uint gfxId = (uint)setup.Parts[i];
|
||
var phys = cache.GetGfxObj(gfxId);
|
||
if (phys?.BSP?.Root is null) continue;
|
||
|
||
Frame partFrame = frame is not null && i < frame.Frames.Count
|
||
? frame.Frames[i]
|
||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||
|
||
float radius = (phys.BoundingSphere?.Radius ?? 1f) * entScale;
|
||
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: gfxId,
|
||
LocalPosition: partFrame.Origin * entScale,
|
||
LocalRotation: partFrame.Orientation,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: radius,
|
||
CylHeight: 0f));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
Placement frame resolution follows the same priority as `SetupMesh.Flatten`:
|
||
`PlacementFrames[Resting]` → `[Default]` → first available.
|
||
|
||
The `Sphere ≈ short cylinder` substitution matches the existing pattern in
|
||
`GameWindow.cs:6020` (landblock-static path) — both paths use the same
|
||
approximation, so behavior is consistent.
|
||
|
||
### 4.6 RegisterMultiPart details
|
||
|
||
Pseudocode:
|
||
|
||
```csharp
|
||
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>();
|
||
|
||
foreach (var shape in shapes)
|
||
{
|
||
// Compose world transform: world = entityRot · localPos + entityPos
|
||
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);
|
||
|
||
AddEntryToCells(entry, partWorldPos, shape.Radius, worldOffsetX, worldOffsetY,
|
||
landblockId, cellScope, allCells, seenCells);
|
||
}
|
||
|
||
_entityToCells[entityId] = allCells;
|
||
}
|
||
```
|
||
|
||
`AddEntryToCells` is extracted from the current single-shape `Register` body —
|
||
same cell-occupancy logic per entry. Shared between `Register` (single-shape
|
||
compat) and `RegisterMultiPart`.
|
||
|
||
### 4.7 The single-shape compat path
|
||
|
||
```csharp
|
||
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, ...)
|
||
{
|
||
// Wrap as single-shape ShadowShape list and forward to RegisterMultiPart.
|
||
var shape = new ShadowShape(
|
||
GfxObjId: gfxObjId,
|
||
LocalPosition: Vector3.Zero,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: scale,
|
||
CollisionType: collisionType,
|
||
Radius: radius,
|
||
CylHeight: cylHeight);
|
||
RegisterMultiPart(entityId, worldPos, rotation, new[] { shape },
|
||
state, flags, worldOffsetX, worldOffsetY, landblockId, cellScope);
|
||
}
|
||
```
|
||
|
||
All 22 existing `ShadowObjectRegistryTests` use this signature. They continue
|
||
working unchanged.
|
||
|
||
---
|
||
|
||
## 5. Risks & mitigation
|
||
|
||
**5.1 BSP collision against the door's parts produces wrong results because part transforms aren't applied.**
|
||
Each `setup.Parts[i]` is in local-to-part space. The Setup's
|
||
`PlacementFrames[Default][i]` is the part's local-to-setup transform. The BSP
|
||
polygons inside the part's GfxObj are in part-local space.
|
||
|
||
Mitigation: `ShadowShape.LocalPosition` + `LocalRotation` store the
|
||
part's local-to-entity transform. `RegisterMultiPart` composes the entity's
|
||
world transform with the local transform to produce `entry.Position` /
|
||
`entry.Rotation`. The existing BSP collision branch
|
||
(`TransitionTypes.cs:2250+`) uses `obj.Position` + `obj.Rotation` to transform
|
||
spheres into BSP-local space. Same path the static-landblock per-part BSP
|
||
already uses successfully. We adopt that working pattern unchanged.
|
||
|
||
Retail anchor: `CPhysicsPart::find_obj_collisions:275055` calls
|
||
`CGfxObj::find_obj_collisions(gfxobj, arg2, scale)` — the part stores its
|
||
own transform; the BSP test runs in part-local frame. Matches our model.
|
||
|
||
**5.2 Performance — entities with many parts produce many shadow rows.**
|
||
The landblock-static path already does this with no observed perf cost.
|
||
Broadphase culls each row by position+radius. For a 4-part door at 132 m, a
|
||
player at 127 m broad-phase-rejects 3 of 4 parts.
|
||
|
||
Mitigation: none needed. Retail incurs the same per-part cost.
|
||
|
||
**5.3 State propagation completeness — does `UpdatePhysicsState` reach all parts?**
|
||
After `RegisterMultiPart`, all part entries share `EntityId`. The existing
|
||
`UpdatePhysicsState` iterates `_entityToCells[entityId]` and updates every
|
||
entry where `EntityId == entityId`. One `SetState` call propagates to every
|
||
part. The conformance test
|
||
`UpdatePhysicsState_PropagatesToAllParts` pins this.
|
||
|
||
Mitigation: covered by the new test.
|
||
|
||
---
|
||
|
||
## 6. Testing
|
||
|
||
### 6.1 `ShadowShapeBuilderTests` — pure unit tests
|
||
|
||
Fixture: `tests/AcDream.Core.Tests/Fixtures/door/0x020019FF.setup.json` (real dat
|
||
dump). Builder is a pure function; tests are deterministic.
|
||
|
||
- `FromSetup_DoorSetup_ProducesFourShapes` — 0 cylinders + 1 sphere + 3 BSP
|
||
- `FromSetup_DoorSetup_SphereAtLocalOffset` — sphere position == (0, 0, 0.018)
|
||
- `FromSetup_DoorSetup_PartTransforms_MatchPlacementFrames`
|
||
- `FromSetup_PartWithoutBsp_SkipsBspShape` — fixture with a UI-only part
|
||
- `FromSetup_CreatureSetup_OnlyCylinders` — fixture: creature setup with CylSpheres + parts whose GfxObjs have no BSP
|
||
- `FromSetup_ScaleFactor_AppliedToRadii` — `entScale=2.0` doubles every radius
|
||
|
||
### 6.2 `ShadowObjectRegistryMultiPartTests` — registry behavior
|
||
|
||
- `RegisterMultiPart_FourShapes_AllInRegistry`
|
||
- `RegisterMultiPart_AllShareEntityId`
|
||
- `Deregister_RemovesAllParts` (and clears `_entityShapes[entityId]`)
|
||
- `UpdatePhysicsState_PropagatesToAllParts`
|
||
- `UpdatePosition_MovesAllPartsWithEntity`
|
||
- `RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed`
|
||
- `Register_SingleShapeCompat_Unchanged` — existing API still works
|
||
|
||
### 6.3 Live capture regression test
|
||
|
||
Re-run launch with `ACDREAM_CAPTURE_RESOLVE=path.jsonl`. Walk into a closed
|
||
door. Capture the tick where the door blocks. Add JSONL record + door's setup
|
||
fixture. Write `LiveCompare_DoorBlocksWhenClosed` in
|
||
`CellarUpTrajectoryReplayTests.cs`:
|
||
|
||
- Pre-fix: documents-the-bug (passes when harness lacks the threshold poly,
|
||
i.e. before this design lands)
|
||
- Post-fix: asserts `result` hit with `obj` attributed to a door entity
|
||
|
||
Mirror of the issue-#98 `LiveCompare_FirstCap_FixClosesCottageFloorCap` pattern.
|
||
|
||
### 6.4 Visual verification (user)
|
||
|
||
1. Cottage cellar climb — still works (no #98 regression)
|
||
2. Cottage doorway from outside (center) — blocks
|
||
3. Cottage doorway from outside (~50 cm off-center) — blocks
|
||
4. Use door → swing animation → walk through — passes
|
||
5. Wait 30 s → close animation → re-blocks both sides
|
||
6. Indoor furniture (chair, fireplace) — still blocks
|
||
7. Outdoor cottage exterior — still blocks
|
||
|
||
### 6.5 Existing tests audit
|
||
|
||
- 22 `ShadowObjectRegistryTests` — must pass unchanged via the compat path
|
||
- 11/11 `CellarUpTrajectoryReplayTests` — must pass unchanged
|
||
|
||
---
|
||
|
||
## 7. Migration sequence
|
||
|
||
| Commit | Title | Verification gate |
|
||
|---|---|---|
|
||
| 1 | `feat(phys): ShadowShape + ShadowShapeBuilder` (no callers yet) | `ShadowShapeBuilderTests` green |
|
||
| 2 | `feat(phys): ShadowObjectRegistry.RegisterMultiPart + ShadowEntry local-offset fields` | `ShadowObjectRegistryMultiPartTests` green; existing 22 tests still green |
|
||
| 3 | `refactor(phys): RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart` | `LiveCompare_DoorBlocksWhenClosed` green; 11/11 cellar tests green |
|
||
| 4 | `refactor(phys): landblock-baked statics use ShadowShapeBuilder + RegisterMultiPart` | Same tests green; visual verifies no static-collision regression |
|
||
| 5 | `docs: door collision per-part BSP — ship` | None |
|
||
|
||
Each commit independently buildable + test-green per CLAUDE.md. Visual
|
||
verification gates after commit 3 (door fix) and commit 4 (regression check on
|
||
statics).
|
||
|
||
---
|
||
|
||
## 8. Out of scope (kept-near reminders)
|
||
|
||
- **A6.P4 slices 2-3** (BuildShadowCellSet + b3ce505 stopgap retirement) —
|
||
separate work. After this door fix lands, the b3ce505 gate becomes a pure
|
||
performance optimization (skipping a radial sweep that wouldn't find
|
||
anything new), so slice 3's removal is still clean and correct.
|
||
- **Issue #100** (transparent ground around houses) — separate rendering
|
||
phase, untouched.
|
||
- **Reverse-portal-map** (A6.P4 spec §3.2.a) — only needed if the door fix
|
||
doesn't fully close the inside-approach case. The portal-reachable cell
|
||
set already covers it for spheres at the doorway threshold.
|
||
- **CPartArray-level optimization** — retail's PartArray has a bounding
|
||
sphere covering all parts, used for an early-out. Our per-part broadphase
|
||
pruning is equivalent. If perf ever becomes a concern, file a separate
|
||
optimization phase.
|
||
|
||
---
|
||
|
||
## 9. Open questions
|
||
|
||
None at design time. The retail-anchor + the static-landblock pattern + the
|
||
existing UpdatePhysicsState/Deregister behavior give a complete design. Open
|
||
questions, if any, will surface during implementation and get filed as
|
||
follow-ups.
|