test(phys): A6.P3 #98 — harness extension: synthetic stair GfxObj + ShadowEntry
Extends the trajectory replay harness with a programmatic mini-stair piece, reconstructed from the live capture's polydump data (docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump). NEW finding: the cellar ramp polygon is NOT in cellStruct.PhysicsPolygons. It lives in a separate GfxObjPhysics (the cellar's stair-piece static building) registered via ShadowObjectRegistry, queried via FindObjCollisions → engine.DataCache.GetGfxObj. CellDumpSerializer is CORRECT — it captures the cell's physics polygons accurately. The ramp polygon comes from a different data source entirely. The polydump probe at BSPQuery.AdjustSphereToPlane:402 reports "cell=0xA9B40147 polyId=0x0008 sides=Landblock" because the SPHERE is in that cell at hit time — but the polygon's actual source is the building's GfxObj. Inside the cellar fixture, polyId=0x0008 happens to be a wall (Normal=(1,0,0)); inside the building's GfxObj, polyId =0x0008 is the ramp (Normal=(0,-0.719,0.695) local). Same ID, different collection. The new RegisterStairRampGfxObj() in the harness constructs the building's ramp polygon in WORLD coordinates (translated from local building frame + 180° yaw), wraps it in a minimal one-leaf PhysicsBSPTree, registers via cache.RegisterGfxObjForTest, and attaches a ShadowEntry with cellScope=CellarId so the shadow is only queried when the sphere is in the cellar cell (matches retail's per-cell shadow scoping for interior statics — Issue #91 family). Verified: world plane n=(0,0.719,0.695), d=-69.5035 (matches live cdb capture exactly to 4 sig figs). Ramp foot at world Y=8.745, Z=90.955; ramp top at world Y=5.845, Z=93.955. 3.0 m vertical rise. NEW blocker discovered: the sphere goes airborne at tick 1 (same issue documented in the prior commit's Finding #2). Sphere FLOATS at Z=91.43 over the cellar floor, never contacts the synthetic ramp. The synthetic stair registration mechanics are validated (the GfxObj is in the cache, the ShadowEntry is in the registry, the BSP tree is well-formed) — but trajectory replay still blocked on the seeded-grounded-state bug. Next session needs to diagnose WHY the engine reports "hit=yes n=(0,0,1) walkable=False" on tick 1 for a sphere correctly seeded as grounded on the cellar floor. Test baseline maintained: 1167 + 4 (harness) = 1171 + 8 pre-existing failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4c9290c691
commit
3d2d10b331
1 changed files with 161 additions and 28 deletions
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -227,15 +230,28 @@ public class CellarUpTrajectoryReplayTests
|
|||
bool CpValid);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="PhysicsEngine"/> 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 <see cref="PhysicsEngine"/> with:
|
||||
/// <list type="bullet">
|
||||
/// <item>The three issue-#98 cottage/cellar cell fixtures registered.</item>
|
||||
/// <item>A stub landblock so <c>TryGetLandblockContext</c> succeeds
|
||||
/// at the cellar XY (needed for FindObjCollisions to query
|
||||
/// the shadow registry).</item>
|
||||
/// <item>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
|
||||
/// <c>[poly-dump]</c> data
|
||||
/// (<c>docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log</c>),
|
||||
/// transformed to world coordinates so the registered object
|
||||
/// sits at world origin with identity rotation/scale.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
|
||||
RegisterStairRampGfxObj(engine, cache);
|
||||
|
||||
return (engine, cache);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>[poly-dump]</c> 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).
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<ushort, ResolvedPolygon>
|
||||
{
|
||||
[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<ushort, Polygon>(),
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue