Replays the captured corner/exit "escape" sweeps (corner-seal-capture.log) through the real ResolveWithTransition with the camera probe call shape. Geometry-map diagnostic proves every zero-contact traversal runs through a REAL opening: the 0170 exit-door portal for the viewer-outdoor frames; the 0171↔0173↔0172 doorway chain (0173 = 20 cm threshold cell) for the corner-press frames. Captured eyes land inside door-opening rectangles; the containment walk shows no path point in solid cell volume; 8,703/14,230 indoor sweeps in the same capture collided correctly (pull-in up to 2.77 m) — camera collision works, nothing penetrates. The user-visible "background at the corner" is therefore NOT collision — it is the §2a edge-on portal-clip collapse with the eye hovering at the doorway plane. Both §4 siblings converge on one render-side mechanism: edge-on clip collapse near opening planes. Next per the 2026-06-08 handoff §5.3: read retail's edge-on clip oracle (PView::GetClip :432344, PView::ClipPortals :433572, polyClipFinish :702749) before designing the fix. Assertion fact = characterization pinning the verified behavior (openings pass clean); Diagnostic fact = dispatch trace + room map + containment apparatus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
14 KiB
C#
293 lines
14 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Tests.Conformance;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// §2b corner camera-seal replay (2026-06-10) — INVESTIGATION RESULT: the
|
|
/// "camera penetrates the wall at corners" hypothesis is REFUTED. Replays the
|
|
/// captured "escape" camera sweeps from <c>corner-seal-capture.log</c> through the
|
|
/// REAL <see cref="PhysicsEngine.ResolveWithTransition"/> with the camera's exact
|
|
/// call shape (<see cref="ObjectInfoState.IsViewer"/> | PathClipped | FreeRotate |
|
|
/// PerfectClip, single 0.3 m sphere — mirrors
|
|
/// src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.SweepEye).
|
|
///
|
|
/// WHAT THE REPLAY + GEOMETRY MAP PROVED (Diagnostic fact below):
|
|
/// every captured zero-contact traversal runs through a REAL OPENING, not a wall —
|
|
/// the building's exit-door portal (0170 → 0xFFFF) for the viewer-outdoor frames, and
|
|
/// the 0171↔0173↔0172 doorway chain (0173 is a 20 cm threshold cell — two parallel
|
|
/// portal planes at local x=4.10/3.90) for the corner-press frames. The captured eyes
|
|
/// land INSIDE the door openings' rectangles; the containment walk shows no path point
|
|
/// inside solid cell volume. 8,703 of 14,230 indoor sweeps in the same capture DID
|
|
/// collide (pull-in up to 2.77 m) — camera collision works; nothing penetrates.
|
|
/// The user-visible "background at the corner" is therefore NOT a collision bug: it is
|
|
/// the §2a edge-on portal-clip collapse with the eye hovering at the doorway plane
|
|
/// (render-side; see docs/research/2026-06-10 corner-seal handoff).
|
|
///
|
|
/// The assertion fact below is a CHARACTERIZATION pinning the verified-correct
|
|
/// behavior: viewer sweeps along these captured opening paths must pass WITHOUT
|
|
/// collision (a future change that makes doorway air solid would break the camera).
|
|
///
|
|
/// Capture provenance (worktree root, untracked): corner-seal-capture.log
|
|
/// L20104 / L20224 / L20386 (door-exit) + L23035 / L81340 / L87958 (corner press);
|
|
/// pivot = player + (0,0,1.5) per RetailChaseCamera.PivotHeight.
|
|
/// </summary>
|
|
public class CameraCornerSealReplayTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public CameraCornerSealReplayTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private const float ViewerSphereRadius = 0.3f; // retail viewer_sphere (acclient :93314)
|
|
private const float PivotHeight = 1.5f; // RetailChaseCamera.PivotHeight
|
|
|
|
// Captured corner-escape samples: (label, start cellId, player feet position,
|
|
// desired eye = the [flap-sweep] in= value). Player from the same frame's
|
|
// [pv-input]; S1 has both the render-interpolated and raw physics player (1 cm
|
|
// apart) — both must stop at the wall, so both are exercised.
|
|
private static readonly (string Label, uint CellId, Vector3 Player, Vector3 Eye)[] Samples =
|
|
{
|
|
("S1-render 0170", 0xA9B40170u, new Vector3(154.934845f, 16.451527f, 94.000000f),
|
|
new Vector3(154.797028f, 19.320539f, 96.248833f)),
|
|
("S1-raw 0170", 0xA9B40170u, new Vector3(154.945374f, 16.451527f, 94.000000f),
|
|
new Vector3(154.797028f, 19.320539f, 96.248833f)),
|
|
("S2 0171", 0xA9B40171u, new Vector3(155.164307f, 14.392493f, 94.000000f),
|
|
new Vector3(154.804489f, 18.434128f, 96.248833f)),
|
|
("S3 0171", 0xA9B40171u, new Vector3(155.475723f, 11.463923f, 94.000000f),
|
|
new Vector3(154.990143f, 16.249460f, 96.248833f)),
|
|
};
|
|
|
|
private static (PhysicsEngine, PhysicsDataCache,
|
|
System.Collections.Generic.Dictionary<uint, AcDream.Core.World.Cells.EnvCell>)
|
|
BuildBuildingEngine(DatCollection dats)
|
|
{
|
|
// Same fixture as ThresholdPortalCrossingReplayTests.BuildBuildingEngine:
|
|
// the full Holtburg building cell range + a stub far-below landblock.
|
|
var cache = new PhysicsDataCache();
|
|
var engine = new PhysicsEngine { DataCache = cache };
|
|
var envCells = new System.Collections.Generic.Dictionary<uint, AcDream.Core.World.Cells.EnvCell>();
|
|
for (uint low = 0x016Fu; low <= 0x0175u; low++)
|
|
{
|
|
uint id = ConformanceDats.HoltburgLandblock | low;
|
|
envCells[id] = ConformanceDats.LoadEnvCell(dats, cache, id);
|
|
}
|
|
|
|
var heights = new byte[81];
|
|
var heightTable = new float[256];
|
|
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
|
engine.AddLandblock(0xA9B40000u, new TerrainSurface(heights, heightTable),
|
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
|
return (engine, cache, envCells);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirror of PhysicsCameraCollisionProbe.SweepEye's transition call (the App-layer
|
|
/// probe is not referencable from Core tests; the call shape is duplicated here and
|
|
/// kept in sync by the file-header provenance note).
|
|
/// </summary>
|
|
private static ResolveResult SweepViewer(PhysicsEngine engine, Vector3 pivot, Vector3 desiredEye, uint cellId)
|
|
{
|
|
uint startCell = cellId;
|
|
if ((cellId & 0xFFFFu) >= 0x0100u)
|
|
{
|
|
var (pivotCell, found) = engine.AdjustPosition(cellId, pivot);
|
|
if (found) startCell = pivotCell;
|
|
}
|
|
|
|
// InitPath sphere-center convention: shift down by the radius (ToSpherePath).
|
|
Vector3 begin = pivot - new Vector3(0f, 0f, ViewerSphereRadius);
|
|
Vector3 end = desiredEye - new Vector3(0f, 0f, ViewerSphereRadius);
|
|
|
|
return engine.ResolveWithTransition(
|
|
currentPos: begin,
|
|
targetPos: end,
|
|
cellId: startCell,
|
|
sphereRadius: ViewerSphereRadius,
|
|
sphereHeight: 0f,
|
|
stepUpHeight: 0f,
|
|
stepDownHeight: 0f,
|
|
isOnGround: false,
|
|
body: null,
|
|
moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped
|
|
| ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip,
|
|
movingEntityId: 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostic (no assertions): per-sweep BSP dispatch trace via the A6.P1
|
|
/// [push-back] probe family. Compares the LEAKING camera path (S1) against two
|
|
/// controls into the same north boundary: a horizontal viewer-style sweep at
|
|
/// chest height and a player-style grounded sweep. Outcome decides whether the
|
|
/// room's exterior boundary is sealed in the cell physics BSP at all (controls
|
|
/// collide ⇒ path/angle-specific miss) or genuinely open (nothing collides ⇒
|
|
/// the building-shell GfxObj is the only enclosure and the live isViewer
|
|
/// shadow-query path is the half that failed).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Diagnostic_DispatchTrace_LeakPath_vs_Controls()
|
|
{
|
|
var datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
var (engine, cache, envCells) = BuildBuildingEngine(dats);
|
|
|
|
var s = Samples[0]; // S1-render 0170
|
|
Vector3 pivot = s.Player + new Vector3(0f, 0f, PivotHeight);
|
|
|
|
RunTraced(engine, "LEAK viewer pivot->eye", () =>
|
|
SweepViewer(engine, pivot, s.Eye, s.CellId));
|
|
|
|
RunTraced(engine, "CTRL-H viewer horizontal +3Y", () =>
|
|
SweepViewer(engine, pivot, pivot + new Vector3(0f, 3f, 0f), s.CellId));
|
|
|
|
RunTraced(engine, "CTRL-P player grounded +3Y", () =>
|
|
engine.ResolveWithTransition(
|
|
currentPos: s.Player,
|
|
targetPos: s.Player + new Vector3(0f, 3f, 0f),
|
|
cellId: s.CellId,
|
|
sphereRadius: 0.4f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.1f,
|
|
isOnGround: true,
|
|
body: null,
|
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0));
|
|
|
|
// ── Geometry map: what does the leak path actually cross? ──────────────
|
|
_out.WriteLine("=== containment along LEAK path (pivot -> eye, 11 steps) ===");
|
|
for (int i = 0; i <= 10; i++)
|
|
{
|
|
Vector3 p = Vector3.Lerp(pivot, s.Eye, i / 10f);
|
|
string inCells = "";
|
|
foreach (var kv in envCells)
|
|
if (kv.Value.PointInCell(p))
|
|
inCells += $" 0x{kv.Key & 0xFFFFu:X4}";
|
|
if (inCells.Length == 0) inCells = " (none)";
|
|
_out.WriteLine(System.FormattableString.Invariant(
|
|
$" t={i / 10f:F1} ({p.X:F2},{p.Y:F2},{p.Z:F2}) ->{inCells}"));
|
|
}
|
|
|
|
_out.WriteLine("=== full room map: origin + portals (cell-LOCAL polygon bounds) ===");
|
|
foreach (uint low in new uint[] { 0x016Fu, 0x0170u, 0x0171u, 0x0172u, 0x0173u, 0x0174u, 0x0175u })
|
|
{
|
|
var cp = cache.GetCellStruct(ConformanceDats.HoltburgLandblock | low);
|
|
if (cp is null) { _out.WriteLine($" 0x{low:X4}: no CellPhysics"); continue; }
|
|
var o = cp.WorldTransform.Translation;
|
|
_out.WriteLine(System.FormattableString.Invariant(
|
|
$" 0x{low:X4} origin=({o.X:F2},{o.Y:F2},{o.Z:F2}) portals={cp.Portals.Count}"));
|
|
foreach (var portal in cp.Portals)
|
|
{
|
|
string bounds = "(polygon missing)";
|
|
if (cp.PortalPolygons is not null
|
|
&& cp.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
|
|
{
|
|
Vector3 min = new(float.MaxValue), max = new(float.MinValue);
|
|
foreach (var v in poly.Vertices)
|
|
{
|
|
min = Vector3.Min(min, v);
|
|
max = Vector3.Max(max, v);
|
|
}
|
|
bounds = System.FormattableString.Invariant(
|
|
$"x[{min.X:F2},{max.X:F2}] y[{min.Y:F2},{max.Y:F2}] z[{min.Z:F2},{max.Z:F2}]");
|
|
}
|
|
_out.WriteLine(System.FormattableString.Invariant(
|
|
$" -> other=0x{portal.OtherCellId:X4} poly={portal.PolygonId} {bounds}"));
|
|
}
|
|
}
|
|
|
|
// ── Containment for the live 0172->0171 corner frames (captured eye points
|
|
// + their pivots). The corner press lived in room 0172; the viewer classified
|
|
// into 0171 (2,963 frames). Which cell volume actually contains those points?
|
|
_out.WriteLine("=== containment: corner-press eyes + pivots (player=0172 viewer=0171 frames) ===");
|
|
var cornerPoints = new (string Label, Vector3 P)[]
|
|
{
|
|
("eyeA (L23035)", new Vector3(154.607880f, 11.154252f, 96.248787f)),
|
|
("eyeB (L81340)", new Vector3(157.477249f, 7.912723f, 96.248863f)),
|
|
("eyeC (L87958)", new Vector3(157.452667f, 7.914233f, 96.248856f)),
|
|
("pivotA", new Vector3(157.976959f, 8.622595f, 95.500000f)),
|
|
("pivotB/C", new Vector3(159.936676f, 7.701012f, 95.500000f)),
|
|
};
|
|
foreach (var (label, p) in cornerPoints)
|
|
{
|
|
string inCells = "";
|
|
foreach (var kv in envCells)
|
|
if (kv.Value.PointInCell(p))
|
|
inCells += $" 0x{kv.Key & 0xFFFFu:X4}";
|
|
if (inCells.Length == 0) inCells = " (none)";
|
|
_out.WriteLine(System.FormattableString.Invariant(
|
|
$" {label} ({p.X:F2},{p.Y:F2},{p.Z:F2}) ->{inCells}"));
|
|
}
|
|
}
|
|
|
|
private void RunTraced(PhysicsEngine engine, string label, Func<ResolveResult> sweep)
|
|
{
|
|
var sw = new StringWriter();
|
|
var prev = Console.Out;
|
|
ResolveResult r;
|
|
try
|
|
{
|
|
Console.SetOut(sw);
|
|
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
|
r = sweep();
|
|
}
|
|
finally
|
|
{
|
|
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
|
Console.SetOut(prev);
|
|
}
|
|
|
|
var lines = sw.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
_out.WriteLine(System.FormattableString.Invariant(
|
|
$"=== {label}: end=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) cell=0x{r.CellId:X8} collNorm={r.CollisionNormalValid} ok={r.Ok} probeLines={lines.Length} ==="));
|
|
for (int i = 0; i < lines.Length && i < 40; i++)
|
|
_out.WriteLine(" " + lines[i].TrimEnd());
|
|
if (lines.Length > 40)
|
|
_out.WriteLine($" ... +{lines.Length - 40} more");
|
|
}
|
|
|
|
[Fact]
|
|
public void ViewerSweep_ThroughOpenings_PassesWithoutCollision()
|
|
{
|
|
var datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
var (engine, _, _) = BuildBuildingEngine(dats);
|
|
|
|
var failures = new System.Collections.Generic.List<string>();
|
|
foreach (var s in Samples)
|
|
{
|
|
Vector3 pivot = s.Player + new Vector3(0f, 0f, PivotHeight);
|
|
float desiredBack = Vector3.Distance(pivot, s.Eye);
|
|
|
|
var r = SweepViewer(engine, pivot, s.Eye, s.CellId);
|
|
|
|
Vector3 eyeOut = r.Position + new Vector3(0f, 0f, ViewerSphereRadius);
|
|
float eyeBack = Vector3.Distance(pivot, eyeOut);
|
|
float pulledIn = desiredBack - eyeBack;
|
|
|
|
// Characterization (see file header): these captured paths run through
|
|
// real openings (exit door / doorway-threshold chain). The geometry-map
|
|
// diagnostic verified no solid is crossed, so the sweep must pass clean —
|
|
// no collision, full traversal.
|
|
bool passedClean = !r.CollisionNormalValid && pulledIn < 0.10f && r.Ok;
|
|
|
|
_out.WriteLine(System.FormattableString.Invariant(
|
|
$"{s.Label}: ok={r.Ok} collNorm={r.CollisionNormalValid} desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={pulledIn:F2} endCell=0x{r.CellId:X8} {(passedClean ? "CLEAN" : "OBSTRUCTED")}"));
|
|
|
|
if (!passedClean)
|
|
failures.Add(s.Label);
|
|
}
|
|
|
|
Assert.True(failures.Count == 0,
|
|
"Viewer sweep through a verified-open doorway path was obstructed or cut short " +
|
|
"(camera would wrongly pull in at openings) for: " + string.Join(", ", failures));
|
|
}
|
|
}
|