fix #137 (window climb): the player's collision capsule topped out at 1.2m — head sphere 0.63m too low
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>
This commit is contained in:
parent
1494f10cb5
commit
aa96d7ad77
6 changed files with 285 additions and 13 deletions
|
|
@ -29,6 +29,8 @@ public class Issue137CorridorSeamInspectionTests
|
|||
[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")
|
||||
|
|
@ -177,6 +179,94 @@ public class Issue137CorridorSeamInspectionTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
|
|
|||
|
|
@ -246,6 +246,165 @@ public class Issue137CorridorSeamReplayTests
|
|||
$"advance with the reversed-movement normal = the seam shake.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log):
|
||||
/// running from the ramp top in 0x8A020179 into the corridor-end opening
|
||||
/// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the
|
||||
/// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) →
|
||||
/// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head
|
||||
/// (and camera) through the opening's roof. The opening is ~1.3 m tall
|
||||
/// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry
|
||||
/// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom:
|
||||
/// "should not be able to run into it".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WindowOpening_HeadCannotFit_EntryBlocked()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level
|
||||
body.ContactPlaneCellId = 0x8A020179u;
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
|
||||
// Walk the live approach (ramp-top toward the corridor-end opening)
|
||||
// so the engine self-accumulates its contact-plane/walkable state,
|
||||
// then push into the opening for several held-key frames (the live
|
||||
// climb happened under a held key, not a single resolve).
|
||||
var pos = new Vector3(88.60f, -41.10f, -5.05f);
|
||||
uint cell = 0x8A020179u;
|
||||
ResolveResult r = default;
|
||||
bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1";
|
||||
for (int i = 0; i < 22; i++)
|
||||
{
|
||||
var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f));
|
||||
var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f;
|
||||
|
||||
var probeBuffer = new System.IO.StringWriter();
|
||||
var prevOut = Console.Out;
|
||||
try
|
||||
{
|
||||
if (probeFrames && i >= 9)
|
||||
{
|
||||
Console.SetOut(probeBuffer);
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||
}
|
||||
r = engine.ResolveWithTransition(
|
||||
currentPos: pos,
|
||||
targetPos: pos + step,
|
||||
cellId: cell,
|
||||
sphereRadius: 0.48f,
|
||||
// #137: the corrected capsule top (dat Setup 0x02000001,
|
||||
// head sphere center 1.350 → top 1.830; Height 1.835).
|
||||
// The live climb happened under the old 1.2f (head top
|
||||
// 1.2 m — no head collision at the lintel).
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: 0.6f,
|
||||
stepDownHeight: 1.5f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (probeFrames && i >= 9)
|
||||
{
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||
Console.SetOut(prevOut);
|
||||
}
|
||||
}
|
||||
if (probeFrames && i >= 9 && i <= 10)
|
||||
_out.WriteLine(probeBuffer.ToString());
|
||||
_out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
|
||||
$"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})");
|
||||
pos = r.Position;
|
||||
cell = r.CellId;
|
||||
|
||||
Assert.NotEqual(0x8A02017Eu, r.CellId);
|
||||
Assert.True(r.Position.Y > -41.6f,
|
||||
$"A 1.68 m character must not enter the 1.3 m-tall opening " +
|
||||
$"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " +
|
||||
$"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " +
|
||||
$"0x8A02017E, head through the roof).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window-climb's placement half, pinned at the exact site: at the
|
||||
/// step-up's raised position on the alcove sill (foot −5.019), the HEAD
|
||||
/// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south
|
||||
/// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's
|
||||
/// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's
|
||||
/// step-down placement insert (CTransition::step_down 0x0050b3b3 →
|
||||
/// placement transitional_insert → BSPTREE::sphere_intersects_solid
|
||||
/// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable.
|
||||
/// Our placement passed (the live + offline climb), so our Path-1 solid
|
||||
/// test misses the head-vs-solid overlap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
|
||||
var cell = engine.DataCache!.GetCellStruct(0x8A020179u);
|
||||
Assert.NotNull(cell);
|
||||
Assert.NotNull(cell!.BSP?.Root);
|
||||
|
||||
// The raised (post-sill-climb) pose from the offline repro's r9.
|
||||
var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER
|
||||
var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER
|
||||
|
||||
var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform);
|
||||
var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform);
|
||||
|
||||
var t = new Transition();
|
||||
t.SpherePath.InitPath(
|
||||
new Vector3(89.683f, -41.247f, -5.019f),
|
||||
new Vector3(89.683f, -41.247f, -5.019f),
|
||||
0x8A020179u, 0.48f, 1.2f);
|
||||
t.SpherePath.InsertType = InsertType.Placement;
|
||||
|
||||
Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin);
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
cell.BSP!.Root,
|
||||
cell.Resolved,
|
||||
t,
|
||||
new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f },
|
||||
new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f },
|
||||
footLocal,
|
||||
Vector3.UnitZ,
|
||||
1.0f,
|
||||
cellRot,
|
||||
engine,
|
||||
worldOrigin: cellOrigin);
|
||||
|
||||
_out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " +
|
||||
$"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})");
|
||||
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing
|
||||
/// at (84.851, −39.764, −6.000) — the foot sphere already straddling the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue