feat(physics): C4 route 4b-2 — remote far snap through the canonical placement
Flips the SetPositionSimple classification (contact, PlayerDistance >= 96 m) for remotes onto 4b-1's drive controller and deletes both legacy far blocks, both duplicated 96f/4f constant pairs, and both `?? Vector3.Zero` fabrications. The 4 m constant now exists exactly once. Teleport and cell-less stay legacy for 4b-3. Retail: MoveOrTeleport @0x00516330's far branch runs StopInterpolating @0x005163CB before SetPositionSimple @0x005163D9 and returns 1 @0x005163E8 regardless — the SetPositionError is discarded — so HandleReceivedPosition arms ConstrainTo @0x00454272 post-move on commit AND on failure. The x87 parity decode at @0x00516393-@0x0051639E puts exactly 96.0 on the far branch. SetPositionSimple @0x005162B0 builds flags 0x1012 at @0x005162C4. Non-commit outcomes still advance the body, because retail's SetPositionInternal @0x00515BD0 commits the destination via store_position @0x00515CE2 when no cell resolves. The partition is by STAGE, not heuristic, enforced by an exhaustive switch: Refused/Contention/NotApplicable/RejectedPreparation store (the placement never executed); Committed/Deferred/RejectedByPlacement do not (the engine ran and refused, matching retail's non-storing returns @0x00515CB2 and @0x00515CD5). Without this a refused far snap froze the remote with an emptied queue. Also fixes a shipped defect this route made live: ParkDeferred's quiescence parks withdrew the entity (InWorld=false, clock suspended, residency removed) and were never restorable, while Forget(restoreCancelledPark: true) runs for every accepted Position on every entity. The restorable decision now lives inside ParkDeferred AFTER SnapToCell, reading body.CellPosition.ObjCellId — the value RestoreParkWithdrawal actually restores at — against every live quiescence rather than one minimum-OperationId token. The three pre-snap fields are hoisted into locals because SnapToCell ends with InWorld = true. ParkCollisionResidents passes restorableOnCancel: false explicitly; the plain unplaceable park is provably unchanged. RestoreParkWithdrawal re-tests the prefix at restore time so a retained route-2 park cannot re-admit into a prefix that began quiescing during the park. CanAttemptDestination is retained as an OPTIMISATION only, with the two Core predicates it cannot reproduce written down at the pre-flight, plus the two properties that depend on it staying there. Four fix rounds and eight Opus reviews. The slice was fully green at 10,990, 10,997 and 11,004 while containing real defects — a frozen remote pinned as correct by its own test, a fallback that over-wrote on the exact retail paths that decline to store, and a park guard incomplete on two independent axes. Register: AP-137 (leftover classifications take AP-87's catch-up; states the cell-less enqueue-vs-place delta deferred to 4b-3, that RejectedData is applied anyway, and the headless divergence), AP-138 (the refusable far placement), AP-136 narrowed to match the relocation. #309's acceptance steps rewritten — step 5 previously asserted a recovery the code does not perform — and gated on a new ACDREAM_PROBE_PARK=1 signal so the check cannot pass while broken. Suite 11,009 passed / 4 skipped / 0 failed against a measured 10,968 baseline. The 10,973 figure recorded earlier was wrong and is corrected here. Connected gate outstanding: the two-client far-snap walk and #309. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1b631f127d
commit
7f1c1f5aa6
24 changed files with 5234 additions and 375 deletions
|
|
@ -0,0 +1,582 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2: behavioural acceptance for the remote far snap. Every test
|
||||
/// calls the PRODUCTION routing entry point
|
||||
/// (<see cref="LiveEntityNetworkUpdateController.ApplyRemoteContactRouting"/>)
|
||||
/// against a REAL <see cref="RuntimeRemotePlacementDriveController"/>, a real
|
||||
/// canonical <see cref="RuntimeEntityRecord"/>/<see cref="PhysicsBody"/> pair,
|
||||
/// and REAL
|
||||
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/> output. Nothing
|
||||
/// is re-derived in a test body — that is the #292 gap route 2 left and route
|
||||
/// 4a's first attempt repeated.
|
||||
///
|
||||
/// <para>
|
||||
/// The load-bearing discriminator is the <c>worldPos</c> argument: it is
|
||||
/// deliberately DIFFERENT from the accepted destination merged onto the
|
||||
/// canonical snapshot. The deleted legacy far block wrote
|
||||
/// <c>Body.Position = worldPos</c>; the canonical placement writes the
|
||||
/// snapshot's destination resolved through Runtime's world frame. Any
|
||||
/// regression back to the legacy write therefore fails
|
||||
/// <see cref="FarSnap_PlacesTheBodyFromTheCanonicalDestination_NotTheCallersWirePose"/>
|
||||
/// on the exact position value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests
|
||||
{
|
||||
private static readonly Vector3 Destination = new(12f, 14f, 7f);
|
||||
|
||||
/// <summary>A wire pose the caller passes but the far arm must ignore —
|
||||
/// distinct from <see cref="Destination"/> in every component.</summary>
|
||||
private static readonly Vector3 DecoyWirePose = new(-70f, -80f, -90f);
|
||||
|
||||
[Fact]
|
||||
public void FarSnap_PlacesTheBodyFromTheCanonicalDestination_NotTheCallersWirePose()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.AllowDestination();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004001u, Destination);
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
DecoyWirePose,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement,
|
||||
routing.Arm);
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Committed,
|
||||
routing.Placement);
|
||||
Assert.Equal(
|
||||
Destination + RemotePlacementDriveFixture.DestinationWorldOffset,
|
||||
body.Position);
|
||||
Assert.NotEqual(DecoyWirePose, body.Position);
|
||||
// The canonical placement — not the App — committed residency.
|
||||
Assert.Equal(RemotePlacementDriveFixture.DestinationCell, record.FullCellId);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
Assert.Equal(0, fixture.RemotePlacementLedger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's far branch stops the interpolation queue BEFORE placing
|
||||
/// (@0x005163CB precedes @0x005163D9). A stale near waypoint surviving the
|
||||
/// snap would drag the freshly placed body back on the next per-tick
|
||||
/// catch-up.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarSnap_ClearsTheInterpolationQueue()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.AllowDestination();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004002u, Destination);
|
||||
remote.Interp.Enqueue(
|
||||
new Vector3(40f, 40f, 7f),
|
||||
Quaternion.Identity,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: body.Position,
|
||||
currentBodyOrientation: body.Orientation);
|
||||
Assert.True(remote.Interp.IsActive);
|
||||
|
||||
_ = LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
DecoyWirePose,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.False(remote.Interp.IsActive);
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The inverted test (C4 route 4b-2 review).</b> This used to assert
|
||||
/// <c>Assert.Equal(before, body.Position)</c> and so PINNED the defect
|
||||
/// both reviews found: a refused far snap left the body at its stale pose
|
||||
/// with the interpolation queue already cleared, i.e. a frozen remote
|
||||
/// that the next 5-10 Hz packet reproduces identically.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail never leaves the object behind. When
|
||||
/// <c>CPhysicsObj::SetPositionInternal</c> @0x00515BD0 resolves no cell,
|
||||
/// it takes @0x00515C1D and still commits the destination —
|
||||
/// <c>store_position</c> @0x00515CE2 — before <c>GotoLostCell</c>
|
||||
/// @0x00515CF2. So a refusal advances the body to the accepted
|
||||
/// destination, resolved through Runtime's world frame (NOT the caller's
|
||||
/// wire pose), while retaining no operation and committing no cell.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarSnap_RefusedDestination_StillAdvancesTheBodyToTheAcceptedDestination()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
// Deliberately NOT AllowDestination().
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004003u, Destination);
|
||||
Vector3 before = body.Position;
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
DecoyWirePose,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement,
|
||||
routing.Arm);
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Refused,
|
||||
routing.Placement);
|
||||
// store_position: the body TRACKED, and from the canonical
|
||||
// destination — not the caller's wire pose, and not its stale pose.
|
||||
Assert.Equal(
|
||||
Destination + RemotePlacementDriveFixture.DestinationWorldOffset,
|
||||
body.Position);
|
||||
Assert.NotEqual(before, body.Position);
|
||||
Assert.NotEqual(DecoyWirePose, body.Position);
|
||||
// …and it is a store_position, NOT a placement: no cell was
|
||||
// committed, nothing was retained.
|
||||
Assert.Equal(RemotePlacementDriveFixture.SourceCell, record.FullCellId);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
Assert.Equal(0, fixture.RemotePlacementLedger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same refusal repeated: a remote genuinely beyond the host's
|
||||
/// service window keeps TRACKING every packet rather than freezing until
|
||||
/// the window opens. This is the reachable production shape the review
|
||||
/// named — a remote that is rendered but still streaming, refused while
|
||||
/// genuinely beyond 96 m — and it is why the fallback cannot be a
|
||||
/// one-shot.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarSnap_RepeatedRefusals_KeepTrackingEveryPacket()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x7000400Cu, Destination);
|
||||
|
||||
foreach (Vector3 step in new[]
|
||||
{
|
||||
new Vector3(20f, 14f, 7f),
|
||||
new Vector3(28f, 14f, 7f),
|
||||
new Vector3(36f, 14f, 7f),
|
||||
})
|
||||
{
|
||||
record.Snapshot = record.Snapshot with
|
||||
{
|
||||
Position = new CreateObject.ServerPosition(
|
||||
RemotePlacementDriveFixture.DestinationCell,
|
||||
step.X,
|
||||
step.Y,
|
||||
step.Z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
};
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
DecoyWirePose,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Refused,
|
||||
routing.Placement);
|
||||
Assert.Equal(
|
||||
step + RemotePlacementDriveFixture.DestinationWorldOffset,
|
||||
body.Position);
|
||||
}
|
||||
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
Assert.Equal(0, fixture.RemotePlacementLedger);
|
||||
}
|
||||
|
||||
// ── The null / Rejected* / cell-less policy ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The login-window trap. <c>ClassifyRemoteAcceptedPosition</c> returns
|
||||
/// null for EVERY remote packet until the local movement controller
|
||||
/// exists, because there is no <c>player_distance</c> to derive. Deleting
|
||||
/// the legacy block without a replacement would freeze every remote for
|
||||
/// that whole window. The stated policy routes it through AP-87's
|
||||
/// catch-up, whose 4 m guard PLACES a body that has drifted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NoClassificationAtAll_StillTracksTheServer()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004004u, Destination);
|
||||
var target = new Vector3(60f, 10f, 7f); // 50 m from the body.
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
route: null,
|
||||
target,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp,
|
||||
routing.Arm);
|
||||
Assert.Equal(target, body.Position);
|
||||
// No canonical placement was attempted for an unclassified packet.
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// …and the same policy ENQUEUES when the body is already tracking, which
|
||||
/// is what proves it is AP-87's catch-up rather than an unconditional
|
||||
/// snap bolted on to make the previous test pass.
|
||||
///
|
||||
/// <para>
|
||||
/// R7 review fix: the earlier version asserted only that the body did not
|
||||
/// move, which "snap when <c>bodyToTarget > 4 m</c>, else do nothing"
|
||||
/// satisfies just as well as an enqueue — it and its sibling both passed
|
||||
/// against that regression. The queue assertion below is what actually
|
||||
/// distinguishes "enqueued" from "did nothing".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NoClassificationAtAll_NearAndTicked_EnqueuesInsteadOfSnapping()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004005u, Destination);
|
||||
Vector3 before = body.Position;
|
||||
var target = before + new Vector3(0.5f, 0f, 0f);
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
route: null,
|
||||
target,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp,
|
||||
routing.Arm);
|
||||
Assert.Equal(before, body.Position);
|
||||
Assert.True(remote.Interp.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectedData_TakesTheUnroutedCatchUp_NotTheFarSnap()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.AllowDestination();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004006u, Destination);
|
||||
var target = new Vector3(60f, 10f, 7f);
|
||||
|
||||
RuntimeAuthoritativePositionRoute rejected =
|
||||
Classify(hasContact: true, playerDistance: float.NaN);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.RejectedData,
|
||||
rejected.Disposition);
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
rejected,
|
||||
target,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp,
|
||||
routing.Arm);
|
||||
Assert.Equal(target, body.Position);
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's cell-less body takes <c>this_1->cell == 0</c> @0x00516386
|
||||
/// (<c>SetPosition</c>), never the far branch — and route 4b-3, not this
|
||||
/// slice, owns it. Claiming it here would be exactly route 4a's
|
||||
/// "'not Interpolate' is not 'far'" finding one level up.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CellLessRemote_TakesTheUnroutedCatchUp_AndNeverThePlacementOwner()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.AllowDestination();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004007u, Destination);
|
||||
var target = new Vector3(60f, 10f, 7f);
|
||||
|
||||
RuntimeAuthoritativePositionRoute cellLess = Classify(
|
||||
hasContact: true,
|
||||
playerDistance: 200f,
|
||||
committedCellId: 0u);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
cellLess.Disposition);
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
cellLess,
|
||||
target,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp,
|
||||
routing.Arm);
|
||||
Assert.Equal(target, body.Position);
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
Assert.Equal(0, fixture.RemotePlacementLedger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The airborne precedence carve-out still outranks the far snap, not
|
||||
/// only the near branch: a remote mid-arc whose packet happens to
|
||||
/// classify far must keep the pre-existing authoritative hard-snap rather
|
||||
/// than open a canonical placement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AirborneBody_OutranksTheFarSnap()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.AllowDestination();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x70004008u, Destination);
|
||||
remote.Airborne = true;
|
||||
var target = new Vector3(60f, 10f, 7f);
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
target,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap,
|
||||
routing.Arm);
|
||||
Assert.Equal(target, body.Position);
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity independence across the two arms in the same tick: one
|
||||
/// remote's committed placement must not touch another's body or leak
|
||||
/// into its ledger.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TwoRemotesInTheSameTick_FarAndNear_DoNotContaminateEachOther()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.AllowDestination();
|
||||
(RuntimeEntityRecord farRecord, RemoteMotion farRemote,
|
||||
PhysicsBody farBody) = fixture.AddRemote(0x70004009u, Destination);
|
||||
(RuntimeEntityRecord nearRecord, RemoteMotion nearRemote,
|
||||
PhysicsBody nearBody) = fixture.AddRemote(0x7000400Au, Destination);
|
||||
Vector3 nearBefore = nearBody.Position;
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement,
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
farRecord,
|
||||
farRemote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
DecoyWirePose,
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true).Arm);
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm
|
||||
.SteadyStateInterpolate,
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
nearRecord,
|
||||
nearRemote,
|
||||
Classify(hasContact: true, playerDistance: 10f),
|
||||
nearBefore + new Vector3(0.5f, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true).Arm);
|
||||
|
||||
Assert.Equal(
|
||||
Destination + RemotePlacementDriveFixture.DestinationWorldOffset,
|
||||
farBody.Position);
|
||||
Assert.Equal(nearBefore, nearBody.Position);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
Assert.Equal(0, fixture.RemotePlacementLedger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R10 review fix: <c>AirborneNoOperation</c> used to fall into
|
||||
/// <c>default:</c>, whose comment ASSERTED unreachability that nothing
|
||||
/// enforced — so a caller that skipped its own airborne-no-op early
|
||||
/// return would have silently enqueued a waypoint on a branch where
|
||||
/// retail writes nothing at all (@0x0051636D). The explicit case is what
|
||||
/// makes the assertion true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AirborneNoOperationClassification_IsRejectedByTheRoutingSeam()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
|
||||
fixture.AddRemote(0x7000400Du, Destination);
|
||||
Vector3 before = body.Position;
|
||||
|
||||
RuntimeAuthoritativePositionRoute airborne =
|
||||
Classify(hasContact: false, playerDistance: 200f);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
||||
airborne.Disposition);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
airborne,
|
||||
new Vector3(60f, 10f, 7f),
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true));
|
||||
|
||||
Assert.Equal(before, body.Position);
|
||||
Assert.False(remote.Interp.IsActive);
|
||||
Assert.Equal(0, fixture.LiveOperationCount);
|
||||
}
|
||||
|
||||
// ── The NPC arm's post-routing wire-cell adoption ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// After a far snap the canonical placement owns the cell. The NPC arm's
|
||||
/// wire-cell write sits AFTER its routing (the player arm's sits before),
|
||||
/// and <c>RemoteMotion.CellId</c> writes through to the canonical
|
||||
/// <c>FullCellId</c> — so an unguarded write would discard the resolved
|
||||
/// cell the placement just committed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WireCellAdoption_IsSuppressedAfterAFarSnapAndRunsForEveryOtherArm()
|
||||
{
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, _) =
|
||||
fixture.AddRemote(0x7000400Bu, Destination);
|
||||
uint resolved = remote.CellId;
|
||||
Assert.Equal(RemotePlacementDriveFixture.SourceCell, resolved);
|
||||
|
||||
Assert.False(
|
||||
LiveEntityNetworkUpdateController.TryAdoptWireCellAfterRouting(
|
||||
remote,
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm
|
||||
.FarSnapPlacement,
|
||||
RemotePlacementDriveFixture.DestinationCell));
|
||||
Assert.Equal(resolved, remote.CellId);
|
||||
Assert.Equal(resolved, record.FullCellId);
|
||||
|
||||
foreach (LiveEntityNetworkUpdateController.RemoteContactArm arm in
|
||||
new[]
|
||||
{
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap,
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm
|
||||
.SteadyStateInterpolate,
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm
|
||||
.UnroutedCatchUp,
|
||||
})
|
||||
{
|
||||
remote.CellId = RemotePlacementDriveFixture.SourceCell;
|
||||
Assert.True(
|
||||
LiveEntityNetworkUpdateController.TryAdoptWireCellAfterRouting(
|
||||
remote,
|
||||
arm,
|
||||
RemotePlacementDriveFixture.DestinationCell));
|
||||
Assert.Equal(
|
||||
RemotePlacementDriveFixture.DestinationCell, remote.CellId);
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeAuthoritativePositionRoute Classify(
|
||||
bool hasContact,
|
||||
float playerDistance,
|
||||
uint committedCellId = RemotePlacementDriveFixture.SourceCell) =>
|
||||
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
||||
new RuntimeAcceptedPositionRouteRequest(
|
||||
new RuntimeAuthoritativePositionAuthority(
|
||||
new RuntimeGenerationToken(7),
|
||||
new RuntimeEntityKey(0x70000001u, 3),
|
||||
PositionAuthorityVersion: 11UL,
|
||||
AcceptedPositionSequence: 20,
|
||||
PreviousTeleportSequence: 10,
|
||||
AcceptedTeleportSequence: 10,
|
||||
PositionTimestampDisposition.Apply),
|
||||
RuntimePositionEntityKind.Remote,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
new CreateObject.ServerPosition(
|
||||
RemotePlacementDriveFixture.DestinationCell,
|
||||
Destination.X,
|
||||
Destination.Y,
|
||||
Destination.Z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
PlacementFrame: 0u,
|
||||
PositionPackVelocity: Vector3.Zero,
|
||||
committedCellId,
|
||||
hasContact,
|
||||
playerDistance,
|
||||
UsePositionFromServer: false,
|
||||
HasAnimations: false,
|
||||
default));
|
||||
}
|
||||
|
|
@ -157,11 +157,17 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
|
|||
RuntimeAuthoritativePositionDisposition.Interpolate,
|
||||
route.Disposition);
|
||||
|
||||
RemoteMotion remote = MakeLandingRemote();
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, _) =
|
||||
fixture.AddRemote(0x70005001u, new Vector3(12f, 14f, 7f));
|
||||
remote.Body.Position = new Vector3(10f, 10f, 5f);
|
||||
remote.Airborne = true;
|
||||
var landing = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m.
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm arm =
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
route,
|
||||
landing,
|
||||
|
|
@ -170,7 +176,7 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
|
|||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap,
|
||||
arm);
|
||||
routing.Arm);
|
||||
Assert.Equal(landing, remote.Body.Position);
|
||||
}
|
||||
|
||||
|
|
@ -181,12 +187,17 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
|
|||
// route 4a's own branch: queued for the per-tick catch-up, body
|
||||
// untouched. This is what proves the airborne test above is a
|
||||
// PRECEDENCE carve-out and not a blanket disable of route 4a.
|
||||
RemoteMotion remote = MakeLandingRemote();
|
||||
using var fixture = new RemotePlacementDriveFixture();
|
||||
(RuntimeEntityRecord record, RemoteMotion remote, _) =
|
||||
fixture.AddRemote(0x70005002u, new Vector3(12f, 14f, 7f));
|
||||
remote.Body.Position = new Vector3(10f, 10f, 5f);
|
||||
remote.Airborne = false;
|
||||
var target = new Vector3(10.5f, 10f, 5f);
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm arm =
|
||||
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
fixture.Drive,
|
||||
record,
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 10f),
|
||||
target,
|
||||
|
|
@ -196,43 +207,10 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
|
|||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm
|
||||
.SteadyStateInterpolate,
|
||||
arm);
|
||||
routing.Arm);
|
||||
Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundedBodyOnAClassificationRoute4aDoesNotOwn_FallsThroughToLegacy()
|
||||
{
|
||||
RemoteMotion remote = MakeLandingRemote();
|
||||
remote.Airborne = false;
|
||||
Vector3 before = remote.Body.Position;
|
||||
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm arm =
|
||||
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
||||
remote,
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
new Vector3(10.5f, 10f, 5f),
|
||||
Quaternion.Identity,
|
||||
willBeDrTicked: true);
|
||||
|
||||
Assert.Equal(
|
||||
LiveEntityNetworkUpdateController.RemoteContactArm.Legacy,
|
||||
arm);
|
||||
// Nothing was written — the caller's own legacy routing owns it.
|
||||
Assert.Equal(before, remote.Body.Position);
|
||||
}
|
||||
|
||||
private static RemoteMotion MakeLandingRemote()
|
||||
{
|
||||
var remote = new RemoteMotion();
|
||||
remote.Body.Position = new Vector3(10f, 10f, 5f);
|
||||
remote.Body.Orientation = Quaternion.Identity;
|
||||
// Past the AP-87 firstUp hint, so the near case really is near.
|
||||
remote.LastServerPosTime = 1_700_000_000d;
|
||||
remote.Airborne = true;
|
||||
return remote;
|
||||
}
|
||||
|
||||
private static void AssertRenderPoseSuppressed(
|
||||
RuntimeAuthoritativePositionRoute? route)
|
||||
{
|
||||
|
|
|
|||
218
tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs
Normal file
218
tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2: a REAL
|
||||
/// <see cref="RuntimeRemotePlacementDriveController"/> over a bare
|
||||
/// <see cref="RuntimeEntityObjectLifetime"/>, so App-layer acceptance tests
|
||||
/// drive the production far-snap path end to end instead of simulating it.
|
||||
/// Mirrors <c>RuntimeRemotePlacementDriveControllerTests</c>' own fixture.
|
||||
/// </summary>
|
||||
internal sealed class RemotePlacementDriveFixture : IDisposable
|
||||
{
|
||||
internal const uint SourceLandblock = 0xB1000000u;
|
||||
internal const uint SourceCell = SourceLandblock | 0x0001u;
|
||||
internal const uint DestinationLandblock = 0xB2000000u;
|
||||
internal const uint DestinationCell = DestinationLandblock | 0x0001u;
|
||||
/// <summary>The +X world offset <see cref="PublishDestinationCollision"/>
|
||||
/// gives the destination landblock, so a committed placement's world
|
||||
/// position is the authored local position plus this.</summary>
|
||||
internal static readonly Vector3 DestinationWorldOffset = new(192f, 0f, 0f);
|
||||
internal const float SpawnHeight = 7f;
|
||||
|
||||
private readonly ServiceWindow _window = new();
|
||||
|
||||
internal RemotePlacementDriveFixture()
|
||||
{
|
||||
Lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
Drive = new RuntimeRemotePlacementDriveController(
|
||||
Lifetime,
|
||||
new GameRuntimeClock(),
|
||||
new UnusedCollisionSource(),
|
||||
_window);
|
||||
}
|
||||
|
||||
internal RuntimeEntityObjectLifetime Lifetime { get; }
|
||||
|
||||
internal RuntimeRemotePlacementDriveController Drive { get; }
|
||||
|
||||
internal void AllowDestination() => _window.Allow(DestinationLandblock);
|
||||
|
||||
/// <summary>
|
||||
/// Commits the destination landblock's collision generation and observes
|
||||
/// the source world frame once, so a placement into
|
||||
/// <see cref="DestinationCell"/> can actually resolve.
|
||||
/// </summary>
|
||||
internal void PublishDestinationCollision()
|
||||
{
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)SpawnHeight);
|
||||
var heightTable = new float[256];
|
||||
for (int index = 0; index < heightTable.Length; index++)
|
||||
heightTable[index] = index;
|
||||
Lifetime.Physics.ObserveLocalWorldFrame(
|
||||
SourceCell, teleportAdvanced: false);
|
||||
Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
DestinationLandblock, 1UL);
|
||||
Lifetime.Physics.Engine.AddLandblock(
|
||||
DestinationLandblock,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: DestinationWorldOffset.X,
|
||||
worldOffsetY: DestinationWorldOffset.Y);
|
||||
Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
DestinationLandblock, 1UL, ready: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers one remote incarnation with a canonical body, its shared
|
||||
/// <see cref="RemoteMotion"/>, and the accepted destination already merged
|
||||
/// onto its snapshot — exactly the state
|
||||
/// <c>RuntimeEntityObjectLifetime.TryApplyPosition</c> leaves behind
|
||||
/// before <c>OnPosition</c> routes the packet.
|
||||
/// </summary>
|
||||
internal (RuntimeEntityRecord Record, RemoteMotion Remote, PhysicsBody Body)
|
||||
AddRemote(uint guid, Vector3 destination)
|
||||
{
|
||||
RuntimeEntityRecord record = Lifetime.RegisterEntity(
|
||||
Spawn(guid)).Canonical!;
|
||||
Lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity);
|
||||
Lifetime.Entities.SetFullCell(
|
||||
record, SourceCell, (SourceCell & 0xFFFF0000u) | 0xFFFFu);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(10f, 10f, SpawnHeight),
|
||||
Orientation = Quaternion.Identity,
|
||||
LastUpdateTime = 1d,
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
body.SnapToCell(SourceCell, body.Position, body.Position);
|
||||
Lifetime.Entities.SetPhysicsBody(record, body);
|
||||
record.ObjectClock.Activate();
|
||||
Lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
|
||||
record.Snapshot = record.Snapshot with
|
||||
{
|
||||
Position = new CreateObject.ServerPosition(
|
||||
DestinationCell,
|
||||
destination.X,
|
||||
destination.Y,
|
||||
destination.Z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
};
|
||||
RemoteMotion remote = Lifetime.Physics.GetOrCreateRemoteMotion(record);
|
||||
// Past AP-87's firstUp hint, so the 4 m body-to-target guard is the
|
||||
// condition under test rather than the first-sample one.
|
||||
remote.LastServerPosTime = 1_700_000_000d;
|
||||
return (record, remote, body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the production placement-projection subscription this
|
||||
/// bare fixture never wires.
|
||||
/// </summary>
|
||||
internal void DrainPlacementFifo()
|
||||
{
|
||||
while (Lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
internal int LiveOperationCount =>
|
||||
Lifetime.Physics.CaptureOwnership().SetPositionOperationCount;
|
||||
|
||||
internal int RemotePlacementLedger =>
|
||||
Lifetime.CaptureOwnership().RemotePlacementDrivePendingCount;
|
||||
|
||||
public void Dispose() => Lifetime.Dispose();
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid) => new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f),
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "remote",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: 0x09000001u);
|
||||
|
||||
private static PhysicsEngine FlatEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
SourceLandblock,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private sealed class ServiceWindow : IRuntimeRemotePlacementServiceWindow
|
||||
{
|
||||
private readonly HashSet<uint> _within = [];
|
||||
|
||||
internal void Allow(uint landblockId) =>
|
||||
_within.Add((landblockId & 0xFFFF0000u) | 0xFFFFu);
|
||||
|
||||
public bool IsWithinServiceWindow(uint landblockId) =>
|
||||
_within.Contains((landblockId & 0xFFFF0000u) | 0xFFFFu);
|
||||
}
|
||||
|
||||
private sealed class UnusedCollisionSource : IPreparedCollisionSource
|
||||
{
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
PakAssetType type,
|
||||
uint sourceFileId) => PreparedAssetPresence.Available;
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PreparedCollisionReadResult<FlatSetupCollision>.Missing;
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats => default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2: the arm-selection half of the remote far snap. Every route
|
||||
/// here is produced by the REAL classifier from a real request, including the
|
||||
/// negative cases the classifier genuinely emits from other entry points (a
|
||||
/// local-player <c>SetPositionSimple</c>, a remote top-level Create) and which
|
||||
/// the far arm must decline. Exactly ONE route is hand-shaped — the
|
||||
/// flags/disposition mismatch in
|
||||
/// <see cref="OwnsFarSnap_IsAStrictSubsetOfThePlacementOwnersPredicate"/>,
|
||||
/// which no classifier path produces but a caller could express.
|
||||
///
|
||||
/// <para>
|
||||
/// Each test was verified to discriminate by temporarily reverting the
|
||||
/// corresponding guard in <see cref="RuntimeRemoteFarSnapPosition"/> and
|
||||
/// confirming the matching test failed, then restoring it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RuntimeRemoteFarSnapPositionTests
|
||||
{
|
||||
private const uint Cell = 0x0101FFFFu;
|
||||
|
||||
[Fact]
|
||||
public void OwnsFarSnap_TrueForTheRemoteFarBranch()
|
||||
{
|
||||
RuntimeAuthoritativePositionRoute far =
|
||||
Classify(hasContact: true, playerDistance: 200f);
|
||||
|
||||
// Retail: player_distance >= 96f -> StopInterpolating @0x005163CB +
|
||||
// SetPositionSimple @0x005163D9, whose arg3 != 0 builds flags 0x1012
|
||||
// (Teleport|Slide|SendPositionEvent) @0x005162C4.
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
far.Disposition);
|
||||
Assert.True(far.StopInterpolating);
|
||||
Assert.True(RuntimeRemoteFarSnapPosition.OwnsFarSnap(far));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnsFarSnap_TrueExactlyAtTheRetailBoundary()
|
||||
{
|
||||
// Retail compares player_distance against 96f and takes the
|
||||
// InterpolateTo branch only when strictly LESS (@0x00516393-@0x0051639E,
|
||||
// the `x87_r7 < temp1` test). 96.0 itself is the far branch.
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(hasContact: true, playerDistance: 95.99f)));
|
||||
Assert.True(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(hasContact: true, playerDistance: 96f)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnsFarSnap_FalseForEveryOtherRemoteClassification()
|
||||
{
|
||||
// Near InterpolateTo @0x005163AF.
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(hasContact: true, playerDistance: 10f)));
|
||||
// Airborne no-op @0x0051636D.
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(hasContact: false, playerDistance: 10f)));
|
||||
// Cell-less: retail's `this_1->cell == 0` branch @0x00516386 —
|
||||
// SetPosition, NOT the far snap. 4b-3 owns it.
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(hasContact: true, playerDistance: 200f, committedCellId: 0u)));
|
||||
// Fresh TELEPORT_TS: the same @0x00516386 branch. 4b-3 owns it.
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(
|
||||
hasContact: true,
|
||||
playerDistance: 200f,
|
||||
previousTeleport: 10,
|
||||
acceptedTeleport: 11)));
|
||||
// Rejections and "no classification at all".
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(
|
||||
hasContact: true,
|
||||
playerDistance: 200f,
|
||||
disposition: PositionTimestampDisposition.Rejected)));
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(
|
||||
Classify(hasContact: true, playerDistance: float.NaN)));
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The disposition alone is not the discriminator: the classifier emits
|
||||
/// <c>SetPositionSimple</c> for the LOCAL PLAYER's FORCE_POSITION branch
|
||||
/// too (<c>RuntimeAuthoritativePositionRouteClassifier.cs:332</c>). Route
|
||||
/// 4b-2 is a remote route; claiming that one would run a remote arm over
|
||||
/// route 2's local-player transaction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OwnsFarSnap_FalseForTheLocalPlayerForcePositionRoute()
|
||||
{
|
||||
RuntimeAuthoritativePositionRoute force = ClassifyKind(
|
||||
RuntimePositionEntityKind.LocalPlayer,
|
||||
hasContact: true,
|
||||
playerDistance: 200f,
|
||||
disposition: PositionTimestampDisposition.ForcePosition);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
force.Disposition);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative,
|
||||
force.OperationKind);
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(force));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A REMOTE top-level initial Create is also <c>RemoteAuthoritative</c>;
|
||||
/// it is excluded here by its disposition (<c>SetPosition</c>), which
|
||||
/// route 4b-3 owns.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OwnsFarSnap_FalseForARemoteTopLevelCreateRoute()
|
||||
{
|
||||
RuntimeAuthoritativePositionRoute create =
|
||||
RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate(
|
||||
new RuntimeCreatePositionRouteRequest(
|
||||
Authority(PositionTimestampDisposition.Apply, 10, 10),
|
||||
RuntimePositionEntityKind.Remote,
|
||||
RuntimeCreateResidenceKind.TopLevel,
|
||||
new CreateObject.ServerPosition(
|
||||
Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f),
|
||||
default));
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
create.OperationKind);
|
||||
Assert.Equal(
|
||||
PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide,
|
||||
create.SetPositionFlags);
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(create));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The invariant the <c>Teleport</c>-flag term actually carries:
|
||||
/// <see cref="RuntimeRemoteFarSnapPosition.OwnsFarSnap"/> must be a strict
|
||||
/// SUBSET of
|
||||
/// <see cref="RuntimeRemotePlacementDriveController.OwnsPlacement"/>. The
|
||||
/// far arm hands its route straight to that controller, so any route the
|
||||
/// arm claims but the controller declines would execute
|
||||
/// <c>StopInterpolating</c> and then silently do nothing
|
||||
/// (<c>NotApplicable</c>) — a remote that stops tracking the server with
|
||||
/// no diagnostic. The flags term is what keeps the two predicates
|
||||
/// aligned; without it, the hand-shaped route below is claimed here and
|
||||
/// declined there.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OwnsFarSnap_IsAStrictSubsetOfThePlacementOwnersPredicate()
|
||||
{
|
||||
// Every route the real classifier produces.
|
||||
foreach (RuntimeAuthoritativePositionRoute route in new[]
|
||||
{
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
Classify(hasContact: true, playerDistance: 10f),
|
||||
Classify(hasContact: false, playerDistance: 10f),
|
||||
Classify(hasContact: true, playerDistance: 200f, committedCellId: 0u),
|
||||
Classify(hasContact: true, playerDistance: float.NaN),
|
||||
ClassifyKind(
|
||||
RuntimePositionEntityKind.LocalPlayer,
|
||||
hasContact: true,
|
||||
playerDistance: 200f,
|
||||
disposition: PositionTimestampDisposition.ForcePosition),
|
||||
})
|
||||
{
|
||||
if (RuntimeRemoteFarSnapPosition.OwnsFarSnap(route))
|
||||
{
|
||||
Assert.True(
|
||||
RuntimeRemotePlacementDriveController.OwnsPlacement(route));
|
||||
}
|
||||
}
|
||||
|
||||
// …and the shape a caller could construct that the classifier does
|
||||
// not: the far disposition and operation kind with a Create's flags.
|
||||
RuntimeAuthoritativePositionRoute mismatched =
|
||||
Classify(hasContact: true, playerDistance: 200f) with
|
||||
{
|
||||
SetPositionFlags = PhysicsSetPositionFlags.Placement
|
||||
| PhysicsSetPositionFlags.Slide,
|
||||
};
|
||||
Assert.False(
|
||||
RuntimeRemotePlacementDriveController.OwnsPlacement(mismatched));
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsFarSnap(mismatched));
|
||||
}
|
||||
|
||||
// ── Arm selection is total, and the leftovers are named ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResolveArm_MapsEveryRemoteClassificationToExactlyOneArm()
|
||||
{
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.AirborneNoOperation,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(
|
||||
Classify(hasContact: false, playerDistance: 10f)));
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.NearInterpolate,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(
|
||||
Classify(hasContact: true, playerDistance: 10f)));
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.FarSnapPlacement,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(
|
||||
Classify(hasContact: true, playerDistance: 200f)));
|
||||
|
||||
// The acdream-only leftovers, all four shapes. This is the stated
|
||||
// policy: they are NOT far. Reading "not Interpolate" as "far" is
|
||||
// route 4a's own review finding, one level up.
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(
|
||||
Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u)));
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(
|
||||
Classify(
|
||||
hasContact: true,
|
||||
playerDistance: 10f,
|
||||
disposition: PositionTimestampDisposition.Rejected)));
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(
|
||||
Classify(hasContact: true, playerDistance: float.NaN)));
|
||||
Assert.Equal(
|
||||
RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp,
|
||||
RuntimeRemoteFarSnapPosition.ResolveArm(null));
|
||||
}
|
||||
|
||||
// ── The single ConstrainTo site's gate ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OwnsAfterOperationConstraint_CoversTheThreeArmsThatRunAnOperation()
|
||||
{
|
||||
// Retail arms @0x00454272 for every nonzero MoveOrTeleport return.
|
||||
// The airborne no-op returns 0 @0x0051636D, so it is excluded — and
|
||||
// that exclusion is route 4a's, carried forward unchanged.
|
||||
Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(
|
||||
Classify(hasContact: true, playerDistance: 10f)));
|
||||
Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(
|
||||
Classify(hasContact: true, playerDistance: 200f)));
|
||||
Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(
|
||||
Classify(hasContact: false, playerDistance: 10f)));
|
||||
|
||||
// The leftovers still arm through the App's legacy pre-operation
|
||||
// site; claiming them here would double-arm them.
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(
|
||||
Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u)));
|
||||
Assert.False(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(
|
||||
Classify(hasContact: true, playerDistance: float.NaN)));
|
||||
Assert.False(
|
||||
RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(null));
|
||||
}
|
||||
|
||||
private static RuntimeAuthoritativePositionAuthority Authority(
|
||||
PositionTimestampDisposition disposition,
|
||||
ushort previousTeleport,
|
||||
ushort acceptedTeleport) =>
|
||||
new(
|
||||
new RuntimeGenerationToken(7),
|
||||
new RuntimeEntityKey(0x70000001u, 3),
|
||||
PositionAuthorityVersion: 11UL,
|
||||
AcceptedPositionSequence: 20,
|
||||
previousTeleport,
|
||||
acceptedTeleport,
|
||||
disposition);
|
||||
|
||||
private static RuntimeAuthoritativePositionRoute Classify(
|
||||
bool hasContact,
|
||||
float playerDistance,
|
||||
uint committedCellId = Cell,
|
||||
ushort previousTeleport = 10,
|
||||
ushort acceptedTeleport = 10,
|
||||
PositionTimestampDisposition disposition =
|
||||
PositionTimestampDisposition.Apply) =>
|
||||
ClassifyKind(
|
||||
RuntimePositionEntityKind.Remote,
|
||||
hasContact,
|
||||
playerDistance,
|
||||
committedCellId,
|
||||
previousTeleport,
|
||||
acceptedTeleport,
|
||||
disposition);
|
||||
|
||||
private static RuntimeAuthoritativePositionRoute ClassifyKind(
|
||||
RuntimePositionEntityKind kind,
|
||||
bool hasContact,
|
||||
float playerDistance,
|
||||
uint committedCellId = Cell,
|
||||
ushort previousTeleport = 10,
|
||||
ushort acceptedTeleport = 10,
|
||||
PositionTimestampDisposition disposition =
|
||||
PositionTimestampDisposition.Apply) =>
|
||||
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
||||
new RuntimeAcceptedPositionRouteRequest(
|
||||
Authority(disposition, previousTeleport, acceptedTeleport),
|
||||
kind,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
new CreateObject.ServerPosition(
|
||||
Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f),
|
||||
PlacementFrame: 0u,
|
||||
PositionPackVelocity: Vector3.Zero,
|
||||
committedCellId,
|
||||
hasContact,
|
||||
playerDistance,
|
||||
UsePositionFromServer: false,
|
||||
HasAnimations: false,
|
||||
default));
|
||||
}
|
||||
|
|
@ -317,17 +317,52 @@ public sealed class RuntimeRemoteSteadyStatePositionTests
|
|||
Assert.Null(host.PositionManager.Constraint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2: the far branch moved onto the post-operation arm with
|
||||
/// route 4a's two. Retail's `MoveOrTeleport` returns 1 @0x005163E8 there,
|
||||
/// so `HandleReceivedPosition`'s single `ConstrainTo` @0x00454272 runs —
|
||||
/// and the App's legacy PRE-operation call site now reads the same
|
||||
/// predicate and skips it, so it is armed exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryArmConstraintAfterOperation_SkipsClassificationsRoute4aDoesNotOwn()
|
||||
public void TryArmConstraintAfterOperation_ArmsForTheFarSnapBranch()
|
||||
{
|
||||
// The far branch still arms its leash — through the untouched legacy
|
||||
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
||||
new Vector3(1f, 2f, 3f));
|
||||
|
||||
RuntimeAuthoritativePositionRoute far =
|
||||
Classify(hasContact: true, playerDistance: 200f);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
far.Disposition);
|
||||
|
||||
Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
far,
|
||||
remote));
|
||||
Assert.True(host.PositionManager.Constraint?.IsConstrained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryArmConstraintAfterOperation_SkipsClassificationsNoArmOwns()
|
||||
{
|
||||
// The cell-less half, the two rejections, and "no classification at
|
||||
// all" still arm their leash through the untouched legacy
|
||||
// PRE-operation call site, not through here. Arming here too would
|
||||
// double-arm it.
|
||||
// double-arm them.
|
||||
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
|
||||
new Vector3(1f, 2f, 3f));
|
||||
|
||||
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
Classify(hasContact: true, playerDistance: 200f),
|
||||
Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u),
|
||||
remote));
|
||||
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
Classify(
|
||||
hasContact: true,
|
||||
playerDistance: 10f,
|
||||
disposition: PositionTimestampDisposition.Rejected),
|
||||
remote));
|
||||
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
Classify(hasContact: true, playerDistance: float.NaN),
|
||||
remote));
|
||||
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.Net;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
|
@ -28,6 +28,30 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
private const uint SpawnLandblock = 0x01010000u;
|
||||
private const float SpawnHeight = 5f;
|
||||
|
||||
/// <summary>
|
||||
/// A landblock whose collision generation is deliberately NEVER committed
|
||||
/// in this fixture — the same id the deferred-park tests below use. The
|
||||
/// pre-engine quiescence check runs ahead of the engine and therefore
|
||||
/// ahead of any collision-readiness question, so its readiness is
|
||||
/// irrelevant to the round-3 destination-quiescence test.
|
||||
/// </summary>
|
||||
private const uint DestinationLandblock = 0x02020000u;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SpawnLandblock"/>'s +X neighbour: landblock ids pack the
|
||||
/// block X index in bits 24-31, so 0x0101 → 0x0201 is one block east, and
|
||||
/// <c>LandDefs.LcoordToGid</c> re-derives exactly this prefix for a global
|
||||
/// lcoord one cell past the 192 m seam.
|
||||
/// </summary>
|
||||
private const uint NeighbourLandblock = 0x02010000u;
|
||||
|
||||
/// <summary>
|
||||
/// Cell (7, 0) of <see cref="SpawnLandblock"/> — block-local X in
|
||||
/// [168, 192), Y in [0, 24). <c>LandDefs.GidToLcoord</c>'s inverse:
|
||||
/// <c>low = (ly & 7) + ((lx & 7) << 3) + 1 = 0 + 56 + 1 = 57</c>.
|
||||
/// </summary>
|
||||
private const uint SpawnSeamCell = SpawnLandblock | 57u;
|
||||
|
||||
[Fact]
|
||||
public void NotApplicable_WhenDispositionIsNotForcePosition()
|
||||
{
|
||||
|
|
@ -830,6 +854,359 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2, round-3 correction A2 (test 1 of 2) — the LOCAL PLAYER
|
||||
/// traverses the shared-core quiescence-park change, on the shape that is
|
||||
/// actually reachable through this route.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Reachability, measured rather than assumed.</b> The correction asked
|
||||
/// for a park under a quiescing SOURCE, reached through
|
||||
/// <c>PlacementTouchesPrefix</c>'s <c>CurrentCellId</c> arm. That shape is
|
||||
/// not constructible: the merge this route requires
|
||||
/// (<c>RuntimeEntityObjectLifetime.TryApplyPosition</c>) commits the
|
||||
/// accepted wire cell to <c>record.FullCellId</c> BEFORE the drive is
|
||||
/// called, and <c>CurrentCellId</c> is read from that field — so by submit
|
||||
/// time it names the DESTINATION, not the landblock being left. The
|
||||
/// reachable pre-sweep shape is therefore a quiescing destination; the
|
||||
/// reachable post-sweep shape is a quiescing swept neighbour, which is the
|
||||
/// sibling test below.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A quiescing destination is the one shape where the rollback genuinely
|
||||
/// would re-admit a spatial root into the prefix that is trying to
|
||||
/// quiesce, so <c>ParkDeferred</c> declines it — AP-136's stated reason,
|
||||
/// applied to the cell the restore would actually use. Route 2 has no
|
||||
/// destination pre-flight of its own (unlike the remote far arm's
|
||||
/// <c>CanAttemptDestination</c>), so it reaches this unmasked.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QuiescingDestinationPrefix_ForcePositionParkIsNotRestored()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(record.PhysicsBody);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out _);
|
||||
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence(
|
||||
DestinationLandblock,
|
||||
collisionGeneration: 2UL,
|
||||
includeOutdoorCells: true);
|
||||
|
||||
WorldSession.EntityPositionUpdate correction = ForceUpdate(
|
||||
new Vector3(10f, 10f, SpawnHeight),
|
||||
landblockId: DestinationLandblock | 0x0001u);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, correction);
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAcceptedPositionExecutionStatus.DeferredCell,
|
||||
drive.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
correction,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport));
|
||||
Assert.False(body.InWorld);
|
||||
|
||||
// ACE's next ordinary broadcast, ~100-200 ms later. Its merge-time
|
||||
// Forget cancels the park — and the rollback must decline, because the
|
||||
// only cell it could restore into is the retiring one.
|
||||
MergeAccepted(
|
||||
runtime,
|
||||
controller,
|
||||
OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3));
|
||||
|
||||
Assert.False(runtime.EntityObjects.Physics.IsSpatialRoot(record));
|
||||
Assert.False(body.InWorld);
|
||||
Assert.False(record.ObjectClock.IsActive);
|
||||
|
||||
drive.Advance();
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2, round-3 correction A2 (test 2 of 2) — the reachable
|
||||
/// RESTORABLE quiescence park on the local-player route.
|
||||
///
|
||||
/// <para>
|
||||
/// The correction is placed one sphere radius inside the seam between
|
||||
/// <see cref="SpawnLandblock"/> and its +X neighbour, so
|
||||
/// <c>CellTransit.AddAllOutsideCells</c> adds the neighbour's cells to the
|
||||
/// sweep footprint (<c>AddOutsideCell</c> re-derives the block id from the
|
||||
/// global lcoord and has no same-block filter). Core's
|
||||
/// <c>ResultTouchesPrefix</c> scans every <c>QueriedCellIds</c> entry, so
|
||||
/// a healthy, resident, about-to-COMMIT ForcePosition is rewritten to
|
||||
/// <c>DeferredCell</c> by a quiescence in a landblock the player is
|
||||
/// neither in nor going to.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Before the shared-core change every quiescence park was non-restorable,
|
||||
/// so the next packet's merge-time <c>Forget</c> destroyed the only
|
||||
/// operation able to wake the player and left it <c>InWorld = false</c>,
|
||||
/// clock suspended, not a spatial root — session-permanent, because
|
||||
/// nothing else restores that state. The three asserts at the end are that
|
||||
/// defect. Restoring is safe here for the reason
|
||||
/// <c>ParkDeferred</c> tests directly: the cell it would restore into is
|
||||
/// the destination, and the destination is not quiescing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QuiescingSweptNeighbour_ForcePositionParkIsRestoredByTheNextPacket()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
CommitLandblockCollision(
|
||||
runtime, NeighbourLandblock, worldOffsetX: 192f);
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(record.PhysicsBody);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out _);
|
||||
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence(
|
||||
NeighbourLandblock,
|
||||
collisionGeneration: 2UL,
|
||||
includeOutdoorCells: true);
|
||||
// Neither the source nor the destination is quiescing — the whole
|
||||
// point of the shape.
|
||||
Assert.False(
|
||||
runtime.EntityObjects.Physics.SetPosition.IsCollisionPrefixQuiescing(
|
||||
SpawnLandblock));
|
||||
|
||||
// Block-local X = 191.95 m: inside cell (7, 0), 0.05 m from the 192 m
|
||||
// seam, well inside the local player's own sphere radius, which is the
|
||||
// `pointX > CellLength - radius` test AddAllOutsideCells applies
|
||||
// before adding lx + 1.
|
||||
WorldSession.EntityPositionUpdate correction = ForceUpdate(
|
||||
new Vector3(191.95f, 10f, SpawnHeight),
|
||||
landblockId: SpawnSeamCell);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, correction);
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAcceptedPositionExecutionStatus.DeferredCell,
|
||||
drive.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
correction,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport));
|
||||
Assert.False(body.InWorld);
|
||||
|
||||
MergeAccepted(
|
||||
runtime,
|
||||
controller,
|
||||
OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3));
|
||||
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(runtime.EntityObjects.Physics.IsSpatialRoot(record));
|
||||
|
||||
drive.Advance();
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2, round-4 correction (the MAJOR) — the DISCRIMINATING
|
||||
/// test. Round 3 relocated the restorable-park decision out of the two
|
||||
/// <c>SubmitPreparedPlacementCore</c> call sites and into
|
||||
/// <c>ParkDeferred</c>, but nothing in the tree distinguished the new
|
||||
/// predicate from the one it replaced. This does.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Round 2's form</b> compared the blocking quiescence's own token
|
||||
/// against the caller's pre-snap cell:
|
||||
/// <c>blockingToken.LandblockPrefix != (result.CellId & 0xFFFF0000)</c>.
|
||||
/// <b>Round 3's form</b> tests the POST-snap cell against every live
|
||||
/// quiescence: <c>!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)</c>.
|
||||
/// Under round 2 this test's player is left withdrawn — <c>InWorld</c>
|
||||
/// false, clock suspended, not a spatial root — for the rest of the
|
||||
/// session. That is the exact stranding AP-136's rollback exists to
|
||||
/// prevent, and it is reached without a second quiescence, purely because
|
||||
/// the two forms read different cells.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The shape.</b> The server names a cell in the player's OWN
|
||||
/// landblock but supplies a block-local X 5 cm PAST that block's 192 m
|
||||
/// seam. A wire (cell, position) pair that disagrees is exactly the pair
|
||||
/// <c>PhysicsBody.StageDormantCellFrame</c>'s
|
||||
/// <c>LandDefs.AdjustToOutside</c> exists to distrust (#107: "never trust
|
||||
/// a server (cell, pos) pair without re-deriving the cell"), and
|
||||
/// <c>AdjustToOutside</c>'s own contract says the re-derived id may
|
||||
/// belong to a NEIGHBOUR landblock — which is what happens here. So the
|
||||
/// prefix the placement PARKED against (the player's own, mid-retirement)
|
||||
/// and the prefix residency would be RESTORED into (the healthy
|
||||
/// neighbour) are different landblocks, and only the second one is the
|
||||
/// question <c>RestoreParkWithdrawal</c> actually asks.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Route 2 is the home for it because it has no destination pre-flight:
|
||||
/// the remote far arm's <c>CanAttemptDestination</c> refuses a quiescing
|
||||
/// destination before any park is opened, so this park is unreachable
|
||||
/// there.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(record.PhysicsBody);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out _);
|
||||
|
||||
// Streaming begins retiring/republishing the player's OWN landblock,
|
||||
// which is also the landblock the wire cell names.
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence(
|
||||
SpawnLandblock,
|
||||
collisionGeneration: 2UL,
|
||||
includeOutdoorCells: true);
|
||||
|
||||
// Cell (7, 0) of SpawnLandblock covers block-local X in [168, 192).
|
||||
// 192.05 m is 5 cm past its block's own seam, so AdjustToOutside
|
||||
// re-derives lx = 8 + floor(192.05 / 24) = 16, i.e. block X index 2,
|
||||
// and LcoordToGid rebuilds the id as NeighbourLandblock | 1.
|
||||
WorldSession.EntityPositionUpdate correction = ForceUpdate(
|
||||
new Vector3(192.05f, 10f, SpawnHeight),
|
||||
landblockId: SpawnSeamCell);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, correction);
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAcceptedPositionExecutionStatus.DeferredCell,
|
||||
drive.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
correction,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport));
|
||||
Assert.False(body.InWorld);
|
||||
|
||||
// The two cells the two predicate forms read are DIFFERENT
|
||||
// landblocks, and only the quiescing one is the caller's.
|
||||
Assert.Equal(SpawnLandblock, correction.Position.LandblockId & 0xFFFF0000u);
|
||||
Assert.Equal(
|
||||
NeighbourLandblock,
|
||||
body.CellPosition.ObjCellId & 0xFFFF0000u);
|
||||
Assert.False(
|
||||
runtime.EntityObjects.Physics.SetPosition.IsCollisionPrefixQuiescing(
|
||||
NeighbourLandblock));
|
||||
|
||||
// ACE's next ordinary broadcast cancels the park at merge time.
|
||||
MergeAccepted(
|
||||
runtime,
|
||||
controller,
|
||||
OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3));
|
||||
|
||||
// Restored, because the cell residency is restored INTO is healthy.
|
||||
// All three of these fail under round 2's single-token form.
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(runtime.EntityObjects.Physics.IsSpatialRoot(record));
|
||||
|
||||
drive.Advance();
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-2, round-4 correction D6 — the restorable decision is
|
||||
/// re-tested at RESTORE time, not only at park time.
|
||||
///
|
||||
/// <para>
|
||||
/// <c>ParkDeferred</c>'s decision is a snapshot. The far snap cancels its
|
||||
/// park synchronously, so that snapshot cannot go stale; route 2's park is
|
||||
/// RETAINED (<c>AwaitingCommitWake</c>) and the restore lands on the next
|
||||
/// packet's merge-time <c>Forget</c>, ~150 ms later at ACE's 5-10 Hz.
|
||||
/// Streaming opens a quiescence per landblock mutation, so the cell the
|
||||
/// rollback would restore into can start quiescing inside that window.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This drives exactly that: a swept-NEIGHBOUR quiescence parks a
|
||||
/// placement whose destination is healthy (so the park IS restorable when
|
||||
/// taken), then the destination's own prefix begins quiescing before the
|
||||
/// next packet. The restore must decline — otherwise it re-admits a
|
||||
/// spatial root into a prefix that is trying to retire, which is AP-136's
|
||||
/// stated reason verbatim. The entity self-heals on a later packet, so
|
||||
/// declining here is strictly the conservative half.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QuiescenceOpenedAfterTheParkDeclinesTheRestoreAtMergeTime()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
CommitLandblockCollision(
|
||||
runtime, NeighbourLandblock, worldOffsetX: 192f);
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(record.PhysicsBody);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out _);
|
||||
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence(
|
||||
NeighbourLandblock,
|
||||
collisionGeneration: 2UL,
|
||||
includeOutdoorCells: true);
|
||||
|
||||
WorldSession.EntityPositionUpdate correction = ForceUpdate(
|
||||
new Vector3(191.95f, 10f, SpawnHeight),
|
||||
landblockId: SpawnSeamCell);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, correction);
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAcceptedPositionExecutionStatus.DeferredCell,
|
||||
drive.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
correction,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport));
|
||||
Assert.False(body.InWorld);
|
||||
// The park WAS restorable when it was taken: the destination prefix
|
||||
// was clean. (Its sibling test above asserts the restore that follows
|
||||
// from exactly this state.)
|
||||
Assert.Equal(
|
||||
SpawnLandblock,
|
||||
body.CellPosition.ObjCellId & 0xFFFF0000u);
|
||||
|
||||
// …and now streaming retires that very landblock, mid-park.
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionPrefixQuiescence(
|
||||
SpawnLandblock,
|
||||
collisionGeneration: 3UL,
|
||||
includeOutdoorCells: true);
|
||||
|
||||
MergeAccepted(
|
||||
runtime,
|
||||
controller,
|
||||
OrdinaryUpdate(new Vector3(31f, 33f, SpawnHeight), positionSequence: 3));
|
||||
|
||||
Assert.False(runtime.EntityObjects.Physics.IsSpatialRoot(record));
|
||||
|
||||
drive.Advance();
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(runtime);
|
||||
}
|
||||
|
||||
private static void AssertConverged(GameRuntime runtime)
|
||||
{
|
||||
RuntimeEntityObjectOwnershipSnapshot ownership =
|
||||
|
|
@ -1011,7 +1388,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
/// </summary>
|
||||
private static void CommitLandblockCollision(
|
||||
GameRuntime runtime,
|
||||
uint landblockId)
|
||||
uint landblockId,
|
||||
float worldOffsetX = 0f)
|
||||
{
|
||||
// Mirrors HeadlessSessionHostTests.AddFlatLandblock's exact
|
||||
// proven-working shape (every heightmap byte and every table entry
|
||||
|
|
@ -1029,7 +1407,7 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetX,
|
||||
worldOffsetY: 0f);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblockId, 1UL, ready: true);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue