docs(phys): design spec — per-part BSP collision for server-spawned entities
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>
This commit is contained in:
parent
b49ed904c3
commit
d71ceaba9c
1 changed files with 553 additions and 0 deletions
|
|
@ -0,0 +1,553 @@
|
||||||
|
# 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue