refactor(physics): delete legacy PhysicsEngine.Resolve/ResolvePlacement/HasCellSurface (C5a, AP-1/AD-1)

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>
This commit is contained in:
Erik 2026-08-05 14:11:31 +02:00
parent f8e55ba5e4
commit 6921a02744
34 changed files with 508 additions and 946 deletions

View file

@ -86,7 +86,7 @@ public class DispatcherToMovementIntegrationTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // facing +X
var kb = new FakeKb();
@ -117,7 +117,7 @@ public class DispatcherToMovementIntegrationTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var kb = new FakeKb();
@ -165,12 +165,12 @@ public class DispatcherToMovementIntegrationTests
// frame's MouseDeltaX doesn't leak into the second run via Yaw.
var engineA = MakeFlatEngine();
var ctrlA = new PlayerMovementController(engineA);
ctrlA.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ctrlA.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ctrlA.Yaw = 0f;
var engineB = MakeFlatEngine();
var ctrlB = new PlayerMovementController(engineB);
ctrlB.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ctrlB.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ctrlB.Yaw = 0f;
var inputZero = new MovementInput(Forward: true, MouseDeltaX: 0f);

View file

@ -161,7 +161,7 @@ public class PlayerMoveToCutoverTests
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // heading 90 = facing +X
const uint selfGuid = 0x5000000Au;

View file

@ -108,7 +108,7 @@ public class W6EdgeDrivenMovementTests
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
return controller;
}

View file

@ -236,7 +236,7 @@ public class CellarUpTrajectoryReplayTests : IDisposable
{
var (engine, _) = BuildEngineWithCellarFixtures();
var controller = new PlayerMovementController(engine);
controller.SetPosition(InitialSphereWorld, CellarId);
controller.SeedPlacementForTest(InitialSphereWorld, CellarId, InitialSphereWorld);
var settled = controller.Update(1f / 60f, new MovementInput());
var basePos = settled.Position;

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
@ -6,6 +7,23 @@ namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Enter-world overlap conformance for retail
/// <c>CTransition::find_placement_pos</c> (0x0050BA50).
///
/// <para>
/// C5a (2026-08-05) re-point: the legacy <c>PhysicsEngine.ResolvePlacement</c>
/// this test originally drove is deleted (zero production callers). Canonical
/// <c>PhysicsEngine.SetPosition</c> reaches the SAME ring-search machinery —
/// <c>SetPositionInternal</c> sets <c>InsertType.Placement</c> before calling
/// <c>Transition.FindValidPosition</c>, which (for any non-<c>Transition</c>
/// insert type) dispatches to <c>FindPlacementPosition</c>
/// (<c>TransitionTypes.cs</c>), and THAT method calls this exact
/// <c>FindPlacementPos</c> ring search after its initial-placement insert —
/// so this is not a new code path, only a new entry point onto the one the
/// legacy method used directly. Sphere shape is reconstructed as the
/// two-sphere capsule the legacy scalar <c>(radius, height)</c> overload
/// built internally (origin (0,0,radius) + (0,0,height-radius), both
/// radius-sized), since canonical <c>SetPosition</c> takes a pre-built
/// sphere list rather than a radius/height pair.
/// </para>
/// </summary>
public sealed class InitialPlacementOverlapTests
{
@ -14,6 +32,7 @@ public sealed class InitialPlacementOverlapTests
private const uint PlayerId = 0x50000001u;
private const uint MonsterId = 0x50000002u;
private const float Radius = 0.48f;
private const float SphereHeight = 1.835f;
[Fact]
public void PlayerReloggingInsideMonster_SearchesOutToNearestClearRing()
@ -39,17 +58,30 @@ public sealed class InitialPlacementOverlapTests
RegisterSphere(engine, MonsterId, monsterCenter,
EntityCollisionFlags.IsCreature);
ResolveResult result = engine.ResolvePlacement(
savedFeet,
Cell,
sphereRadius: Radius,
sphereHeight: 1.835f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: PlayerId);
// The mover's own two-sphere capsule, matching the legacy scalar
// InitPath(sphereRadius: 0.48, sphereHeight: 1.835) reconstruction:
// a foot sphere at (0,0,radius) and a head sphere at
// (0,0,height-radius), both radius-sized.
ImmutableArray<FlatCollisionSphere> spheres = ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, Radius), Radius),
new FlatCollisionSphere(new Vector3(0f, 0f, SphereHeight - Radius), Radius));
Assert.True(result.Ok);
PhysicsSetPositionResult result = engine.SetPosition(
new PhysicsSetPositionRequest(
Position: savedFeet,
Orientation: Quaternion.Identity,
CellId: Cell,
CellLocalPosition: savedFeet,
Spheres: spheres,
Scale: 1f,
StepUpHeight: 0.4f,
StepDownHeight: 0.4f,
MoverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
MovingEntityId: PlayerId,
Flags: PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Slide));
Assert.True(result.IsCommitted);
Assert.True(result.InContact);
Assert.True(result.OnWalkable);
Assert.Equal(Cell, result.ContactPlaneCellId);

