using System; using System.IO; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using Xunit; using Xunit.Abstractions; using Env = System.Environment; namespace AcDream.Core.Tests.Physics; /// /// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe /// evidence (launch-175-verify2.log:42858): crossing corridor cells /// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal /// (−1,0,0) — pointing straight back against the movement — after which the /// stale sliding normal wedges all forward motion (ok=False hit=no, offset /// projected to zero). Question this dump answers: does cell 0x8A02017A's /// PHYSICS polygon set contain a portal-spanning polygon at its entry plane /// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in /// our collision set where retail filters them? /// public class Issue137CorridorSeamInspectionTests { private readonly ITestOutputHelper _out; public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output; [Theory] [InlineData(0x8A02016Eu)] [InlineData(0x8A02017Au)] public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId) { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: dat directory not found at {datDir}"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var envCell = dats.Get(envCellId); Assert.NotNull(envCell); _out.WriteLine($"=== EnvCell 0x{envCellId:X8} ==="); _out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " + $"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})"); _out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}"); _out.WriteLine($" CellPortals={envCell.CellPortals.Count}"); foreach (var p in envCell.CellPortals) _out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}"); var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); Assert.NotNull(environment); Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); _out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)"); foreach (var (id, poly) in cs.PhysicsPolygons) { // Compute the face normal from the vertex fan (same math as // PhysicsDataCache.ResolvePolygons). var verts = poly.VertexIds; if (verts.Count < 3) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross( v1.Origin - v0.Origin, v2.Origin - v0.Origin)); // Only print near-horizontal-normal polys (walls) — the seam wall // candidates; floors/ceilings are noise here. if (MathF.Abs(n.Z) > 0.3f) continue; _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}"); } // The portal polygons live in the VISUAL polygon set — print their // ids so overlap with the physics set (same id space?) is visible. _out.WriteLine($" VisualPolygons={cs.Polygons.Count}"); foreach (var p in envCell.CellPortals) { if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp)) { _out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})"); bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId); _out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}"); } } } /// /// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's /// PhysicsPolygons TABLE does not mean the physics BSP ever tests a /// polygon — retail's BSPLEAF::sphere_intersects_poly (0x0053d580) /// iterates the LEAF's in_polys index list (leaf construction /// 0x0053d4a0: in_polys[i] = &pack_poly[index]), and our /// BSPQuery walks the dat's PhysicsBSP leaves the same way. This dump /// answers: do the physics-BSP LEAVES of the corridor cells reference the /// portal polygons? If yes, retail's own BSP query would test them too /// (→ the passable mechanism must be transit/approach-side — the cdb /// question). If no, our collision is testing polys retail never reaches /// (→ a desk-fixable acdream divergence). /// [Theory] [InlineData(0x8A02016Eu)] [InlineData(0x8A02017Au)] public void CorridorCell_PhysicsBspLeafMembership_OfPortalPolys(uint envCellId) { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: dat directory not found at {datDir}"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var envCell = dats.Get(envCellId); Assert.NotNull(envCell); var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); Assert.NotNull(environment); Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); var portalPolyIds = new System.Collections.Generic.HashSet(); foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId); _out.WriteLine($"=== EnvCell 0x{envCellId:X8} — physics BSP leaf membership ==="); _out.WriteLine($" Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " + $"portalPolyIds=[{string.Join(",", portalPolyIds)}] " + $"physicsTable=[{string.Join(",", cs!.PhysicsPolygons.Keys)}]"); var root = cs.PhysicsBSP?.Root; Assert.NotNull(root); int leafCount = 0; var leafPolyIds = new System.Collections.Generic.HashSet(); var portalPolyLeafHits = new System.Collections.Generic.List(); var stack = new System.Collections.Generic.Stack<(DatReaderWriter.Types.PhysicsBSPNode Node, string Path)>(); stack.Push((root!, "R")); while (stack.Count > 0) { var (n, path) = stack.Pop(); if (n.Polygons is { Count: > 0 }) { leafCount++; foreach (var pid in n.Polygons) { leafPolyIds.Add(pid); if (portalPolyIds.Contains(pid)) portalPolyLeafHits.Add($"poly {pid} in leaf@{path} (type={n.Type}, polys=[{string.Join(",", n.Polygons)}])"); } } if (n.PosNode is not null) stack.Push((n.PosNode, path + "+")); if (n.NegNode is not null) stack.Push((n.NegNode, path + "-")); } _out.WriteLine($" BSP leaves-with-polys={leafCount} distinctLeafPolyIds=[{string.Join(",", leafPolyIds)}]"); var tableNotInLeaves = new System.Collections.Generic.List(); foreach (var pid in cs.PhysicsPolygons.Keys) if (!leafPolyIds.Contains(pid)) tableNotInLeaves.Add(pid); _out.WriteLine($" physics-table polys NOT referenced by any BSP leaf: [{string.Join(",", tableNotInLeaves)}]"); if (portalPolyLeafHits.Count == 0) { _out.WriteLine(" >>> NO portal polygon is referenced by any physics-BSP leaf — " + "retail's sphere_intersects_poly never tests them from this cell's BSP."); } else { foreach (var hit in portalPolyLeafHits) _out.WriteLine($" >>> PORTAL POLY IN PHYSICS LEAF: {hit}"); } } /// /// Mechanism-1 re-characterization (2026-07-06): the live hit normal /// (−1.00, 0.03, −0.03) at world (85.253, −39.776, −5.992) matches NO /// physics polygon of either corridor cell — 0x8A02016E (identity /// rotation) and 0x8A02017A (180° Z) both have only ±Y-normal wall polys, /// and the PortalSide portals to 0x011E (polys 1/3/5) are ±Y planes /// 1.4 m north of the player's track, perpendicular to the +X run — the /// pos_hits_sphere directional cull rejects them for this movement. This /// sweep hunts the ACTUAL culprit: every physics poly of the seam cell + /// all portal-adjacent neighbors, world-transformed, scored against the /// hit point + normal. /// [Fact] public void CorridorSeam_FindPolygonMatchingLiveHit() { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: dat directory not found at {datDir}"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); // Live evidence (launch-175-verify2.log:42858). var hitPoint = new System.Numerics.Vector3(85.253f, -39.776f, -5.992f); var hitNormal = new System.Numerics.Vector3(-1.00f, 0.03f, -0.03f); hitNormal = System.Numerics.Vector3.Normalize(hitNormal); const float sphereRadius = 0.48f; // Seam cells + every portal-adjacent neighbor of both. var cellIds = new System.Collections.Generic.HashSet { 0x8A02016Eu, 0x8A02017Au, }; foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au }) { var seedCell = dats.Get(seed); if (seedCell is null) continue; foreach (var p in seedCell.CellPortals) cellIds.Add(0x8A020000u | p.OtherCellId); } foreach (var cellId in cellIds) { var envCell = dats.Get(cellId); if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; } var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue; var rot = new System.Numerics.Quaternion( envCell.Position.Orientation.X, envCell.Position.Orientation.Y, envCell.Position.Orientation.Z, envCell.Position.Orientation.W); var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) * System.Numerics.Matrix4x4.CreateTranslation( envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); var portalPolyIds = new System.Collections.Generic.HashSet(); foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId); foreach (var (id, poly) in cs!.PhysicsPolygons) { var verts = poly.VertexIds; if (verts.Count < 3) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; var w0 = System.Numerics.Vector3.Transform(v0.Origin, world); var w1 = System.Numerics.Vector3.Transform(v1.Origin, world); var w2 = System.Numerics.Vector3.Transform(v2.Origin, world); var n = System.Numerics.Vector3.Normalize( System.Numerics.Vector3.Cross(w1 - w0, w2 - w0)); float align = System.Numerics.Vector3.Dot(n, hitNormal); if (align < 0.95f) continue; // within ~18° of the recorded normal // Plane distance from the hit point. float d = -System.Numerics.Vector3.Dot(n, w0); float dist = System.Numerics.Vector3.Dot(n, hitPoint) + d; if (MathF.Abs(dist) > sphereRadius + 0.1f) continue; // Rough proximity: hit point near the polygon's vertex span. float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)) - 1f; float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)) + 1f; float minY = MathF.Min(w0.Y, MathF.Min(w1.Y, w2.Y)) - 1f; float maxY = MathF.Max(w0.Y, MathF.Max(w1.Y, w2.Y)) + 1f; if (hitPoint.X < minX || hitPoint.X > maxX || hitPoint.Y < minY || hitPoint.Y > maxY) continue; _out.WriteLine( $">>> CANDIDATE cell=0x{cellId:X8} poly={id} " + $"worldN=({n.X:F3},{n.Y:F3},{n.Z:F3}) align={align:F3} planeDist={dist:F3} " + $"isPortalPoly={portalPolyIds.Contains(id)} " + $"w0=({w0.X:F2},{w0.Y:F2},{w0.Z:F2}) w1=({w1.X:F2},{w1.Y:F2},{w1.Z:F2}) w2=({w2.X:F2},{w2.Y:F2},{w2.Z:F2}) " + $"verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}"); } } _out.WriteLine("(sweep complete)"); } /// /// Entry-poly hunt: the synthetic reversed-movement collision normal is /// produced by slide_sphere's opposing-normals branch, which needs an /// INPUT collision normal anti-parallel to the grounded contact plane — /// i.e., a DOWNWARD-facing polygon (lintel / arch underside). Those were /// filtered out of the wall dump (|n.Z| > 0.3). Sweep both corridor /// cells for downward polys near the seam column and print where their /// planes sit relative to the player's head sphere. /// [Fact] public void CorridorSeam_DownwardPolysNearSeam() { var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: dat directory not found at {datDir}"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); foreach (var cellId in new[] { 0x8A02016Eu, 0x8A02017Au }) { var envCell = dats.Get(cellId); Assert.NotNull(envCell); var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); Assert.NotNull(environment); Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); var rot = new System.Numerics.Quaternion( envCell.Position.Orientation.X, envCell.Position.Orientation.Y, envCell.Position.Orientation.Z, envCell.Position.Orientation.W); var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) * System.Numerics.Matrix4x4.CreateTranslation( envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); _out.WriteLine($"=== 0x{cellId:X8} downward physics polys (n.Z < -0.3) ==="); foreach (var (id, poly) in cs!.PhysicsPolygons) { var verts = poly.VertexIds; if (verts.Count < 3) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; var w0 = System.Numerics.Vector3.Transform(v0.Origin, world); var w1 = System.Numerics.Vector3.Transform(v1.Origin, world); var w2 = System.Numerics.Vector3.Transform(v2.Origin, world); var n = System.Numerics.Vector3.Normalize( System.Numerics.Vector3.Cross(w1 - w0, w2 - w0)); if (n.Z > -0.3f) continue; // Only near the seam column the player crossed. float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)); float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)); if (maxX < 83.5f || minX > 87.0f) continue; var allW = new System.Collections.Generic.List(); foreach (var vid in verts) if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var vv)) allW.Add(System.Numerics.Vector3.Transform(vv.Origin, world)); float minZ = float.MaxValue, maxZ = float.MinValue, minY = float.MaxValue, maxY = float.MinValue; foreach (var w in allW) { minZ = MathF.Min(minZ, w.Z); maxZ = MathF.Max(maxZ, w.Z); minY = MathF.Min(minY, w.Y); maxY = MathF.Max(maxY, w.Y); } _out.WriteLine( $" poly {id}: worldN=({n.X:F2},{n.Y:F2},{n.Z:F2}) x=[{minX:F2},{maxX:F2}] " + $"y=[{minY:F2},{maxY:F2}] z=[{minZ:F2},{maxZ:F2}] verts={verts.Count} " + $"sides={poly.SidesType} stip={poly.Stippling}"); } } _out.WriteLine("(downward sweep complete)"); } }