diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
index f681707..762e602 100644
--- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
@@ -4,6 +4,8 @@ using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
@@ -136,33 +138,34 @@ public class CellarUpTrajectoryReplayTests
}
///
- /// Documents finding #1: cellar fixture is missing the ramp
- /// polygon. With only axis-aligned cellar/cottage geometry, the
- /// sphere walks horizontally and the trajectory's max-Z equals
- /// the starting Z. When fixtures are re-captured with the ramp,
- /// flip this assertion (and rename the test).
+ /// Diagnostic dump: print the first 10 trajectory points + the
+ /// engine's resolve-probe decisions. Useful when investigating
+ /// what the harness is actually doing.
///
[Fact]
- public void Harness_FixtureLimitation_NoRampPolygon()
+ public void Harness_DiagnosticDump_FirstTenTicks()
{
- var (engine, _) = BuildEngineWithCellarFixtures();
- var body = BuildInitialBody();
- var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks);
+ PhysicsDiagnostics.ProbeResolveEnabled = true;
+ try
+ {
+ var (engine, _) = BuildEngineWithCellarFixtures();
+ var body = BuildInitialBody();
+ var trajectory = SimulateTicks(engine, body, CellarId, 10);
- var maxZ = trajectory.Max(t => t.Position.Z);
- var startZ = InitialSphereWorld.Z;
+ var msg = "Trajectory (10 ticks):\n " +
+ string.Join("\n ", trajectory.Select(p =>
+ $"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
+ $"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}"));
- // CURRENT behavior: maxZ == startZ because there's no ramp
- // polygon in the fixtures. When the fixtures are re-captured
- // and include the ramp, this assertion must be flipped to
- // require maxZ >= 93.5f (sphere reaches cottage floor).
- Assert.True(
- MathF.Abs(maxZ - startZ) < 0.01f,
- $"Harness limitation documented: cellar fixture has no ramp " +
- $"polygon, so the sphere should not gain altitude. If this " +
- $"fails, the fixture was re-captured — flip this test to " +
- $"require a successful climb. " +
- $"maxZ={maxZ:F4}, startZ={startZ:F4}, Δ={maxZ - startZ:F4}.");
+ // Always pass — this is a diagnostic test; the resolve
+ // probe output appears in the test runner's captured stdout
+ // and the trajectory in the assertion message on failure.
+ Assert.True(true, msg);
+ }
+ finally
+ {
+ PhysicsDiagnostics.ProbeResolveEnabled = false;
+ }
}
///
@@ -227,15 +230,28 @@ public class CellarUpTrajectoryReplayTests
bool CpValid);
///
- /// Builds a with the three issue-#98
- /// cottage/cellar cell fixtures registered. No landblock is
- /// registered — the indoor BSP path takes over because the cell
- /// IDs have low byte ≥ 0x100.
+ /// Builds a with:
+ ///
+ /// - The three issue-#98 cottage/cellar cell fixtures registered.
+ /// - A stub landblock so TryGetLandblockContext succeeds
+ /// at the cellar XY (needed for FindObjCollisions to query
+ /// the shadow registry).
+ /// - A SYNTHETIC stair-piece GfxObj containing the cellar ramp
+ /// polygon, registered as a ShadowEntry scoped to the cellar
+ /// cell. Reconstructed programmatically from the live-capture
+ /// [poly-dump] data
+ /// (docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log),
+ /// transformed to world coordinates so the registered object
+ /// sits at world origin with identity rotation/scale.
+ ///
///
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithCellarFixtures()
{
- var cache = new PhysicsDataCache();
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ // ── 1. Cell fixtures (existing) ─────────────────────────────
foreach (var cellId in new[] { CellarId, CottageNeighborA, CottageNeighborB })
{
var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
@@ -247,7 +263,124 @@ public class CellarUpTrajectoryReplayTests
cache.RegisterCellStructForTest(cellId, cell);
}
- return (new PhysicsEngine { DataCache = cache }, cache);
+ // ── 2. Stub landblock so TryGetLandblockContext succeeds ───
+ // FindObjCollisions early-returns if no landblock covers the
+ // sphere's XY. The cellar is in the world's first landblock
+ // (worldOffset 0,0 covers 0..192m). We don't need real terrain
+ // for indoor BSP collision — minimal heights array suffices.
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)0);
+ var heightTab = new float[256];
+ for (int i = 0; i < 256; i++) heightTab[i] = i * 1.0f;
+ engine.AddLandblock(
+ landblockId: 0xA9B40000u,
+ terrain: new TerrainSurface(heights, heightTab),
+ cells: Array.Empty(),
+ portals: Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ // ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
+ RegisterStairRampGfxObj(engine, cache);
+
+ return (engine, cache);
+ }
+
+ ///
+ /// Constructs a synthetic GfxObj containing the cellar ramp polygon
+ /// in WORLD coordinates and registers it as a ShadowEntry scoped to
+ /// the cellar cell. The polygon's vertices + normal are reproduced
+ /// from the live capture's [poly-dump] data (commit pre-3f56915),
+ /// transformed to world frame so the GfxObj can sit at world origin
+ /// with identity rotation/scale (simplifies the
+ /// FindObjCollisions local-to-world transform).
+ ///
+ ///
+ /// Live capture's local polygon vertices (in building frame):
+ /// (0.8,-1.59,-1.5), (0.8,1.31,1.5), (-0.8,1.31,1.5), (-0.8,-1.59,-1.5).
+ /// Building's world transform: origin (141.5, 7.155, 92.455), 180° yaw
+ /// around Z. After applying yaw + translation, world vertices are:
+ /// (140.7, 8.745, 90.955), (140.7, 5.845, 93.955),
+ /// (142.3, 5.845, 93.955), (142.3, 8.745, 90.955).
+ /// World normal = (0, 0.719, 0.695), world d = -69.5035 — matches
+ /// the live cdb capture exactly.
+ ///
+ ///
+ private static void RegisterStairRampGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
+ {
+ const ushort RampPolyId = 0x0008;
+ const uint StairGfxId = 0xDEADBEEFu;
+ const uint StairEntityId = 0xC0FFEE00u;
+
+ // World-frame vertices (winding order preserved from live capture).
+ var v0 = new Vector3(140.7f, 8.745f, 90.955f); // ramp foot, X=-side
+ var v1 = new Vector3(140.7f, 5.845f, 93.955f); // ramp top, X=-side
+ var v2 = new Vector3(142.3f, 5.845f, 93.955f); // ramp top, X=+side
+ var v3 = new Vector3(142.3f, 8.745f, 90.955f); // ramp foot, X=+side
+ var verts = new[] { v0, v1, v2, v3 };
+
+ // Compute normal from cross(v1-v0, v3-v0).
+ var edge0 = v1 - v0;
+ var edge1 = v3 - v0;
+ var normal = Vector3.Normalize(Vector3.Cross(edge0, edge1));
+ // Plane equation: N·p + d = 0 → d = -N·v0.
+ float d = -Vector3.Dot(normal, v0);
+
+ var resolved = new Dictionary
+ {
+ [RampPolyId] = new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new System.Numerics.Plane(normal, d),
+ NumPoints = 4,
+ SidesType = CullMode.Landblock,
+ },
+ };
+
+ // Minimal one-leaf BSP containing the ramp poly. Bounding sphere
+ // encompasses the polygon (center at poly centroid).
+ var leaf = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere
+ {
+ Origin = new Vector3(141.5f, 7.295f, 92.455f),
+ Radius = 3.0f,
+ },
+ };
+ leaf.Polygons.Add(RampPolyId);
+
+ var bspTree = new PhysicsBSPTree { Root = leaf };
+
+ var gfxPhysics = new GfxObjPhysics
+ {
+ BSP = bspTree,
+ PhysicsPolygons = new Dictionary(),
+ Vertices = new VertexArray(),
+ Resolved = resolved,
+ BoundingSphere = leaf.BoundingSphere,
+ };
+
+ cache.RegisterGfxObjForTest(StairGfxId, gfxPhysics);
+
+ // ShadowEntry: object at world origin (0,0,0), identity rotation,
+ // scale 1.0 — keeps the polygon's WORLD-frame vertices intact
+ // through the FindObjCollisions local-transform math.
+ // cellScope = CellarId so the entry is only queried when the sphere
+ // is in cellar cell (matches retail's per-cell shadow scoping for
+ // interior statics — Issue #91 family).
+ engine.ShadowObjects.Register(
+ entityId: StairEntityId,
+ gfxObjId: StairGfxId,
+ worldPos: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ radius: 5.0f,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: 0xA9B40000u,
+ collisionType: ShadowCollisionType.BSP,
+ scale: 1.0f,
+ cellScope: CellarId);
}
///