feat(phys): A6.P4 Task 7 — RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart

Closes the M1.5 "doors don't block in production" bug (alongside the
foundation fix at 3b7dc46). Server-spawned entities (doors, NPCs,
chests, items) now register one ShadowEntry per collision shape —
matching retail's CPhysicsObj-with-CPartArray model
(acclient_2013_pseudo_c.txt:286236) — instead of one Cylinder
approximation per entity.

Before:
  RegisterLiveEntityCollision picked ONE shape via a CylSphere → Radius
  → Sphere cascade, registered as a single Cylinder. Doors got a
  14 cm × 20 cm cylinder from setup.Radius — far too narrow to span
  the doorway gap. Players could walk through closed doors.

After:
  - ShadowShapeBuilder.FromSetup emits N shapes:
    • one Cylinder per CylSphere
    • one Cylinder per Sphere (only when no CylSpheres — retail
      convention)
    • one BSP shape per Part with a non-null PhysicsBSP
  - Caller substitutes the real BoundingSphere.Radius from
    PhysicsDataCache for BSP shapes (pure builder's 2.0 placeholder
    is tightened to the actual cached value).
  - setup.Radius fallback preserved: if the builder produces zero
    shapes but Radius > 0, register a Radius-based Cylinder so simple
    decorative props don't silently lose collision.
  - ShadowObjects.RegisterMultiPart adds N rows, all sharing
    entity.Id so the existing UpdatePhysicsState (ETHEREAL flip on
    door Use) propagates to every part without changes.

Door 0x020019FF (Holtburg cottage) now registers:
  - Cylinder r=0.10 h=0.20 (from the single Sphere)
  - BSP from Part 0 = GfxObj 0x010044B5, the 6-face 1.925 m × 0.261 m
    × 2.490 m two-sided slab confirmed by
    DoorSetupGfxObjInspectionTests
  Parts 1 + 2 (GfxObj 0x010044B6, the visual leaves) are visual-only
  in the dat by retail design and correctly skipped.

Test impact: 53/53 pass in the shape / registry / door /
cellar-replay scope. App-layer 41/41 pass.

Visual verification needed: launch the client, walk into a closed
Holtburg cottage door from outside (dead center AND ~50 cm
off-center), then walk into it from inside. Door should block all
three approaches. Use the door (click + Use) → door swings open →
walking through passes (ETHEREAL flip via existing SetState path).

Foundation fix dependency:
  3b7dc46 fix(phys): GetNearbyObjects dedup-by-entityId silently
                     drops multi-part shadows
Without 3b7dc46 in place, the BSP shape registered here would be
dropped by GetNearbyObjects's dedup. They land together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 18:52:36 +02:00
parent 3b7dc46219
commit ca9341c2cb

View file

@ -3090,41 +3090,60 @@ public sealed class GameWindow : IDisposable
return; return;
float entScale = spawn.ObjScale ?? 1.0f; float entScale = spawn.ObjScale ?? 1.0f;
float radius;
float height;
if (hasCyl) // A6.P4 door fix (2026-05-24): build the multi-part shape list.
// ShadowShapeBuilder emits one entry per CylSphere, one per Sphere
// (only when no CylSpheres), and one per Part with a non-null
// PhysicsBSP. Retail-faithful per CTransition::find_obj_collisions
// → CPartArray::FindObjCollisions
// (acclient_2013_pseudo_c.txt:286236). Pre-fix doors registered
// ONE small Cylinder via setup.Radius / Sphere — too narrow to
// span the doorway gap, so the player could walk through. With
// this change the door also registers the part-0 BSP slab
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
setup, entScale,
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null);
// Substitute the real bounding-sphere radius for BSP shapes —
// the pure builder's 2.0 placeholder works for typical doors
// (BS radius ≈ 1.975 m) but is loose for larger entities and
// tight for smaller ones. Mirrors the landblock-static path's
// pattern of pulling the real radius from PhysicsDataCache.
var shapes = new List<AcDream.Core.Physics.ShadowShape>(raw.Count);
foreach (var s in raw)
{ {
// Pick the largest CylSphere as the body cylinder. Retail if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP)
// tests every CylSphere in turn (276891) but for collision
// BLOCKING the largest is sufficient — the player will stop
// at the body's outer radius.
var sph = setup.CylSpheres[0];
for (int i = 1; i < setup.CylSpheres.Count; i++)
{ {
if (setup.CylSpheres[i].Radius > sph.Radius) sph = setup.CylSpheres[i]; var phys = _physicsDataCache.GetGfxObj(s.GfxObjId);
float bspR = (phys?.BoundingSphere?.Radius ?? 2f) * entScale;
shapes.Add(s with { Radius = bspR });
} }
radius = sph.Radius * entScale; else
height = (sph.Height > 0 ? sph.Height : sph.Radius * 4f) * entScale;
}
else if (hasRadius)
{
radius = setup.Radius * entScale;
height = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale;
}
else
{
// Sphere-only: largest sphere as a Cylinder approximation.
var sph = setup.Spheres[0];
for (int i = 1; i < setup.Spheres.Count; i++)
{ {
if (setup.Spheres[i].Radius > sph.Radius) sph = setup.Spheres[i]; shapes.Add(s);
} }
radius = sph.Radius * entScale;
height = sph.Radius * 2f * entScale;
} }
if (radius <= 0f) return; // setup.Radius fallback: the builder doesn't emit a Radius-only
// shape (it only walks CylSpheres / Spheres / Parts). For entities
// with no CylSpheres / Spheres / BSP-bearing Parts but a non-zero
// Radius (rare — simple decorative props), preserve the prior
// behavior of registering a setup.Radius cylinder so we don't
// silently regress those entities' collision.
if (shapes.Count == 0 && hasRadius)
{
shapes.Add(new AcDream.Core.Physics.ShadowShape(
GfxObjId: 0u,
LocalPosition: System.Numerics.Vector3.Zero,
LocalRotation: System.Numerics.Quaternion.Identity,
Scale: entScale,
CollisionType: AcDream.Core.Physics.ShadowCollisionType.Cylinder,
Radius: setup.Radius * entScale,
CylHeight: (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale));
}
if (shapes.Count == 0) return;
// Decode PvP / Player / Impenetrable from PWD._bitfield. // Decode PvP / Player / Impenetrable from PWD._bitfield.
// IsCreature comes from the spawn's ItemType (server-known type). // IsCreature comes from the spawn's ItemType (server-known type).
@ -3136,17 +3155,31 @@ public sealed class GameWindow : IDisposable
uint state = spawn.PhysicsState ?? 0u; uint state = spawn.PhysicsState ?? 0u;
_physicsEngine.ShadowObjects.Register( _physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.SourceGfxObjOrSetupId, entityId: entity.Id,
entity.Position, entity.Rotation, radius, entityWorldPos: entity.Position,
origin.X, origin.Y, spawn.Position.Value.LandblockId, entityWorldRot: entity.Rotation,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, shapes: shapes,
cylHeight: height, scale: 1.0f, state: state,
state: state, flags: flags); flags: flags,
worldOffsetX: origin.X,
worldOffsetY: origin.Y,
landblockId: spawn.Position.Value.LandblockId);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// Per-shape detail appears in [resolve-bldg] when collisions fire;
// this entity-level line keeps the spawn-time identification.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
{
int nCyl = 0, nBsp = 0;
foreach (var s in shapes)
{
if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) nCyl++;
else nBsp++;
}
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root state=0x{state:X8} flags={flags}")); $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} shapes=cyl{nCyl}+bsp{nBsp} note=server-spawn-root state=0x{state:X8} flags={flags}"));
}
} }
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)