diff --git a/docs/research/2026-05-25-door-bug-partial-fix-shipped.md b/docs/research/2026-05-25-door-bug-partial-fix-shipped.md index 9fe5310..d775a0d 100644 --- a/docs/research/2026-05-25-door-bug-partial-fix-shipped.md +++ b/docs/research/2026-05-25-door-bug-partial-fix-shipped.md @@ -106,29 +106,90 @@ The `[bsp-test]` probe fires 245 times for the door entity during the post-fix inside-out attempts — door IS being queried. The collision-detection mechanics produce the wrong response. -## What's next (separate bug) +## What's next (separate bug — REFRAMED 2026-05-25 evening) -**Investigate BSPQuery.FindCollisions's response for two-sided polygons -when the sphere is already overlapping the slab.** Retail's -`CBSPTree::find_collisions` family handles this specifically — the -sphere's path through the slab faces gets traced and the FIRST face -crossed in motion direction is the collision. With two-sided polygons, -both faces are collidable; the front-vs-back determination is by -sphere-velocity vs face-normal dot product. +**Initial hypothesis was wrong.** Two new directional tests built +post-fix (`Directional_OutsideIn_*`, `Directional_InsideOut_*`) BOTH +PASS — the BSP collision response is symmetric at unit-test level. +The asymmetric production bug must come from something the unit tests +weren't capturing. -Likely files: -- `src/AcDream.Core/Physics/BSPQuery.cs` — the BSP traversal + - sphere-poly intersection logic. -- Retail decomp anchors: - `acclient_2013_pseudo_c.txt:BSPTREE::find_collisions` + - `SPHEREPATH::sphere_intersects_poly` family. +A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`) +reveals the real story: -Apparatus to write next: a focused test that registers the door at its -actual production world transform (entity origin + partFrame offset -from the dat, with correct rotation) and replays a sphere passing -through it from EACH side at various speeds. Compare collision normal -+ position-resolution per side. The asymmetric response will be -reproducible at unit-test speed. +``` +Setup 0x020019FF (cottage door): + CylSphere[0]: r=0.10, h=0.20, origin=(0, 0, 0.018) + → world Z [94.118, 94.318] when entity at Z=94.1 + Part 0 (GfxObj 0x010044B5, the BSP "slab"): + placement frame [Default][0].Origin = (-0.006, 0.125, 1.275) + → BSP world Z [95.375, 97.865] when entity at Z=94.1 + +Player at floor Z=94: + sphere height = 1.20, sphere top = 95.20 + + BSP slab BOTTOM (95.375) is ABOVE sphere TOP (95.20) by 0.175 m. + The slab NEVER collides with the player's body sphere. +``` + +The slab is a LINTEL (the door frame above the doorway), not a leaf. +The door's only collision against a player at floor level is the +0.10 m radius foot cylinder. Sphere radius 0.48 + cyl 0.10 = 0.58 m +collision reach. Any sphere center > 0.58 m from cylinder center +(132.6, 17.1) passes freely. + +The user-reported "inside-out walkthrough at ~50 cm off-center" is +the sphere walking AROUND the cylinder, at X = 132.6 ± 0.6+ m where +collision misses entirely. "Body partially intersects door" is the +character model occupying the visual door's volume while the collision +sphere passes beside the foot cylinder. + +The "outside-in works" case is also the foot cylinder doing the +blocking — when the user approaches more centered or hits the cylinder +head-on. The cell-visibility fix made the cylinder visible from indoor +cells (it wasn't before), which is why outside-in went from "walks +through" to "blocks" — but the cylinder is the actual collider. + +**This is a door-geometry interpretation question, NOT a BSP query bug.** +Three candidate next-step investigations: + +1. **Retail-faithfulness audit on door collision.** Read retail's + `CPhysicsObj::set_setup_for_door` (or similar) to determine what + shapes retail loads for a closed cottage door. If retail uses the + SAME setup data + finds the same shapes we do, the door geometry + in the dat IS the spec. The "blocking" the user sees in retail + might similarly be the foot cylinder + perhaps a different default + sphere from setup.Radius/setup.Height that we're not registering. + +2. **Inspect door parts 1+2 (GfxObj 0x010044B6).** Per prior session's + handoff they are visual-only (HasPhysics=false). Verify by direct + dat read — maybe the PhysicsBSP is null but the cylinder/sphere + list on the GfxObj itself has collision data we're missing. + `DoorSetupGfxObjInspectionTests` already prints this; re-read. + +3. **The cottage cell BSP encloses the doorway.** Cell 0x0150 + (the doorway alcove) is bounded by cottage walls. The DOOR opens + through a gap in those walls. When the door is closed, the gap + is filled by the door geometry. Maybe retail's collision relies + on the cottage walls (cell BSP) for the "doorway side walls" and + the door's slab covers ONLY the opening's leaf area. The asymmetric + block we see could be the cottage cell walls catching outside-in + approach (cell BSP collision via FindEnvCollisions, before the + door's shadow ever fires) but NOT catching inside-out at the same + alcove geometry. + +The 50cm off-center approach probably exits the cottage walls' BSP +through the doorway gap, walks past the foot cylinder (which is small), +and never has anything to collide against in the world's collision +representation. Retail might block this via a `setup.Radius=0.1414` +cylinder we're missing (Setup.Radius isn't currently registered by +`ShadowShapeBuilder.FromSetup` for entities WITH a CylSphere). + +Apparatus to write next: a test that registers the door using +ShadowShapeBuilder.FromSetup AND verifies setup.Radius is reflected +in the shape list. If it isn't, that's a candidate fix — the door's +0.14 m radius + 0.20 m height SETUP sphere is wider than the 0.10 m +CylSphere and would catch the off-center approach. ## Commits diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs index b9fab9d..dc81a21 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -260,6 +260,338 @@ public class DoorBugTrajectoryReplayTests string.Join(",", candidates.Select(c => $"0x{c:X8}"))); } + /// + /// A6.P4 inside-out asymmetric collision (2026-05-25 evening) — + /// synthesizes a sphere approaching a faithfully-registered door + /// from each side and asserts the BSP collision fires symmetrically. + /// + /// + /// Door registered via the SAME path production uses + /// (ShadowShapeBuilder.FromSetup + RegisterMultiPart) with the + /// real Setup 0x020019FF loaded from the dat, including the + /// part-0 BSP's PlacementFrame[Default][0] origin of + /// (-0.006, 0.125, 1.275). Entity world rotation is 180° around Z + /// to match the cottage's world transform (per the cellar fixture + /// pattern + observed [bsp-test] world position alignment). + /// + /// + /// + /// After the 180° entity rotation, the slab's local Y thickness axis + /// maps to world -Y. Slab spans world Y in approximately + /// [.Y - 0.261, .Y] + /// (entity Y minus 0 to entity Y minus 0.261 thickness). Two faces + /// matter: + /// + /// Higher-Y face (world Y ≈ entity.Y) has world + /// normal +Y. A sphere NORTH of the slab moving SOUTH (-Y) + /// hits this face. cn should be near (0, +1, 0). + /// Lower-Y face (world Y ≈ entity.Y - 0.261) has world + /// normal -Y. A sphere SOUTH of the slab moving NORTH (+Y) + /// hits this face. cn should be near (0, -1, 0). + /// + /// + /// + /// + /// User-reported behavior post-AddAllOutsideCells-fix: + /// outside→inside blocks cleanly; inside→outside shows the body + /// partially intersecting the door before sphere slides through. + /// If the asymmetry is in BSP collision, these tests will + /// reproduce it at unit-test speed. + /// + /// + /// + /// Geometric finding (2026-05-25 evening) — pins the door geometry + /// math that explains why the "inside-out walkthrough" persists + /// after the cell-visibility fix. + /// + /// + /// The cottage door Setup 0x020019FF has: + /// + /// One CylSphere (r=0.10, h=0.20, origin=(0, 0, 0.018)) — a + /// TINY foot collider at entity Z + 0.018, extending Z just + /// to 0.218 above entity Z. + /// Part 0 = GfxObj 0x010044B5 (BSP slab 1.925 × 0.261 × 2.490 m), + /// placed via PlacementFrames[Default][0].Origin = + /// (-0.006, 0.125, 1.275). The slab's local Z=0 origin sits + /// at entity Z + 1.275 — i.e., the slab's BOTTOM is 1.275 m + /// ABOVE the door's entity foot. + /// + /// + /// + /// + /// With entity at (132.6, 17.1, 94.1) (the captured Holtburg cottage + /// door spawn position): + /// + /// Cylinder world Z range: [94.118, 94.318] — touches the + /// ground (sphere foot Z=94 to top Z=95.20 overlaps cyl Z up to 94.318). + /// Slab world Z range: [95.375, 97.865]. The slab's BOTTOM + /// (95.375 m) is 0.175 m ABOVE the player's sphere top + /// (95.20 m). The slab NEVER intersects the player's + /// body sphere vertically. + /// + /// + /// + /// + /// Implication: the door's only effective collision against a player + /// at floor level is the 0.10 m radius foot cylinder. The 1.93 m wide + /// slab is collision-irrelevant at sphere height — it's a LINTEL + /// (the door frame above), not a leaf collision. The user-reported + /// "off-center walkthrough" is the sphere walking AROUND the + /// 0.10 m foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m; + /// any sphere center >0.58 m from cylinder center passes freely). + /// The "body partially intersects door" is the rendered character model + /// occupying volume the door visual fills, but no collision body to + /// stop it because the slab is too high. + /// + /// + /// + /// Next session: this is a DAT or registration issue, not a BSP query + /// bug. Options: + /// + /// Verify retail actually USES this geometry — maybe the door + /// relies on the cottage CELL'S walls (cell 0x0150's + /// PhysicsPolygons) to enclose the doorway, and the door's + /// only collision is the foot cylinder + a leaf shape we're + /// missing. + /// Inspect parts 1+2 (the door LEAVES, GfxObj 0x010044B6) to + /// confirm they're truly visual-only or if we missed a + /// physics shape. + /// cdb attach to retail at a Holtburg cottage door — set a + /// breakpoint on CTransition::FindObjCollisions for the door + /// entity and inspect what shapes retail tests against. + /// + /// + /// + [Fact] + public void Geometric_DoorSlabZRange_AbovePlayerSphereTop() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + // Load the door setup + part 0's PlacementFrame. + DatReaderWriter.Types.Frame partFrame; + float slabLocalZMax; + using (var dats = new DatCollection(datDir, DatAccessType.Read)) + { + var setup = dats.Get(0x020019FFu)!; + Assert.NotNull(setup); + Assert.True(setup.PlacementFrames.ContainsKey(DatReaderWriter.Enums.Placement.Default)); + partFrame = setup.PlacementFrames[DatReaderWriter.Enums.Placement.Default].Frames[0]; + + var gfx = dats.Get(DoorGfxObjId)!; + Assert.NotNull(gfx.PhysicsPolygons); + // Compute local AABB Z max from vertex array. + slabLocalZMax = float.MinValue; + foreach (var poly in gfx.PhysicsPolygons.Values) + { + foreach (ushort vid in poly.VertexIds) + { + if (gfx.VertexArray.Vertices.TryGetValue(vid, out var sv)) + if (sv.Origin.Z > slabLocalZMax) slabLocalZMax = sv.Origin.Z; + } + } + } + + // The door's entity is placed at world Z=94.1 in Holtburg (per + // captured spawn log). Slab local Z=0 origin offsets to: + float slabWorldZBottom = DoorSpawnPos.Z + partFrame.Origin.Z; + float slabWorldZTop = slabWorldZBottom + slabLocalZMax; + + // The player has sphereHeight=1.20, sphereRadius=0.48. Sphere top + // in world = foot.Z + height - radius + radius = foot.Z + height + // (the top of the head sphere centered at foot.Z + height - radius). + const float SphereHeight = 1.20f; + const float PlayerFootZ = 94f; // standard Holtburg floor + float sphereTopZ = PlayerFootZ + SphereHeight; + + // The crucial assertion: slab bottom is above sphere top. + Assert.True(slabWorldZBottom > sphereTopZ, + $"Door slab bottom ({slabWorldZBottom:F3}) should be ABOVE " + + $"player sphere top ({sphereTopZ:F3}). Gap = " + + $"{slabWorldZBottom - sphereTopZ:F3} m. This pins the geometric " + + $"fact that the slab does not collide with the player at floor " + + $"level — only the foot cylinder does. The inside-out 'walkthrough' " + + $"is the sphere passing around the cylinder, not through the slab."); + } + + [Fact] + public void Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + var (engine, _) = BuildFaithfulDoorEngine(datDir); + + // Sphere starts SOUTH of slab (low Y), moves NORTH (+Y) toward door. + // Slab world Y ∈ [16.84, 17.10] approximately after 180° entity rot. + // Sphere south edge needs to be just south of slab south face. + var currentPos = new Vector3(132.5f, 16.3f, 94f); + var targetPos = new Vector3(132.5f, 16.7f, 94f); // +0.4 m north + + var (result, body) = ResolveAt(engine, currentPos, targetPos, DoorCellOutdoor); + + // The slab's south face has world normal (0, -1, 0) after the + // 180° entity rotation. Sphere moving +Y hits it; collision + // normal should oppose motion, i.e., negative Y component. + Assert.True(result.CollisionNormalValid, + $"Outside-in: door should block sphere. Got: pos={result.Position}, " + + $"cnValid={result.CollisionNormalValid}, cn={result.CollisionNormal}."); + Assert.True(result.CollisionNormal.Y < -0.5f, + $"Outside-in: cn.Y should be negative (south face normal). " + + $"Got cn={result.CollisionNormal}."); + } + + [Fact] + public void Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + var (engine, _) = BuildFaithfulDoorEngine(datDir); + + // Sphere starts NORTH of slab (high Y), moves SOUTH (-Y) toward door. + var currentPos = new Vector3(132.5f, 17.6f, 94f); + var targetPos = new Vector3(132.5f, 17.2f, 94f); // -0.4 m south + + var (result, body) = ResolveAt(engine, currentPos, targetPos, DoorCellOutdoor); + + // The slab's north face has world normal (0, +1, 0) after the + // 180° entity rotation. Sphere moving -Y hits it; collision + // normal should oppose motion, i.e., positive Y component. + Assert.True(result.CollisionNormalValid, + $"Inside-out: door should block sphere. Got: pos={result.Position}, " + + $"cnValid={result.CollisionNormalValid}, cn={result.CollisionNormal}."); + Assert.True(result.CollisionNormal.Y > 0.5f, + $"Inside-out: cn.Y should be positive (north face normal). " + + $"Got cn={result.CollisionNormal}."); + } + + /// + /// Faithful engine: registers the real Setup 0x020019FF door via + /// ShadowShapeBuilder.FromSetup at the captured entity world position + /// (132.6, 17.1, 94.1) with the cottage's 180° Z rotation. Mirrors + /// production GameWindow.RegisterLiveEntityCollision exactly. + /// + private static (PhysicsEngine engine, PhysicsDataCache cache) + BuildFaithfulDoorEngine(string datDir) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // Cache GfxObj 0x010044B5 (the BSP slab) from dat. + DatReaderWriter.DBObjs.Setup setup; + using (var dats = new DatCollection(datDir, DatAccessType.Read)) + { + var gfx = dats.Get(DoorGfxObjId); + Assert.NotNull(gfx); + cache.CacheGfxObj(DoorGfxObjId, gfx!); + + setup = dats.Get(0x020019FFu)!; + Assert.NotNull(setup); + } + + // Stub landblock at (0, 0) so TryGetLandblockContext succeeds. + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock( + landblockId: DoorLandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + // Build shape list the same way production does + // (GameWindow.RegisterLiveEntityCollision): + // 1. ShadowShapeBuilder.FromSetup with entScale=1 + // 2. Substitute BSP shape's radius with the real BoundingSphere.Radius + var rawShapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, + id => cache.GetGfxObj(id)?.BSP?.Root is not null); + var shapes = new List(rawShapes.Count); + foreach (var s in rawShapes) + { + if (s.CollisionType == ShadowCollisionType.BSP) + { + var phys = cache.GetGfxObj(s.GfxObjId); + float bspR = phys?.BoundingSphere?.Radius ?? 2f; + shapes.Add(s with { Radius = bspR }); + } + else + { + shapes.Add(s); + } + } + Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP); + + // Register the door at the cottage's entity world transform: + // - Position from the captured spawn data: (132.6, 17.1, 94.1) + // - Rotation 180° around Z to match cottage orientation + // (consistent with [bsp-test] world position alignment) + engine.ShadowObjects.RegisterMultiPart( + entityId: DoorEntityId, + entityWorldPos: DoorSpawnPos, + entityWorldRot: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI), + shapes: shapes, + state: DoorClosedState, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: DoorLandblockId); + + return (engine, cache); + } + + /// + /// Run one call + /// against at the given positions/cell, + /// returning the result + the body's post-call state. + /// + private static (ResolveResult result, PhysicsBody body) + ResolveAt(PhysicsEngine engine, Vector3 currentPos, Vector3 targetPos, uint cellId) + { + var body = new PhysicsBody + { + Position = currentPos, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -94f), + ContactPlaneCellId = cellId, + WalkablePolygonValid = true, + WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -94f), + WalkableVertices = new[] + { + // Big walkable poly covering Y in [10, 30], X in [120, 145]. + new Vector3(120f, 10f, 94f), + new Vector3(145f, 10f, 94f), + new Vector3(145f, 30f, 94f), + new Vector3(120f, 30f, 94f), + }, + WalkableUp = Vector3.UnitZ, + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + + var result = engine.ResolveWithTransition( + currentPos: currentPos, + targetPos: targetPos, + cellId: cellId, + sphereRadius: 0.48f, + sphereHeight: 1.20f, + stepUpHeight: 0.60f, + stepDownHeight: 1.5f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: DoorEntityId + 1); + + return (result, body); + } + + // The captured door spawn position from launch.log [entity-source]: + // "live: spawn ... name=Door setup=0x020019FF pos=(132.6,17.1,94.1)@0xA9B40029" + private static readonly Vector3 DoorSpawnPos = new(132.6f, 17.1f, 94.1f); + private const uint DoorCellOutdoor = 0xA9B40029u; + /// /// Direct test of with /// the captured sphere position (132.36, 16.81, 94) and currentCellId