From ca9341c2cbed9e55c6601101965922f0869affdc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 24 May 2026 18:52:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(phys):=20A6.P4=20Task=207=20=E2=80=94=20Re?= =?UTF-8?q?gisterLiveEntityCollision=20uses=20ShadowShapeBuilder=20+=20Reg?= =?UTF-8?q?isterMultiPart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 103 ++++++++++++++++-------- 1 file changed, 68 insertions(+), 35 deletions(-) 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)