View file

@ -9,32 +9,49 @@ using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #133 (Bug A) — the validated-claim placement branch of
/// <see cref="PhysicsEngine.Resolve"/> must return the VALIDATED claim's own
/// full cell id, NOT <c>lbPrefix | (cellId &amp; 0xFFFF)</c>.
/// #133 (Bug A) — a validated dungeon claim's landblock prefix is
/// AUTHORITATIVE; a candidate world position that also falls inside a
/// neighbouring RESIDENT landblock's <c>[0,192)</c> local bounds must NOT
/// re-stamp the claim with that neighbour's prefix.
///
/// <para>
/// C5a (2026-08-05) re-point: the legacy <c>PhysicsEngine.Resolve</c> this
/// test originally drove is deleted (zero production callers — every
/// production placement writer reaches canonical
/// <c>PhysicsEngine.SetPosition</c> through <c>RuntimeSetPositionState</c>).
/// The defect this test pins lived entirely inside <c>Resolve</c>'s own
/// <c>lbPrefix</c> resident-landblock scan (find a resident block whose
/// <c>[0,192)</c> bounds contain the candidate XY, then stamp
/// <c>lbPrefix | (cellId &amp; 0xFFFF)</c> — the scan has no notion that
/// the claim's OWN landblock can legitimately fail its own bounds test,
/// which is exactly what a dungeon EnvCell's negative local Y does). The
/// canonical <c>SetPosition</c>/<c>SetPositionInternal</c> pipeline has no
/// equivalent lbPrefix scan: a validated claim (<c>AdjustSeedCell</c>
/// confirms the seed cell contains the point) is committed with ITS OWN
/// cell id, full stop — the defect class cannot recur there by
/// construction. This test now proves that positively: drive the SAME
/// geometry as the original #133 capture (dungeon claim
/// <c>0x00070143</c> at dungeon-local <c>(70,-60,0.01)</c>, with a
/// still-resident Holtburg neighbour block whose <c>[0,192)</c> bounds
/// contain the same world XY) through canonical
/// <c>PhysicsEngine.SetPosition</c> and assert the committed cell keeps
/// the <c>0x0007</c> prefix.
/// </para>
///
/// <para>
/// <c>lbPrefix</c> is found by scanning resident landblocks for one whose
/// <c>[0,192)</c> local bounds contain the candidate XY. A dungeon EnvCell's
/// local Y can be NEGATIVE relative to its own landblock (the live capture:
/// server teleport to dungeon cell <c>0x00070143</c> at local <c>(70,-60,0.01)</c>).
/// The dungeon landblock fails the <c>localY &gt;= 0</c> bounds test, so the loop
/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock
/// whose world bounds happen to contain the same XY) and sets
/// <c>lbPrefix = 0xA9B30000</c>. The old code then returned
/// The dungeon landblock fails the <c>localY &gt;= 0</c> bounds test, so the
/// legacy scan instead matched a still-resident NEIGHBOURING block (a
/// Holtburg landblock whose world bounds happen to contain the same XY) and
/// set <c>lbPrefix = 0xA9B30000</c>. The old code then returned
/// <c>0xA9B30000 | 0x0143 = 0xA9B30143</c>, re-stamping the validated dungeon
/// claim with the wrong landblock — the client mis-resolved the player into
/// Holtburg and spammed ACE with rejected moves
/// (<c>movement pre-validation failed from 00070143 to A9B30143</c>).
/// </para>
///
/// <para>
/// The validated claim's prefix is authoritative; a position falling in a
/// neighbouring resident landblock must not re-stamp it. This test reproduces
/// the exact geometry of the capture (dungeon claim in landblock <c>0x0007</c>,
/// candidate XY also inside resident Holtburg <c>0xA9B3</c>) and asserts the
/// returned cell keeps its <c>0x0007</c> prefix.
/// </para>
/// </summary>
public class Issue133DungeonTeleportPrefixTests
{
@ -53,13 +70,29 @@ public class Issue133DungeonTeleportPrefixTests
{
var engine = BuildEngine();
// Zero delta = the snap shape (teleport arrival). cellId is the dungeon
// claim; the candidate XY also falls inside the resident Holtburg block.
var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f);
// Zero-delta teleport arrival — canonical SetPosition's placement
// shape for a server-restored (cell, position) pair. The dungeon
// claim is the seed cell; the candidate XY also falls inside the
// resident Holtburg block's [0,192) bounds.
PhysicsSetPositionResult result = engine.SetPosition(
new PhysicsSetPositionRequest(
Position: SpawnPos,
Orientation: Quaternion.Identity,
CellId: DungeonCellId,
CellLocalPosition: SpawnPos,
Spheres: default,
Scale: 1f,
StepUpHeight: 0.4f,
StepDownHeight: 0.4f,
Flags: PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Teleport
| PhysicsSetPositionFlags.Slide));
Assert.True(result.IsOnGround);
Assert.True(result.IsCommitted);
// The validated claim's prefix is authoritative — high word stays 0x0007,
// NOT re-stamped to the neighbouring Holtburg 0xA9B3.
// NOT re-stamped to the neighbouring Holtburg 0xA9B3. Canonical
// SetPosition has no lbPrefix scan to get this wrong in the first
// place; this assertion is the positive proof of that.
Assert.Equal(DungeonCellId, result.CellId);
Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u);
}
@ -73,12 +106,13 @@ public class Issue133DungeonTeleportPrefixTests
// The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition
// validates the claim (returns it with found=true). Its Resolved set has
// one walkable floor polygon at z=0 under the spawn XY so the #111
// validated-claim branch grounds onto it.
// one walkable floor polygon at z=0 under the spawn XY so placement
// grounds onto it.
cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell());
// Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the
// candidate XY (70,70). This is the block the lbPrefix loop wrongly matched.
// candidate XY (70,70). This is the block the legacy lbPrefix loop wrongly
// matched.
engine.AddLandblock(
landblockId: HoltburgLandblock,
terrain: FlatTerrain(),
@ -89,7 +123,7 @@ public class Issue133DungeonTeleportPrefixTests
// The dungeon's own landblock, offset so the candidate XY produces a
// NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds
// test, which is exactly why the old code fell through to the Holtburg
// test, which is exactly why the legacy code fell through to the Holtburg
// prefix. Registered so the scenario is faithful (a resident dungeon block
// whose local bounds don't cover the EnvCell's negative-Y position).
engine.AddLandblock(

View file

@ -31,192 +31,6 @@ public class PhysicsEngineTests
return engine;
}
[Fact]
public void Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn()
{
var engine = new PhysicsEngine();
var diagnostics = new List<string>();
Assert.Null(engine.DiagnosticLog);
_ = engine.Resolve(
Vector3.Zero,
cellId: 0xA9B40001u,
delta: Vector3.Zero,
stepUpHeight: 0.5f);
engine.DiagnosticLog = diagnostics.Add;
_ = engine.Resolve(
Vector3.Zero,
cellId: 0xA9B40001u,
delta: Vector3.Zero,
stepUpHeight: 0.5f);
Assert.Single(diagnostics);
Assert.StartsWith(
"[snap] claim=0xA9B40001",
diagnostics[0],
StringComparison.Ordinal);
}
[Fact]
public void Resolve_FlatTerrain_ZMatchesTerrain()
{
var engine = MakeFlatEngine(terrainZ: 50f);
var result = engine.Resolve(
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f),
stepUpHeight: 2f);
Assert.Equal(50f, result.Position.Z, precision: 1);
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_WalkUpSmallSlope_Accepted()
{
// Heights slope from 50 to 52 across X — small enough for step height.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, LinearHeightTable());
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
var result = engine.Resolve(
new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
stepUpHeight: 5f);
Assert.True(result.IsOnGround);
Assert.True(result.Position.Z >= 50f); // moved uphill
}
[Fact]
public void Resolve_StepUpExceedsHeight_MovementBlocked()
{
// Heights jump sharply: left half = 50, right half = 100.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100);
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, LinearHeightTable());
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
// Try to walk from the low side to the high side.
var result = engine.Resolve(
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
stepUpHeight: 2f);
// Movement should be blocked — Z delta (50→100) exceeds step height (2).
Assert.Equal(96f, result.Position.X, precision: 1); // didn't move
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_OutdoorThroughPortal_TransitionsToIndoor()
{
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
// A CellSurface for the indoor cell with floor at Z=50.
var cellVerts = new Dictionary<ushort, Vector3>
{
[0] = new(40f, 40f, 50f),
[1] = new(60f, 40f, 50f),
[2] = new(60f, 60f, 50f),
[3] = new(40f, 60f, 50f),
};
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
// A portal plane at X=45 (vertical plane facing +X).
// OwnerCellId = 0x0100 (the indoor cell), TargetCellId = 0xFFFF (faces outdoor).
// From outside, walking through this portal enters OwnerCellId.
var portal = PortalPlane.FromVertices(
new Vector3(45f, 40f, 45f),
new Vector3(45f, 60f, 45f),
new Vector3(45f, 60f, 55f),
targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0);
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal },
worldOffsetX: 0f, worldOffsetY: 0f);
// Walk from X=40 (outdoor) through X=45 (portal) to X=50 (indoor).
var result = engine.Resolve(
new Vector3(40f, 50f, 50f), cellId: 0x0001, delta: new Vector3(10f, 0f, 0f),
stepUpHeight: 5f);
// Should have transitioned to indoor cell 0x0100.
Assert.Equal(0x0100u, result.CellId & 0xFFFFu);
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_IndoorThroughExitPortal_TransitionsToOutdoor()
{
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
var cellVerts = new Dictionary<ushort, Vector3>
{
[0] = new(40f, 40f, 50f),
[1] = new(60f, 40f, 50f),
[2] = new(60f, 60f, 50f),
[3] = new(40f, 60f, 50f),
};
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
// Same portal geometry — OwnerCellId = 0x0100, TargetCellId = 0xFFFF (outdoor exit).
var portal = PortalPlane.FromVertices(
new Vector3(45f, 40f, 45f),
new Vector3(45f, 60f, 45f),
new Vector3(45f, 60f, 55f),
targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0);
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal },
worldOffsetX: 0f, worldOffsetY: 0f);
// Walk from X=50 (indoor) through X=45 (portal) to X=40 (outdoor).
var result = engine.Resolve(
new Vector3(50f, 50f, 50f), cellId: 0x0100, delta: new Vector3(-10f, 0f, 0f),
stepUpHeight: 5f);
// Should have transitioned to outdoor.
Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_LandblockBoundary_PicksAdjacentTerrain()
{
var engine = new PhysicsEngine();
// Landblock A: flat at Z=50, offset at X=0.
var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
// Landblock B: flat at Z=60, offset at X=192 (adjacent east).
var terrainB = new TerrainSurface(FlatHeightmap(60), LinearHeightTable());
engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 192f, worldOffsetY: 0f);
// Walk from X=190 (landblock A) across to X=194 (landblock B).
var result = engine.Resolve(
new Vector3(190f, 96f, 50f), cellId: 0x0001, delta: new Vector3(4f, 0f, 0f),
stepUpHeight: 15f);
// Should be at Z=60 (landblock B's terrain) and position X≈194.
Assert.Equal(60f, result.Position.Z, precision: 1);
Assert.True(result.Position.X > 192f);
}
[Fact]
public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId()
{
@ -368,102 +182,6 @@ public class PhysicsEngineTests
Assert.Equal(0xAAB40001u, result.CellId);
}
[Fact]
public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
{
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
var cellVerts = new Dictionary<ushort, Vector3>
{
[0] = new(40f, 40f, 55f),
[1] = new(60f, 40f, 55f),
[2] = new(60f, 60f, 55f),
[3] = new(40f, 60f, 55f),
};
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
// Start inside the cell, walk out.
var result = engine.Resolve(
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
stepUpHeight: 10f);
// Should transition back to outdoor. (#106 follow-up: masked compare —
// Resolve now returns FULL prefixed cell ids; the old unmasked
// `CellId < 0x0100` assertion codified the bare-low-word bug.)
Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
Assert.Equal(0xA9B40000u, result.CellId & 0xFFFF0000u);
Assert.Equal(50f, result.Position.Z, precision: 1);
Assert.True(result.IsOnGround);
}
/// <summary>
/// #106 follow-up (2026-06-09): the live boundary-walk gate was sabotaged by
/// the teleport-arrival snap (GameWindow.cs:4869) receiving a BARE indoor
/// cell id from Resolve (`0x0000013F`). A bare indoor id wedges the whole
/// membership chain: GetCellStruct misses (no wall BSP), the #98 gate reads
/// "indoor primary" and skips the outdoor object sweep (no collision with
/// anything), and FindCellSet can never re-resolve a malformed id. Resolve
/// MUST return the matched landblock's full 32-bit cell id on every
/// computed exit — the same convention its own inputs use.
/// </summary>
[Fact]
public void Resolve_IndoorStay_ReturnsFullPrefixedCellId()
{
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
var cellVerts = new Dictionary<ushort, Vector3>
{
[0] = new(40f, 40f, 55f),
[1] = new(60f, 40f, 55f),
[2] = new(60f, 60f, 55f),
[3] = new(40f, 60f, 55f),
};
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
// The teleport shape: full indoor cell id in, zero delta (pure snap).
var result = engine.Resolve(
new Vector3(50f, 50f, 55f), cellId: 0xA9B40100u, delta: Vector3.Zero,
stepUpHeight: 5f);
Assert.Equal(0xA9B40100u, result.CellId);
}
[Fact]
public void Resolve_OutdoorStay_ReturnsFullPrefixedCellId()
{
var engine = MakeFlatEngine(terrainZ: 50f);
var result = engine.Resolve(
new Vector3(96f, 96f, 50f), cellId: 0xA9B40029u, delta: new Vector3(1f, 0f, 0f),
stepUpHeight: 2f);
// (97, 96) is over grid (4, 4) → low = 4*8+4+1 = 0x25, prefixed.
Assert.Equal(0xA9B40025u, result.CellId);
}
[Fact]
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
{
var engine = new PhysicsEngine();
// No landblocks loaded — entity is floating in void.
var result = engine.Resolve(
new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero,
stepUpHeight: 2f);
Assert.False(result.IsOnGround);
}
/// <summary>
/// #42 lock — when the moving entity's own ShadowEntry is registered
/// in <see cref="ShadowObjectRegistry"/> at the body's exact position

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using AcDream.Core.Physics;
@ -176,57 +177,95 @@ public sealed class TransitionScratchDifferentialTests
Coverage: CoverageKind.Failure));
}
/// <summary>
/// C5a (2026-08-05) re-point: the legacy <c>PhysicsEngine.ResolvePlacement</c>
/// this arm originally drove is deleted (zero production callers).
/// Canonical <c>PhysicsEngine.SetPosition</c> reaches the exact same
/// <c>FindPlacementPos</c> ring search — <c>SetPositionInternal</c> sets
/// <c>InsertType.Placement</c> before <c>Transition.FindValidPosition</c>,
/// which dispatches to <c>FindPlacementPosition</c>, which calls
/// <c>FindPlacementPos</c> — so this is the same scratch-reuse surface
/// under a new entry point, not a new code path. The bitwise
/// fresh-vs-reused comparison and the second-identity leak check are both
/// preserved verbatim, now via <see cref="AssertSetPositionBitwise"/>
/// over <see cref="PhysicsSetPositionResult"/>.
/// </summary>
[Fact]
public void ReusedScratch_MatchesFreshPlacementSearch()
{
PhysicsEngine fresh = BuildPlacementEngine(reuse: false);
PhysicsEngine reused = BuildPlacementEngine(reuse: true);
ResolveResult expected = fresh.ResolvePlacement(
new Vector3(10f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u);
ResolveResult actual = reused.ResolvePlacement(
new Vector3(10f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u);
PhysicsSetPositionResult expected = fresh.SetPosition(
PlacementRequest(
new Vector3(10f, 10f, 0f),
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u));
PhysicsSetPositionResult actual = reused.SetPosition(
PlacementRequest(
new Vector3(10f, 10f, 0f),
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u));
AssertResolveBitwise(expected, actual, "placement");
AssertSetPositionBitwise(expected, actual, "placement");
// A6 (architecture review, 2026-08-05): the bitwise comparison alone
// proves fresh and reused AGREE, not that either actually placed
// anything — a future regression that makes SetPosition fail
// IDENTICALLY on both engines would leave this differential green
// while the scratch-reuse surface it exists to guard goes
// unexercised. Assert the positive fact the comparison itself can't:
// this placement committed.
Assert.True(expected.IsCommitted, "fresh engine did not commit the placement");
Assert.True(actual.IsCommitted, "reused engine did not commit the placement");
// A second placement with a different self identity proves that the
// previous mover and collision-GUID list cannot leak through the lease.
expected = fresh.ResolvePlacement(
new Vector3(11f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.EdgeSlide,
0x80000102u);
actual = reused.ResolvePlacement(
new Vector3(11f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.EdgeSlide,
0x80000102u);
expected = fresh.SetPosition(
PlacementRequest(
new Vector3(11f, 10f, 0f),
ObjectInfoState.EdgeSlide,
0x80000102u));
actual = reused.SetPosition(
PlacementRequest(
new Vector3(11f, 10f, 0f),
ObjectInfoState.EdgeSlide,
0x80000102u));
AssertResolveBitwise(expected, actual, "placement after hostile identity");
AssertSetPositionBitwise(expected, actual, "placement after hostile identity");
Assert.True(
expected.IsCommitted,
"fresh engine did not commit the second placement");
Assert.True(
actual.IsCommitted,
"reused engine did not commit the second placement");
}
/// <summary>
/// Builds the two-sphere capsule request the legacy scalar
/// <c>InitPath(sphereRadius: 0.48, sphereHeight: 1.835)</c> reconstructed
/// internally (origin (0,0,radius) + (0,0,height-radius), both
/// radius-sized), so canonical <c>SetPosition</c> sees the identical
/// mover shape the legacy <c>ResolvePlacement</c> call did.
/// </summary>
private static PhysicsSetPositionRequest PlacementRequest(
Vector3 position,
ObjectInfoState moverFlags,
uint movingEntityId) => new(
Position: position,
Orientation: Quaternion.Identity,
CellId: Cell,
CellLocalPosition: position,
Spheres: ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.48f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 1.835f - 0.48f), 0.48f)),
Scale: 1f,
StepUpHeight: 0.40f,
StepDownHeight: 0.40f,
MoverFlags: moverFlags,
MovingEntityId: movingEntityId,
Flags: PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Slide);
private static void RunSequence(
Func<bool, PhysicsEngine> buildEngine,
params ResolveSpec[] specs)
@ -441,6 +480,66 @@ public sealed class TransitionScratchDifferentialTests
Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
}
/// <summary>
/// Bitwise sibling of <see cref="AssertResolveBitwise"/> for
/// <see cref="PhysicsSetPositionResult"/>, used by the re-pointed
/// <see cref="ReusedScratch_MatchesFreshPlacementSearch"/> (C5a). Every
/// field is compared — float-bearing members bitwise (a stale scratch
/// leaking a different -0.0/+0.0 sign bit would otherwise pass a
/// value-equality check), enum/bool/id members by value, and the
/// immutable id arrays by ordered sequence.
/// </summary>
private static void AssertSetPositionBitwise(
PhysicsSetPositionResult expected,
PhysicsSetPositionResult actual,
string context)
{
Assert.Equal(expected.Error, actual.Error);
Assert.Equal(expected.Residence, actual.Residence);
AssertVectorBitwise(expected.Position, actual.Position, context);
AssertQuaternionBitwise(expected.Orientation, actual.Orientation, context);
Assert.Equal(expected.CellId, actual.CellId);
AssertVectorBitwise(
expected.CellLocalPosition,
actual.CellLocalPosition,
$"{context}.CellLocalPosition");
Assert.Equal(expected.InContact, actual.InContact);
Assert.Equal(expected.OnWalkable, actual.OnWalkable);
AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context);
Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId);
Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
Assert.Equal(expected.SlidingNormalValid, actual.SlidingNormalValid);
AssertVectorBitwise(
expected.SlidingNormal,
actual.SlidingNormal,
$"{context}.SlidingNormal");
Assert.Equal(expected.CollisionNormalValid, actual.CollisionNormalValid);
AssertVectorBitwise(
expected.CollisionNormal,
actual.CollisionNormal,
$"{context}.CollisionNormal");
Assert.Equal(expected.FramesStationaryFall, actual.FramesStationaryFall);
Assert.Equal(expected.CollidedWithEnvironment, actual.CollidedWithEnvironment);
Assert.Equal(expected.CollisionHandlerResult, actual.CollisionHandlerResult);
Assert.Equal(expected.CellChanged, actual.CellChanged);
Assert.Equal(expected.ShadowAction, actual.ShadowAction);
// ImmutableArray<T>.Equals(ImmutableArray<T>) compares the BACKING
// ARRAY REFERENCE, not the elements — the fresh and reused engines
// never share a backing array even when the contents match, so
// Assert.Equal on the bare ImmutableArray would false-fail. Compare
// as plain arrays (regular array Equals/sequence comparison) instead.
Assert.Equal(ToArrayOrEmpty(expected.CrossCellIds), ToArrayOrEmpty(actual.CrossCellIds));
Assert.Equal(
ToArrayOrEmpty(expected.CollidedObjectIds),
ToArrayOrEmpty(actual.CollidedObjectIds));
Assert.Equal(
ToArrayOrEmpty(expected.QueriedCellIds),
ToArrayOrEmpty(actual.QueriedCellIds));
}
private static uint[] ToArrayOrEmpty(ImmutableArray<uint> array) =>
array.IsDefault ? Array.Empty<uint>() : array.ToArray();
private static void AssertCoverage(ResolveSpec spec, ResolveResult result)
{
switch (spec.Coverage)