The 'run into the last corridor window and pop up through its roof' report: the live callers passed sphereHeight: 1.2f into SpherePath.InitPath, whose head-sphere formula (height - radius) put the head sphere center at 0.72 - the capsule top at 1.2m. The top 0.63m of a 1.83m character had NO collision, so at the corridor-end window alcove (0x8A020179 -> 0x8A02017E: 0.70m sill face, 1.3m opening, sloped funnel behind) the step-up's placement never saw the head overlapping the lintel solids and let the player climb in head-through-roof. Dat truth (HumanSetup_CollisionSpheres_DatTruth): Setup 0x02000001 spheres = (0,0,0.475) r=0.48 and (0,0,1.350) r=0.48 - capsule top 1.83 = Setup.Height 1.835. Retail collides with that sphere list verbatim (CPhysicsObj::transition 0x00512dc0 -> init_sphere(GetNumSphere, GetSphere, m_scale)). Fix: PlayerMovementController + the GameWindow remote resolve now pass sphereHeight: 1.835f (capsule top; head center 1.355 vs dat 1.350). InitPath unchanged - captured-input replay fixtures (recorded 1.2 inputs) stay byte-identical. Register TS-46: the (radius, capsule-top) scalar approximation of the Setup sphere list (5mm foot/head offsets; remotes use human dims) with the retire path (plumb the sphere list). Pins: WindowOpening_HeadCannotFit_EntryBlocked (22-frame walked approach wall-slides at the sill, never enters 0x8A02017E) + WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides (Path-1 placement rejects the raised head in the lintel solids) + the WindowShaft_FullPolyDump / HumanSetup dat inspections. Suites: Core 2562 / App 713 / UI 425 / Net 385, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
518 lines
26 KiB
C#
518 lines
26 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// #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?
|
||
/// </summary>
|
||
public class Issue137CorridorSeamInspectionTests
|
||
{
|
||
private readonly ITestOutputHelper _out;
|
||
public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output;
|
||
|
||
[Theory]
|
||
[InlineData(0x8A02016Eu)]
|
||
[InlineData(0x8A02017Au)]
|
||
[InlineData(0x8A02011Eu)] // the under-floor room the corridor's floor-portals lead to
|
||
[InlineData(0x8A020179u)] // the ramp corridor cell with the window (the #137 window-climb repro)
|
||
[InlineData(0x8A02017Eu)] // the cell beyond the window the player climbed into
|
||
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<EnvCell>(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<DatReaderWriter.DBObjs.Environment>(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}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's
|
||
/// <c>PhysicsPolygons</c> TABLE does not mean the physics BSP ever tests a
|
||
/// polygon — retail's <c>BSPLEAF::sphere_intersects_poly</c> (0x0053d580)
|
||
/// iterates the LEAF's <c>in_polys</c> index list (leaf construction
|
||
/// 0x0053d4a0: <c>in_polys[i] = &pack_poly[index]</c>), 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).
|
||
/// </summary>
|
||
[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<EnvCell>(envCellId);
|
||
Assert.NotNull(envCell);
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||
Assert.NotNull(environment);
|
||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||
|
||
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||
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<ushort>();
|
||
var portalPolyLeafHits = new System.Collections.Generic.List<string>();
|
||
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<ushort>();
|
||
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}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #137 window climb: the dat truth for the player's collision spheres.
|
||
/// Our InitPath places the head sphere at (sphereHeight − radius) = 0.72
|
||
/// (capsule top 1.2 m); retail collides with the Setup's SPHERE LIST
|
||
/// verbatim (CPhysicsObj::transition → init_sphere(GetNumSphere,
|
||
/// GetSphere, scale)). Print human Setup 0x02000001's spheres.
|
||
/// </summary>
|
||
[Fact]
|
||
public void HumanSetup_CollisionSpheres_DatTruth()
|
||
{
|
||
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 setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x02000001u);
|
||
Assert.NotNull(setup);
|
||
|
||
_out.WriteLine($"Setup 0x02000001: Height={setup!.Height:F3} Radius={setup.Radius:F3} " +
|
||
$"StepUp={setup.StepUpHeight:F3} StepDown={setup.StepDownHeight:F3}");
|
||
_out.WriteLine($"Spheres ({setup.Spheres.Count}):");
|
||
foreach (var s in setup.Spheres)
|
||
_out.WriteLine($" origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}");
|
||
_out.WriteLine($"CylSpheres ({setup.CylSpheres.Count}):");
|
||
foreach (var c in setup.CylSpheres)
|
||
_out.WriteLine($" origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #137 window-climb geometry (2026-07-06): full world-space vertex dump
|
||
/// of the shaft cell 0x8A02017E (all physics polys) and 0x8A020179's
|
||
/// south-wall family — the opening's lintel/ceiling spans decide where
|
||
/// retail blocks the head.
|
||
/// </summary>
|
||
[Fact]
|
||
public void WindowShaft_FullPolyDump()
|
||
{
|
||
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[] { 0x8A02017Eu, 0x8A020179u })
|
||
{
|
||
var envCell = dats.Get<EnvCell>(cellId);
|
||
Assert.NotNull(envCell);
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||
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} full physics polys (world verts) ===");
|
||
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||
{
|
||
var verts = poly.VertexIds;
|
||
if (verts.Count < 3) continue;
|
||
var w = new System.Collections.Generic.List<System.Numerics.Vector3>();
|
||
foreach (var vid in verts)
|
||
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
|
||
w.Add(System.Numerics.Vector3.Transform(v.Origin, world));
|
||
var n = System.Numerics.Vector3.Normalize(
|
||
System.Numerics.Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||
|
||
// 017E: everything. 0179: south-wall family + ceilings only.
|
||
if (cellId == 0x8A020179u && MathF.Abs(n.Y) < 0.3f && n.Z > -0.3f) continue;
|
||
|
||
var vs = string.Join(" ", w.ConvertAll(p => $"({p.X:F2},{p.Y:F2},{p.Z:F2})"));
|
||
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) verts={vs}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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<uint>
|
||
{
|
||
0x8A02016Eu, 0x8A02017Au,
|
||
};
|
||
foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au })
|
||
{
|
||
var seedCell = dats.Get<EnvCell>(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<EnvCell>(cellId);
|
||
if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; }
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(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<ushort>();
|
||
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);
|
||
// |align|: the vertex-fan winding convention can flip the
|
||
// computed normal vs the physics plane's true facing — accept
|
||
// both signs (2026-07-06 sweep flaw fix).
|
||
if (MathF.Abs(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)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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<EnvCell>(cellId);
|
||
Assert.NotNull(envCell);
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(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<System.Numerics.Vector3>();
|
||
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)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-07-06 gate-session follow-up: seam crossings SUCCEED at
|
||
/// y≈−40.8..−41.2 and BLOCK at y≈−39.5..−39.8 (cell-transit log,
|
||
/// launch-137-corridor-gate.log). A y-dependent boundary with no physics
|
||
/// polygon culprit points at the PORTAL POLYGONS — if the doorway
|
||
/// openings don't span the full corridor width, the transit/membership
|
||
/// machinery only hands the sphere to the neighbor inside the portal
|
||
/// poly's span. Dump every portal polygon's world-space vertex extent.
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(0x8A02016Eu)]
|
||
[InlineData(0x8A02017Au)]
|
||
public void CorridorCell_PortalPolygonWorldSpans(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<EnvCell>(envCellId);
|
||
Assert.NotNull(envCell);
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(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{envCellId:X8} portal polygons (world spans) ===");
|
||
foreach (var p in envCell.CellPortals)
|
||
{
|
||
if (!cs!.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
|
||
{
|
||
_out.WriteLine($" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} {p.Flags}: NOT in visual set");
|
||
continue;
|
||
}
|
||
|
||
var min = new System.Numerics.Vector3(float.MaxValue);
|
||
var max = new System.Numerics.Vector3(float.MinValue);
|
||
foreach (var vid in poly.VertexIds)
|
||
{
|
||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) continue;
|
||
var w = System.Numerics.Vector3.Transform(v.Origin, world);
|
||
min = System.Numerics.Vector3.Min(min, w);
|
||
max = System.Numerics.Vector3.Max(max, w);
|
||
}
|
||
_out.WriteLine(
|
||
$" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
|
||
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
|
||
$"verts={poly.VertexIds.Count}");
|
||
}
|
||
}
|
||
}
|