diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1bfce28..6227452 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3090,41 +3090,60 @@ public sealed class GameWindow : IDisposable return; 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(raw.Count); + foreach (var s in raw) { - // Pick the largest CylSphere as the body cylinder. Retail - // 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 (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP) { - 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; - 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++) + else { - 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. // IsCreature comes from the spawn's ItemType (server-known type). @@ -3136,17 +3155,31 @@ public sealed class GameWindow : IDisposable uint state = spawn.PhysicsState ?? 0u; - _physicsEngine.ShadowObjects.Register( - entity.Id, entity.SourceGfxObjOrSetupId, - entity.Position, entity.Rotation, radius, - origin.X, origin.Y, spawn.Position.Value.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.Cylinder, - cylHeight: height, scale: 1.0f, - state: state, flags: flags); + _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): [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) + { + 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( - $"[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)