Member-wise deletion of the three legacy resolver members named in docs/research/2026-08-05-c5a-contract.md: PhysicsEngine.Resolve, PhysicsEngine.HasCellSurface, and PhysicsEngine.ResolvePlacement. An exhaustive receiver census over src/ found zero production callers of any of the three — every production placement writer already reaches the canonical PhysicsEngine.SetPosition transaction exclusively through RuntimeSetPositionState (three call sites total). The deletion is purely member-wise: IsSpawnCellReady and AdjustPosition, which shared the same source region as the deleted members, are preserved byte-identical — every remaining production caller of either (including PhysicsCameraCollisionProbe, AdjustPosition's sole surviving production caller) is unaffected. Companion changes: - PlayerMovementController's 3-argument SetPosition test overload is renamed to SeedPlacementForTest (internal) and CommitPreparedPosition is deleted; 83 call sites across 19 test files were mechanically renamed to match. - Seven pinned test dispositions from the contract are executed: 3.1 (PhysicsEngineTests.cs: 11 legacy-resolver tests deleted, 6 ResolveWithTransition tests kept), 3.2/3.3/3.4 (re-point to canonical SetPosition, with TransitionScratchDifferentialTests.cs additionally gaining positive IsCommitted assertions after each bitwise comparison so the differential proves a placement actually committed, not just that two possibly-uncommitted results match), 3.5 (Runtime rename), and 3.6 (PlayerMovementPlacementTransactionTests.cs rewritten — its xmldoc now states plainly that the render-root publish moved to RuntimeSetPositionState.cs, but the sticky-release relocation claim was false and is retracted; this disposition's coverage loss is the sticky release path, not silently absorbed elsewhere). - Stale `PhysicsEngine.Resolve`/`Resolve` doc citations in CellTransit.cs, PlayerMovementController.cs, and HeadlessSessionWorldProjection.cs are corrected to name the surviving canonical entry points by symbol (SetPosition, AdjustSetPosition/AdjustPosition, ResolveWithTransition) rather than fragile line numbers. Retires AP-1 and AD-1 in docs/architecture/retail-divergence-register.md: both rows described production zero-delta placement routing remaining on the legacy resolver pending the Slice 4B2/4B route cutover; that resolver no longer exists, so the condition each row tracked is now structurally false rather than merely narrowed. AP-145 (routed through the prior commit) and this commit's AP-1/AD-1 together bring the section counts to 101 AP / 47 AD active rows. Builds on the AP-145 fix (previous commit) — this commit's staged tree was independently rebuilt and its four suites independently rerun on top of that commit before this commit was created, in addition to the combined rebuild/rerun below. Full-solution build: 0 errors (21 pre-existing warnings, all unrelated). Suite results (combined tree): Core 4270/4271 passed (1 skip; the single DatSoundCacheTests concurrent-decode-dedup failure is a known load-sensitive race, confirmed passing standalone and unrelated to this change), Runtime 1176/1176, Headless 86/86, App 4132/4135 (3 skips). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
283 lines
12 KiB
C#
283 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
public class PhysicsEngineTests
|
|
{
|
|
private static float[] LinearHeightTable()
|
|
{
|
|
var table = new float[256];
|
|
for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
|
|
return table;
|
|
}
|
|
|
|
private static byte[] FlatHeightmap(byte value = 50)
|
|
{
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, value);
|
|
return heights;
|
|
}
|
|
|
|
private PhysicsEngine MakeFlatEngine(float terrainZ = 50f)
|
|
{
|
|
var engine = new PhysicsEngine();
|
|
var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable());
|
|
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
|
return engine;
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId()
|
|
{
|
|
var engine = MakeFlatEngine(terrainZ: 50f);
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: new Vector3(23f, 10f, 50f),
|
|
targetPos: new Vector3(25f, 10f, 50f),
|
|
cellId: 0x0001u,
|
|
sphereRadius: 0.5f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.4f,
|
|
isOnGround: true);
|
|
|
|
Assert.True(result.IsOnGround);
|
|
Assert.InRange(result.Position.X, 24.9f, 25.1f);
|
|
// Phase D fix: ResolveOutdoorCellId now always applies the matched
|
|
// landblock's high-16 prefix — 0xA9B4 prefix from the registered
|
|
// landblock (0xA9B4FFFF) is now included in the returned CellId.
|
|
Assert.Equal(0xA9B40009u, result.CellId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveWithTransition_EdgeSlideFlag_AllowsNormalFlatMovement()
|
|
{
|
|
var engine = MakeFlatEngine(terrainZ: 50f);
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: new Vector3(96f, 96f, 50f),
|
|
targetPos: new Vector3(98f, 96f, 50f),
|
|
cellId: 0x0025u,
|
|
sphereRadius: 0.5f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.4f,
|
|
isOnGround: true,
|
|
moverFlags: ObjectInfoState.EdgeSlide);
|
|
|
|
Assert.True(result.IsOnGround);
|
|
Assert.InRange(result.Position.X, 97.9f, 98.1f);
|
|
// Phase D fix: ResolveOutdoorCellId now always applies the matched
|
|
// landblock's high-16 prefix — 0xA9B4 prefix from the registered
|
|
// landblock (0xA9B4FFFF) is now included in the returned CellId.
|
|
Assert.Equal(0xA9B40025u, result.CellId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveWithTransition_EdgeSlideStopsAtLoadedTerrainBoundary()
|
|
{
|
|
var engine = MakeFlatEngine(terrainZ: 50f);
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = new Vector3(191.25f, 96f, 50f),
|
|
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
|
ContactPlaneValid = true,
|
|
ContactPlane = new Plane(Vector3.UnitZ, -50f),
|
|
ContactPlaneCellId = 0x003Du,
|
|
};
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: new Vector3(191.25f, 96f, 50f),
|
|
targetPos: new Vector3(193f, 96f, 50f),
|
|
cellId: 0x003Du,
|
|
sphereRadius: 0.5f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.4f,
|
|
isOnGround: true,
|
|
body: body,
|
|
moverFlags: ObjectInfoState.EdgeSlide);
|
|
|
|
Assert.True(result.IsOnGround);
|
|
Assert.InRange(result.Position.X, 190.75f, 192.0001f);
|
|
Assert.Equal(50f, result.Position.Z, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion()
|
|
{
|
|
var engine = MakeFlatEngine(terrainZ: 50f);
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = new Vector3(191f, 96f, 50f),
|
|
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
|
ContactPlaneValid = true,
|
|
ContactPlane = new Plane(Vector3.UnitZ, -50f),
|
|
ContactPlaneCellId = 0x003Du,
|
|
};
|
|
|
|
var settled = engine.ResolveWithTransition(
|
|
currentPos: new Vector3(191f, 96f, 50f),
|
|
targetPos: new Vector3(191.25f, 96f, 50f),
|
|
cellId: 0x003Du,
|
|
sphereRadius: 0.5f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.4f,
|
|
isOnGround: true,
|
|
body: body,
|
|
moverFlags: ObjectInfoState.EdgeSlide);
|
|
|
|
Assert.True(body.WalkablePolygonValid);
|
|
Assert.NotNull(body.WalkableVertices);
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: settled.Position,
|
|
targetPos: new Vector3(193f, 98f, 50f),
|
|
cellId: 0x003Du,
|
|
sphereRadius: 0.5f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.4f,
|
|
isOnGround: true,
|
|
body: body,
|
|
moverFlags: ObjectInfoState.EdgeSlide);
|
|
|
|
Assert.True(result.IsOnGround);
|
|
Assert.InRange(result.Position.X, 190.75f, 192.0001f);
|
|
Assert.True(result.Position.Y > 96.2f);
|
|
Assert.Equal(50f, result.Position.Z, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
|
|
{
|
|
var engine = new PhysicsEngine();
|
|
|
|
var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
|
engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
|
|
|
|
var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
|
engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), worldOffsetX: 192f, worldOffsetY: 0f);
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: new Vector3(191f, 10f, 50f),
|
|
targetPos: new Vector3(193f, 10f, 50f),
|
|
cellId: 0xA9B40039u,
|
|
sphereRadius: 0.5f,
|
|
sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f,
|
|
stepDownHeight: 0.4f,
|
|
isOnGround: true);
|
|
|
|
Assert.True(result.IsOnGround);
|
|
Assert.InRange(result.Position.X, 192.9f, 193.1f);
|
|
Assert.Equal(0xAAB40001u, result.CellId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #42 lock — when the moving entity's own ShadowEntry is registered
|
|
/// in <see cref="ShadowObjectRegistry"/> at the body's exact position
|
|
/// (the production pattern from <c>GameWindow.cs:2545</c> spawn → register
|
|
/// + <c>UpdatePosition</c> live tracking), the airborne sweep MUST skip
|
|
/// it. Without the gate, <c>FindObjCollisions</c> sees the cylinder as
|
|
/// a foreign collidable and slides the sphere ~1m horizontally on the
|
|
/// first non-zero-motion frame — the bug observed by the [SWEEP-OBJ]
|
|
/// trace and reported as the post-jump XY drift in #42.
|
|
/// <para>
|
|
/// Mirrors retail's self-skip at <c>CObjCell::find_obj_collisions</c>
|
|
/// (named-retail <c>acclient_2013_pseudo_c.txt:308931</c>):
|
|
/// <c>physobj != arg2->object_info.object</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches()
|
|
{
|
|
var freshCache = new PhysicsDataCache();
|
|
var engine = new PhysicsEngine { DataCache = freshCache };
|
|
engine.AddLandblock(
|
|
0xA9B4FFFFu,
|
|
new TerrainSurface(FlatHeightmap(50), LinearHeightTable()),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
// FindObjCollisions early-returns when DataCache is null. An empty
|
|
// cache is enough for cylinder objects; only BSP objects look up
|
|
// entries inside.
|
|
// #145 D: register terrain for 0xA9B4 at (0,0,0) so TryGetTerrainOrigin
|
|
// succeeds for the outdoor seed cell (0xA9B40039). In production the
|
|
// streaming-center landblock is always resident before outdoor resolves run;
|
|
// we replicate that invariant here by registering a flat dummy terrain.
|
|
freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero);
|
|
|
|
const uint movingEntityId = 0xDEADBEEFu;
|
|
var bodyPos = new Vector3(96f, 96f, 50f);
|
|
var targetPos = bodyPos + new Vector3(0f, 0f, 0.022f); // stationary +Z
|
|
|
|
// Register the moving entity's own ShadowEntry — humanoid Cylinder
|
|
// sized to match the live-spawn registration in production
|
|
// (GameWindow.cs:2545). The gfxObj id 0x02000001 is the standard
|
|
// human setup; radius/height match the [SWEEP-OBJ] trace observed
|
|
// during run #2 of the #42 investigation.
|
|
engine.ShadowObjects.Register(
|
|
entityId: movingEntityId,
|
|
gfxObjId: 0x02000001u,
|
|
worldPos: bodyPos,
|
|
rotation: Quaternion.Identity,
|
|
radius: 0.679f,
|
|
worldOffsetX: 0f, worldOffsetY: 0f,
|
|
landblockId: 0xA9B4FFFFu,
|
|
collisionType: ShadowCollisionType.Cylinder,
|
|
cylHeight: 1.835f);
|
|
|
|
// Without the gate (movingEntityId == 0): the sweep must be
|
|
// INTERFERED WITH by the self-entry. This proves the registry
|
|
// actually causes a collision, so the following filtered case is not
|
|
// a vacuous pass.
|
|
//
|
|
// Observable updated for the 2026-07-05 CCylSphere family port: the
|
|
// old hand-rolled response radial-pushed the sphere ~1 m sideways
|
|
// (the original #42 symptom this test asserted). Retail's dispatcher
|
|
// (0x0053b440) resolves this geometry — airborne, dead-center on the
|
|
// cylinder axis, moving up — through land_on_cylinder → the Collide
|
|
// re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition
|
|
// then reverts to a stay-put (no sideways teleport, Ok=true). The
|
|
// response-model-independent interference signal is the DENIED +Z
|
|
// movement: the sweep must NOT reach the +0.022 target.
|
|
var unfiltered = engine.ResolveWithTransition(
|
|
currentPos: bodyPos, targetPos: targetPos,
|
|
cellId: 0xA9B40039u,
|
|
sphereRadius: 0.48f, sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f, stepDownHeight: 0.4f,
|
|
isOnGround: false,
|
|
movingEntityId: 0u);
|
|
|
|
Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f,
|
|
$"Without movingEntityId, the sweep must collide with the mover's own " +
|
|
$"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " +
|
|
$"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " +
|
|
$"target Z={targetPos.Z:F4}");
|
|
|
|
// With the gate: the sweep must leave XY unchanged.
|
|
var filtered = engine.ResolveWithTransition(
|
|
currentPos: bodyPos, targetPos: targetPos,
|
|
cellId: 0xA9B40039u,
|
|
sphereRadius: 0.48f, sphereHeight: 1.2f,
|
|
stepUpHeight: 0.4f, stepDownHeight: 0.4f,
|
|
isOnGround: false,
|
|
movingEntityId: movingEntityId);
|
|
|
|
float filteredXY = MathF.Sqrt(
|
|
(filtered.Position.X - targetPos.X) * (filtered.Position.X - targetPos.X) +
|
|
(filtered.Position.Y - targetPos.Y) * (filtered.Position.Y - targetPos.Y));
|
|
Assert.InRange(filteredXY, 0f, 0.001f);
|
|
}
|
|
}
|