fix(physics): C4 route 5 — projectile authoritative placement (#276 partial)
Ports retail's missile Position handling into the canonical Runtime
placement owner instead of the deleted ApplyAuthoritativePosition
short-circuit. The Create/residence-window halves of the projectile
pipeline (RuntimeProjectile binding, TryBind's adopted-body branch,
the collision/shadow registration) were already canonical from prior
slices; this closes the remaining gap — how an ACCEPTED Position for
an in-flight missile is classified, placed, and presented.
Byte-decode (Step 1 hard gate, before any code was written):
CPhysicsObj::MoveOrTeleport @0x00516330-0x00516438 disassembled from
the PDB-paired binary (Capstone, x86 32-bit thiscall). `ret 0x10`
establishes four stack args; [esp+0x7c] (arg5, the velocity pointer)
is never referenced in any of the three branches (teleport/near/far).
The retail reviewer independently reproduced this by searching the
whole function body for the `24 7c` mod/rm+disp8 encoding a
`[esp+0x7c]` read would require and found zero occurrences. This
retired a fabricated `?? Vector3.Zero` fallback in the deleted method
— retail's PositionPack::UnPack initializes an absent velocity to
zero and MoveOrTeleport never installs it; the projectile's Vector
channel (RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector)
remains the sole velocity authority for a missile. D-P5 in the
contract; the Runtime seam commits no velocity from the Position
packet at all.
The unbound-missile fix: RuntimeEntityObjectLifetime's
ClassifyRemoteAcceptedPosition now derives ProjectileAuthoritative
from a CONJUNCTIVE predicate — the Missile bit AND a bound
RuntimeProjectile whose Body is the canonical PhysicsBody — never the
bit alone. Retail places every non-player CPhysicsObj unconditionally
(there is no missile-specific placement gate in MoveOrTeleport or its
callers), so an unbindable or not-yet-bound missile taking the
ordinary remote tail is retail-faithful, not a fallback: the earlier
bit-only discriminator would have silently frozen it instead.
AP-141 records this as a deliberate, recorded divergence, not
fidelity. Retail mechanically WOULD arm a missile's ConstrainTo leash
on any nonzero MoveOrTeleport return: HandleReceivedPosition
@0x00453FD0's only kind test is player-vs-not, ConstrainTo
@0x00454272 has no kind test of its own, and CPhysicsObj::ConstrainTo
@0x00510520 creates a PositionManager on demand via
MakePositionManager @0x00510523 if one doesn't exist. acdream
deliberately does not construct that EntityPhysicsHost/
PositionManager/InterpolationManager chain for a ballistic body — the
route-5b split the C4 route 5 contract rejected — so a live missile
never shows an armed leash and never catches up via the near/
UnroutedCatchUp policy. This divergence is safe specifically because
ACE never sends UpdatePosition for a missile
(references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:
333-334, SendUpdatePosition() commented out inside the
PhysicsState.Missile branch at :265) — every half of this row is
deterministic-test-gated only, never exercised against a real server.
AP-141 also records the surviving ConstrainTo re-anchor divergence
under clause (b): for the adopted-body case (TryBind's shared-body
branch — an ordinary remote whose Missile bit is set by a later
State packet, so it still carries a live RemoteMotion), acdream now
ports retail's teleport-branch and far-branch StopInterpolating
action (Interp.Clear()), but never re-arms or re-anchors the
inherited ConstrainTo leash the way retail's HandleReceivedPosition
@0x00454254/@0x00454272 does on every nonzero return. The risk
column's earlier wording — that a stale leash "would drag the body
toward a stale anchor" — was wrong and is retracted in this same
commit: ConstraintManager.ConstraintPos is write-only in both retail
and the port (never read by AdjustOffset), and
ConstraintManager::adjust_offset @0x00556180 only tapers or zeroes an
already-composed per-tick offset while InContact — a leash brakes
motion the interp/sticky chain already produced, it cannot pull
anything toward the anchor. The real residual is one tick of un-reset
brake accumulator, contact-gated, and it cannot move an airborne
far-snapped missile at all (the clamp branch does not run while
airborne).
NO CONNECTED GATE EXISTS for this route, by design: ACE never sends a
missile UpdatePosition (see above), so retail's own server never
exercises this code path in play. Every proof obligation here is
test-gated only — Runtime and App-level fixtures constructing the
packet directly — never a live client/server capture.
Three review rounds closed 8 MAJOR findings before this landed:
round 1 (A1 App discarded the seam's status; A2/R1 silent swallow on
an unbound missile; A3/R2 the adopted-body teleport_hook never
wired; A4/A5 zero Runtime/App test coverage); round 2 (a
ParentCellId regression introduced by round 1's own R6 finding,
which the retail reviewer retracted the following round as factually
wrong — the fix here is the REVERT to record.FullCellId, not the
relocation round 1 shipped; B2 the far-branch StopInterpolating skip
never extended to the adopted-body case; residual App/Runtime store-
path coverage; a per-packet closure contradicting the file's own
#315 cached-delegate pattern). Round 3 closed on coverage alone (no
defect): the Advance() retry arm's projectile branch — added at
round 2, semantically reordered at round 2's B5 fix (skip prediction
invalidation on a re-parked Contention, since it writes nothing) —
had never been executed by any test; two new tests drive it directly
and are sabotage-verified against both the reordering and the
retry-arm's own SyncProjectilePresentation call site. The one
recorded defect this campaign produced (the ParentCellId regression)
was caused by complying with a review finding that its own author
later retracted — the standing lesson recorded for future rounds is
that review findings are evidence to re-verify against the code, not
commands to obey unconditionally.
Complete Release suite: 11,063 passed / 4 skipped / 0 failed
(baseline 11,036 at 30d3d114, +27 new tests across this campaign).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
30d3d114b0
commit
36255af0f6
19 changed files with 5390 additions and 393 deletions
|
|
@ -500,6 +500,434 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
// both, because there is one call site left to sabotage. Reverted before
|
||||
// committing.
|
||||
|
||||
// ── C4 route 5 (A5 fix): missile matrix ─────────────────────────────
|
||||
//
|
||||
// The architecture review's FAIL-level finding: zero App tests drove
|
||||
// OnPosition with a missile packet, so A1 (App discards the seam's
|
||||
// status) and A2 (an unbound missile's Position silently dropped) both
|
||||
// lived in the ~35 lines of "thin glue" the implementer argued were
|
||||
// covered by two well-tested layers. These tests extend THIS file's own
|
||||
// fixture — never a lighter one — with a genuine Missile-flagged,
|
||||
// RuntimeProjectile-bound, RemoteMotion-less incarnation, and assert the
|
||||
// SAME observable surface the remote scenarios above assert: body/entity
|
||||
// position and ParentCellId (that pair is the exact A1 assertion), plus
|
||||
// the projectile-specific half — no RemoteMotion is EVER created
|
||||
// (invariant 8's mutual exclusion) and no early wire-pose write occurs.
|
||||
// Destinations for the commit scenarios are airborne (well above
|
||||
// PublishDestinationCollision's flat terrain), isolating the placement
|
||||
// assertions from the shared pipeline's ordinary ground-contact response
|
||||
// (Claim 4's confound, restated here for the App layer).
|
||||
|
||||
private const uint MissileGuid = 0x80007101u;
|
||||
private static readonly Vector3 MissileAirborneDestination =
|
||||
new(12f, 14f, SpawnHeight + 10f);
|
||||
|
||||
[Fact]
|
||||
public void MissileTeleportCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
Assert.NotNull(canonical.Projectile);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
||||
Assert.Equal(resolved, body.Position);
|
||||
Assert.Equal(body.Position, fixture.Entity.Position);
|
||||
// A1's own assertion: ParentCellId agrees with the RESOLVED body's
|
||||
// OWN cell — the review's concrete wrong-cell scenario, checked
|
||||
// positively here rather than only on the no-op scenarios below.
|
||||
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
|
||||
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
||||
// D-P4: the collision table was force-ended (no seeded owner here to
|
||||
// assert a 1->0 transition — that half is covered at
|
||||
// tests/AcDream.Runtime.Tests — but the call must not throw or
|
||||
// leave the entity uncollidable long-term; Tick below proves it is
|
||||
// still a live, ordinary object).
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
fixture.Shadows.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == fixture.Entity.Id);
|
||||
Assert.Equal(body.Position, shadowEntry.Position);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
||||
Assert.Equal(resolved, body.Position);
|
||||
Assert.Equal(body.Position, fixture.Entity.Position);
|
||||
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
|
||||
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1 EnvCell id — an indoor-format low word (0x100+, outside
|
||||
/// <c>LandDefs.AdjustToOutside</c>'s 1-0x40 outdoor range) staged onto
|
||||
/// the body BEFORE the store-path dispatch below. This is what makes
|
||||
/// the regression assertion actually discriminate: for an OUTDOOR
|
||||
/// source/destination pair, <c>PhysicsBody.Position</c>'s setter
|
||||
/// delta-syncs <c>CellPosition</c> through <c>AdjustToOutside</c> and
|
||||
/// happens to re-derive the correct destination landblock anyway (pure
|
||||
/// geometry, no collision data needed) — so a wrong
|
||||
/// <c>body.CellPosition.ObjCellId</c> read would coincidentally agree
|
||||
/// with <c>record.FullCellId</c> and the test would pass either way.
|
||||
/// An INDOOR source cell takes <c>SyncCellPositionDelta</c>'s OTHER
|
||||
/// branch (<c>PhysicsBody.cs:295-300</c>): it carries the position delta
|
||||
/// but never re-derives the cell id, so <c>body.CellPosition.ObjCellId</c>
|
||||
/// stays PINNED at the stale indoor source cell through the store path.
|
||||
/// <c>record.FullCellId</c> has no such blind spot — it is merged from
|
||||
/// the wire unconditionally — so this is the scenario where the two
|
||||
/// expressions genuinely diverge and MAJOR 1's revert is provable.
|
||||
/// </summary>
|
||||
private const uint IndoorSourceCell = SourceLandblock | 0x0100u;
|
||||
|
||||
/// <summary>
|
||||
/// Residual 1 close (round-2 review): the STORE path — <c>Refused</c> —
|
||||
/// at the App layer, closing both the coverage gap AND standing as the
|
||||
/// regression test for MAJOR 1 (the <c>ParentCellId</c> revert). The
|
||||
/// destination is deliberately left outside the service window
|
||||
/// (<see cref="RemoteServiceWindow.Allow"/> is never called for
|
||||
/// <see cref="DestinationLandblock"/>), so
|
||||
/// <c>CanAttemptDestination</c> refuses before the engine ever runs and
|
||||
/// <c>StoreAcceptedDestinationPose</c> resolves the destination through
|
||||
/// Runtime's own world frame instead of a commit. <c>Refused</c> is
|
||||
/// still a storing (A1-admitted) outcome, so the App-level presentation
|
||||
/// sync runs — the entity's position AND <c>ParentCellId</c> must both
|
||||
/// move to the DESTINATION (the wire cell), not the stale INDOOR source
|
||||
/// cell a <c>body.CellPosition.ObjCellId</c> read would have produced
|
||||
/// (see <see cref="IndoorSourceCell"/>'s doc comment for why an outdoor
|
||||
/// source cell would not have discriminated here).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonicalBeforeStage));
|
||||
PhysicsBody stagedBody = canonicalBeforeStage.PhysicsBody!;
|
||||
stagedBody.SnapToCell(
|
||||
IndoorSourceCell, stagedBody.Position, stagedBody.Position);
|
||||
Assert.Equal(IndoorSourceCell, stagedBody.CellPosition.ObjCellId);
|
||||
fixture.PublishDestinationCollision();
|
||||
// Deliberately NOT allowed — CanAttemptDestination refuses.
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
Assert.NotNull(canonical.Projectile);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
|
||||
Assert.Equal(resolved, body.Position);
|
||||
Assert.True(body.InWorld);
|
||||
// The store fallback never re-derives an INDOOR cell id — confirms
|
||||
// the divergence this test is built to exercise actually occurred.
|
||||
Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId);
|
||||
// The exact B1 regression check: ParentCellId is the DESTINATION
|
||||
// (wire) cell, matching record.FullCellId — never
|
||||
// body.CellPosition.ObjCellId, which just asserted it is STILL the
|
||||
// stale indoor source cell.
|
||||
Assert.Equal(body.Position, fixture.Entity.Position);
|
||||
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissileNear_NoOp_BodyUnchangedNoRemoteMotionNoWirePoseWrite()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
Vector3 spawnPose = fixture.Entity.Position;
|
||||
var target = new Vector3(4f, 3f, SpawnHeight);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
target, SourceCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Assert.Equal(spawnPose, body.Position);
|
||||
// A1's regression, asserted directly: no early wire-pose write —
|
||||
// the render entity was never moved to `target`.
|
||||
Assert.Equal(spawnPose, fixture.Entity.Position);
|
||||
Assert.NotEqual(target, fixture.Entity.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissileAirborne_NoOp_BodyUnchangedNoRemoteMotion()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: true);
|
||||
Vector3 spawnPose = fixture.Entity.Position;
|
||||
var wirePos = new Vector3(50f, 50f, SpawnHeight);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
wirePos, SourceCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: false));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Assert.Equal(spawnPose, body.Position);
|
||||
Assert.Equal(spawnPose, fixture.Entity.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissileNullClassification_Swallowed_NoRemoteMotionNoWriteNoPredictionChange()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
MissileGuid, nullClassification: true, isMissile: true);
|
||||
Vector3 spawnPose = fixture.Entity.Position;
|
||||
ulong predictionBefore = fixture.Projectile!.PredictionAuthorityVersion;
|
||||
var wirePos = new Vector3(50f, 50f, SpawnHeight);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
wirePos, SourceCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
Assert.Null(canonical.RemoteMotion);
|
||||
PhysicsBody body = canonical.PhysicsBody!;
|
||||
Assert.Equal(spawnPose, body.Position);
|
||||
Assert.Equal(spawnPose, fixture.Entity.Position);
|
||||
Assert.Equal(
|
||||
predictionBefore, fixture.Projectile.PredictionAuthorityVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2/R1's regression scenario, driven end to end: Missile bit set, but
|
||||
/// no <c>RuntimeProjectile</c> bound (TryBind refused, or has not run
|
||||
/// yet — <c>ProjectileController.cs:160-166</c>'s unsupported-Setup
|
||||
/// case, or the pre-bind window). Retail places every non-player object
|
||||
/// unconditionally; the fixed classifier must classify this packet
|
||||
/// Remote and route it through the SAME generic remote placement path
|
||||
/// an ordinary remote uses — never the frozen silent drop the
|
||||
/// unconjoined discriminator produced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileUnbound_FallsThroughToRemoteTail_TracksInsteadOfFreezing()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: false);
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
// Flip Missile AFTER construction, deliberately WITHOUT binding a
|
||||
// RuntimeProjectile — the unbound shape A2 names.
|
||||
fixture.Lifetime.Entities.SetFinalPhysicsState(
|
||||
canonical,
|
||||
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
||||
Assert.Null(canonical.Projectile);
|
||||
EntityPhysicsHost host = fixture.InstallHost();
|
||||
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact;
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
Vector3 spawnPose = fixture.Entity.Position;
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
// Placed via the ordinary remote far-snap arm — not frozen, and
|
||||
// armed exactly like FarSnap_BothGuids above.
|
||||
Assert.NotEqual(spawnPose, fixture.Remote.Body.Position);
|
||||
Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position);
|
||||
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
|
||||
Assert.NotNull(host.PositionManager.Constraint);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A3/R2's adopted-body scenario: an ordinary remote (populated Interp
|
||||
/// queue, an armed ConstrainTo leash) whose Missile bit is later set by
|
||||
/// a State packet — <c>ProjectileController.TryBind</c>'s shared-body
|
||||
/// branch adopts the SAME <c>RemoteMotion</c>/body rather than replacing
|
||||
/// it. Retail's <c>teleport_hook</c> per-manager guards are satisfied
|
||||
/// for this shape, so all six actions run; the App-level pre-dispatch
|
||||
/// hook call (mirroring the remote teleport arm's own
|
||||
/// <c>RunRemoteTeleportHook</c> wiring) must un-arm the leash and clear
|
||||
/// the queue before the placement, exactly like retail's ordering.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: false);
|
||||
EntityPhysicsHost host = fixture.InstallHost();
|
||||
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact;
|
||||
// Arm the leash and populate the queue directly — the pre-teleport
|
||||
// "live remote" state the adopted-body scenario requires.
|
||||
host.PositionManager.ConstrainTo(
|
||||
new AcDream.Core.Physics.Position(
|
||||
SourceCell, fixture.Remote.Body.Position, Quaternion.Identity),
|
||||
startDistance: 1f,
|
||||
maxDistance: 5f);
|
||||
Assert.NotNull(host.PositionManager.Constraint);
|
||||
fixture.Remote.Interp.Enqueue(
|
||||
fixture.Remote.Body.Position + Vector3.UnitX,
|
||||
heading: 0f,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: fixture.Remote.Body.Position);
|
||||
Assert.True(fixture.Remote.Interp.IsActive);
|
||||
|
||||
// TryBind's shared-body branch: adopt the SAME body into a
|
||||
// RuntimeProjectile, and set Missile — the record now carries BOTH
|
||||
// a RemoteMotion and a bound projectile, exactly like production.
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
fixture.Lifetime.Entities.SetFinalPhysicsState(
|
||||
canonical,
|
||||
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
||||
fixture.Lifetime.Physics.BindProjectile(
|
||||
canonical,
|
||||
canonical.PhysicsBody!,
|
||||
new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
||||
Assert.NotNull(canonical.Projectile);
|
||||
Assert.NotNull(canonical.RemoteMotion);
|
||||
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
// UnConstrain and StopInterpolating both ran. UnConstrain unmarks
|
||||
// IsConstrained rather than nulling the manager back out (it was
|
||||
// lazily CREATED by the ConstrainTo test-setup call above, and
|
||||
// creation is one-way) — this is the exact discriminator
|
||||
// WireAirborneNullClassified_BothGuids_WritesOnlyAP135Bookkeeping's
|
||||
// own comment names ("Constraint is lazily created only on a
|
||||
// genuine arm").
|
||||
Assert.NotNull(host.PositionManager.Constraint);
|
||||
Assert.False(host.PositionManager.Constraint!.IsConstrained);
|
||||
Assert.False(fixture.Remote.Interp.IsActive);
|
||||
// The placement itself still committed through the projectile arm.
|
||||
Assert.Equal(
|
||||
MissileAirborneDestination + DestinationWorldOffset,
|
||||
canonical.PhysicsBody!.Position);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B1/B2 fix (round-2 review): the far-branch counterpart to
|
||||
/// <see cref="MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue"/>.
|
||||
/// Retail's far branch (<c>SetPositionSimple</c>,
|
||||
/// <c>player_distance >= 96 m</c>) runs <c>StopInterpolating</c>
|
||||
/// @0x005163C9-@0x005163CB whenever <c>position_manager != 0</c> — the
|
||||
/// SAME guard the teleport branch's full <c>teleport_hook</c> shares
|
||||
/// for its own <c>StopInterpolating</c> action — but the far branch
|
||||
/// does NOT run the other five teleport_hook actions (no
|
||||
/// <c>UnConstrain</c>). For a bare missile this is structurally inert
|
||||
/// (no <c>RemoteMotion</c>), which is why the pre-round-2 AP-141 row
|
||||
/// could call the far-branch skip "faithful by consequence." The
|
||||
/// adopted-body case breaks that: it carries a live <c>Interp</c>
|
||||
/// queue the far branch must ALSO clear, while its armed
|
||||
/// <c>ConstrainTo</c> leash must stay armed (proving the far branch
|
||||
/// really does run only <c>StopInterpolating</c>, not the full hook).
|
||||
///
|
||||
/// <para>
|
||||
/// Round-3 nit (C2): "leash still armed" pins acdream's OWN divergence,
|
||||
/// not retail's behaviour. Retail's <c>HandleReceivedPosition</c>
|
||||
/// @0x00454254/@0x00454272 re-anchors an existing leash at the object's
|
||||
/// just-updated position on every nonzero <c>MoveOrTeleport</c> return —
|
||||
/// including the far branch's. acdream's far arm never calls
|
||||
/// <c>ConstrainTo</c> at all (D-P4, AP-141 clause (b)), so "still armed"
|
||||
/// here means "left exactly as staged," not "correctly re-anchored." A
|
||||
/// future reader should not read this assertion as full far-branch
|
||||
/// leash fidelity — only the <c>StopInterpolating</c> half is ported.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileAdoptedBody_FarCommit_ClearsInterpQueueButLeavesConstraintArmed()
|
||||
{
|
||||
using var fixture = new Fixture(MissileGuid, isMissile: false);
|
||||
EntityPhysicsHost host = fixture.InstallHost();
|
||||
fixture.Remote.Body.TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact;
|
||||
host.PositionManager.ConstrainTo(
|
||||
new AcDream.Core.Physics.Position(
|
||||
SourceCell, fixture.Remote.Body.Position, Quaternion.Identity),
|
||||
startDistance: 1f,
|
||||
maxDistance: 5f);
|
||||
Assert.NotNull(host.PositionManager.Constraint);
|
||||
Assert.True(host.PositionManager.Constraint!.IsConstrained);
|
||||
fixture.Remote.Interp.Enqueue(
|
||||
fixture.Remote.Body.Position + Vector3.UnitX,
|
||||
heading: 0f,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: fixture.Remote.Body.Position);
|
||||
Assert.True(fixture.Remote.Interp.IsActive);
|
||||
|
||||
Assert.True(fixture.Lifetime.Entities.TryGetActive(
|
||||
MissileGuid, out RuntimeEntityRecord canonical));
|
||||
fixture.Lifetime.Entities.SetFinalPhysicsState(
|
||||
canonical,
|
||||
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
||||
fixture.Lifetime.Physics.BindProjectile(
|
||||
canonical,
|
||||
canonical.PhysicsBody!,
|
||||
new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
||||
Assert.NotNull(canonical.Projectile);
|
||||
Assert.NotNull(canonical.RemoteMotion);
|
||||
|
||||
fixture.PublishDestinationCollision();
|
||||
fixture.ServiceWindow.Allow(DestinationLandblock);
|
||||
|
||||
// teleportSequence: 1 (unchanged from the fixture's baseline) with a
|
||||
// cross-landblock destination classifies as the FAR disposition
|
||||
// (SetPositionSimple) — the same discriminator
|
||||
// MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody
|
||||
// uses above, just against the adopted-body shape instead of a bare
|
||||
// missile.
|
||||
fixture.Controller.OnPosition(fixture.Update(
|
||||
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
|
||||
guid: MissileGuid, isGrounded: true));
|
||||
|
||||
// StopInterpolating ran — the queue is cleared.
|
||||
Assert.False(fixture.Remote.Interp.IsActive);
|
||||
// UnConstrain did NOT run — the far branch is one action, not six.
|
||||
// The leash is still armed.
|
||||
Assert.NotNull(host.PositionManager.Constraint);
|
||||
Assert.True(host.PositionManager.Constraint!.IsConstrained);
|
||||
// The placement itself still committed through the projectile arm.
|
||||
Assert.Equal(
|
||||
MissileAirborneDestination + DestinationWorldOffset,
|
||||
canonical.PhysicsBody!.Position);
|
||||
|
||||
fixture.DrainPlacementFifo();
|
||||
}
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
internal RuntimeEntityObjectLifetime Lifetime { get; }
|
||||
|
|
@ -510,6 +938,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
internal WorldEntity Entity { get; }
|
||||
internal RemoteMotion Remote { get; private set; } = null!;
|
||||
internal LiveEntityAnimationState? Animated { get; private set; }
|
||||
/// <summary>
|
||||
/// C4 route 5 (A5 fix): the bound projectile component for an
|
||||
/// <c>isMissile</c> fixture — <see langword="null"/> for an ordinary
|
||||
/// remote fixture. Exposed so tests can assert prediction-version
|
||||
/// movement without re-deriving it from <see cref="Lifetime"/>.
|
||||
/// </summary>
|
||||
internal RuntimeProjectile? Projectile { get; private set; }
|
||||
private readonly GpuWorldState _spatial;
|
||||
private readonly uint _guid;
|
||||
private readonly bool _nullClassification;
|
||||
|
|
@ -517,7 +952,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
internal Fixture(
|
||||
uint guid,
|
||||
bool nullClassification = false,
|
||||
bool withAnimation = false)
|
||||
bool withAnimation = false,
|
||||
bool isMissile = false)
|
||||
{
|
||||
_guid = guid;
|
||||
_nullClassification = nullClassification;
|
||||
|
|
@ -559,8 +995,16 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
ForcePosition: 1,
|
||||
ObjDesc: 1,
|
||||
Instance: 1);
|
||||
// C4 route 5 (A5 fix): a missile fixture carries the Missile bit
|
||||
// from spawn — the SAME data-driven bit
|
||||
// RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition
|
||||
// (D-P1) reads, so this fixture's record classifies exactly like
|
||||
// a real missile would once TryBind/BindProjectile below binds
|
||||
// the component.
|
||||
PhysicsStateFlags baseState = PhysicsStateFlags.ReportCollisions
|
||||
| (isMissile ? PhysicsStateFlags.Missile : PhysicsStateFlags.None);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
RawState: (uint)baseState,
|
||||
Position: wirePosition,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
|
|
@ -593,7 +1037,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
PhysicsState: (uint)baseState,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 1,
|
||||
MovementSequence: 1,
|
||||
|
|
@ -606,24 +1050,65 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
"fixture failed to materialize the remote entity");
|
||||
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
|
||||
|
||||
var remote = new RemoteMotion();
|
||||
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
||||
remote.CellId = SourceCell;
|
||||
Runtime.SetRemoteMotionRuntime(_guid, remote);
|
||||
Remote = remote;
|
||||
Shadows.Register(
|
||||
Entity.Id,
|
||||
0x02000001u,
|
||||
Entity.Position,
|
||||
Entity.Rotation,
|
||||
radius: 0.48f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: SourceLandblock,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
// C4 route 5 (A5 fix): a missile fixture binds a
|
||||
// RuntimeProjectile directly through the SAME production Runtime
|
||||
// entry point ProjectileController.TryBind eventually calls
|
||||
// (RuntimePhysicsState.BindProjectile) — never a RemoteMotion.
|
||||
// This is the exact shape invariant 8's mutual exclusion pins:
|
||||
// Missile-set means a projectile arm and NO RemoteMotion ever
|
||||
// exists for the entity.
|
||||
if (isMissile)
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = Entity.Position,
|
||||
Orientation = Entity.Rotation,
|
||||
LastUpdateTime = 1d,
|
||||
State = baseState,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
||||
RuntimeEntityRecord canonical = record.Canonical!;
|
||||
Lifetime.Entities.SetPhysicsBody(canonical, body);
|
||||
canonical.ObjectClock.Activate();
|
||||
Lifetime.Physics.AcknowledgeSpatialProjection(canonical, spatial: true);
|
||||
Projectile = (RuntimeProjectile)Lifetime.Physics.BindProjectile(
|
||||
canonical, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
||||
Shadows.Register(
|
||||
Entity.Id,
|
||||
0x02000001u,
|
||||
Entity.Position,
|
||||
Entity.Rotation,
|
||||
radius: 0.1f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: SourceLandblock,
|
||||
collisionType: ShadowCollisionType.Sphere,
|
||||
state: (uint)baseState,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var remote = new RemoteMotion();
|
||||
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
|
||||
remote.CellId = SourceCell;
|
||||
Runtime.SetRemoteMotionRuntime(_guid, remote);
|
||||
Remote = remote;
|
||||
Shadows.Register(
|
||||
Entity.Id,
|
||||
0x02000001u,
|
||||
Entity.Position,
|
||||
Entity.Rotation,
|
||||
radius: 0.48f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: SourceLandblock,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f,
|
||||
seedCellId: SourceCell,
|
||||
isStatic: false);
|
||||
}
|
||||
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(
|
||||
|
|
|
|||
|
|
@ -164,16 +164,21 @@ public sealed class ProjectileControllerTests
|
|||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
||||
IRuntimeProjectile runtime = record.ProjectileRuntime!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(1f, 2f, 3f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// C4 route 5: accepted-Position placement now routes through
|
||||
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition
|
||||
// (Runtime-owned, tested at tests/AcDream.Runtime.Tests). This test is
|
||||
// about Tick/hydration behaviour for a pending destination, not about
|
||||
// the placement seam itself, so the destination is written directly —
|
||||
// mirroring what a committed placement leaves on the canonical body —
|
||||
// and the presentation ack uses the SAME production method the seam's
|
||||
// caller uses (SyncPresentationFromResolvedBody).
|
||||
runtime.Body.SnapToCell(
|
||||
CellB,
|
||||
new Vector3(10f, 202f, 5f),
|
||||
new Vector3(10f, 10f, 5f));
|
||||
runtime.Body.Orientation = Quaternion.Identity;
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellB));
|
||||
Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 2.1));
|
||||
|
||||
Assert.False(record.IsSpatiallyVisible);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
|
|
@ -365,8 +370,16 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshVectorAndPositionCorrectionsMutateSameBody()
|
||||
public void FreshVectorCorrectionsMutateTheCanonicalBody()
|
||||
{
|
||||
// C4 route 5: the Position half of this test (a correction commits a
|
||||
// velocity too) is RETIRED by design (D-P5) — a byte-level decode of
|
||||
// retail's MoveOrTeleport (@0x00516330-@0x00516438) confirms it never
|
||||
// reads its velocity argument, and RuntimeRemotePlacementDriveController
|
||||
// .ApplyAcceptedProjectilePosition commits no velocity from a Position
|
||||
// packet. The no-velocity invariant (a Position correction leaves an
|
||||
// in-flight body's velocity bit-identical) is exercised at
|
||||
// tests/AcDream.Runtime.Tests, the seam's own layer, not here.
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 3);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0));
|
||||
|
|
@ -379,43 +392,20 @@ public sealed class ProjectileControllerTests
|
|||
currentTime: 4.5));
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
||||
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
|
||||
|
||||
var correction = new Vector3(30f, 31f, 32f);
|
||||
var correctedVelocity = new Vector3(3f, 4f, 5f);
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 5.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.Equal(correction, body.Position);
|
||||
Assert.Equal(correction, record.WorldEntity!.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
|
||||
// InboundPhysicsStateController normalizes an absent PositionPack
|
||||
// velocity to zero before this contract is called.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 5.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
// C4 route 5: the AuthoritativeMutation.Position case of this theory is
|
||||
// RETIRED — accepted-Position corrections no longer route through
|
||||
// ProjectileController at all (RuntimeRemotePlacementDriveController
|
||||
// .ApplyAcceptedProjectilePosition owns them). The successor —
|
||||
// "a split quantum straddling an accepted far/teleport Position aborts
|
||||
// Complete rather than clobbering the committed placement" — is a
|
||||
// Runtime-level test (trap T3) at tests/AcDream.Runtime.Tests, exercised
|
||||
// directly against RuntimeProjectilePhysicsUpdater.TryBegin/Complete and
|
||||
// the new seam, since that pairing is what now shares the prediction
|
||||
// version this scenario discards.
|
||||
[Theory]
|
||||
[InlineData(AuthoritativeMutation.Vector)]
|
||||
[InlineData(AuthoritativeMutation.Position)]
|
||||
[InlineData(AuthoritativeMutation.State)]
|
||||
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
||||
AuthoritativeMutation mutation)
|
||||
|
|
@ -429,7 +419,6 @@ public sealed class ProjectileControllerTests
|
|||
quantum: 0.05f,
|
||||
out ProjectileController.QuantumStep step));
|
||||
|
||||
Vector3 correctedPosition = new(30f, 31f, 32f);
|
||||
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
||||
Vector3 correctedOmega = new(0f, 0f, 2f);
|
||||
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
||||
|
|
@ -443,19 +432,6 @@ public sealed class ProjectileControllerTests
|
|||
currentTime: 1.02));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correctedPosition,
|
||||
correctedPosition,
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.State:
|
||||
record.FinalPhysicsState = correctedState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
|
|
@ -477,10 +453,6 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
Assert.Equal(correctedOmega, body.Omega);
|
||||
break;
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.Equal(correctedPosition, body.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
break;
|
||||
case AuthoritativeMutation.State:
|
||||
Assert.Equal(correctedState, body.State);
|
||||
break;
|
||||
|
|
@ -856,14 +828,21 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose()
|
||||
public void MalformedFreshVectorUpdate_DoesNotPoisonCanonicalBody()
|
||||
{
|
||||
// C4 route 5: the Position half of this test moved. A malformed
|
||||
// accepted-Position payload for a missile is rejected further
|
||||
// upstream now — the shared CanAcceptPositionPayload gate
|
||||
// (unchanged, its own tests still cover it directly) runs
|
||||
// unconditionally in OnPosition BEFORE the D-P6 dispatch even
|
||||
// decides this is a missile packet, so the malformed-payload swallow
|
||||
// is exercised at that layer (LiveEntityNetworkUpdateController's own
|
||||
// "invalid-payload swallow" test), not here.
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Vector3 velocity = body.Velocity;
|
||||
Vector3 position = body.Position;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
record,
|
||||
|
|
@ -871,19 +850,6 @@ public sealed class ProjectileControllerTests
|
|||
Vector3.Zero,
|
||||
currentTime: 2.0));
|
||||
Assert.Equal(velocity, body.Velocity);
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(position, body.Position);
|
||||
Assert.Equal(position, record.WorldEntity!.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1333,16 +1299,33 @@ public sealed class ProjectileControllerTests
|
|||
accepted.Position!.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
worldPosition: new Vector3(12f, 10f, 5f),
|
||||
cellLocalPosition: new Vector3(12f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: Vector3.Zero,
|
||||
fullCellId: CellA,
|
||||
currentTime: 10.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// C4 route 5: the placement itself is Runtime-owned now
|
||||
// (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||
// tested at tests/AcDream.Runtime.Tests). This test is about the
|
||||
// shadow/clock re-entry after a pickup+leave-world, so the resolved
|
||||
// destination is written directly and the presentation ack reuses
|
||||
// the same production method the seam's caller uses.
|
||||
// ApplyAuthoritativeState (unchanged) rebases the controller's own
|
||||
// `_lastFiniteGameTime` to the packet's receipt time exactly like the
|
||||
// deleted ApplyAuthoritativePosition's `currentTime` parameter used
|
||||
// to — it is a same-value state re-assert (no functional State
|
||||
// change), used here purely as the clock anchor.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
record, record.FinalPhysicsState, currentTime: 10.0, 1, 1));
|
||||
record.PhysicsBody!.SnapToCell(
|
||||
CellA,
|
||||
new Vector3(12f, 10f, 5f),
|
||||
new Vector3(12f, 10f, 5f));
|
||||
record.PhysicsBody.Orientation = Quaternion.Identity;
|
||||
// D-P5: a Position packet no longer commits velocity (the retired
|
||||
// behaviour this test used to lean on via the deleted method's
|
||||
// `velocity: Vector3.Zero` argument). The Vector channel is a
|
||||
// separate concern (untouched by route 5); stop the body directly so
|
||||
// this test's clock/shadow assertions are not confounded by ordinary
|
||||
// straight-line motion.
|
||||
record.PhysicsBody.set_velocity(Vector3.Zero);
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellA));
|
||||
Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 10.0));
|
||||
// The incarnation-stable RetailObjectQuantumClock is canonical after
|
||||
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
||||
// clock mirror and need not equal the packet receipt time once the
|
||||
|
|
@ -1431,8 +1414,20 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 5: the accepted-Position placement itself moved to
|
||||
/// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||
/// which re-validates currency internally before writing (proof
|
||||
/// obligation P3's argument, tested at tests/AcDream.Runtime.Tests). This
|
||||
/// test now exercises the successor of the OLD method's post-commit
|
||||
/// currency guard: ProjectileController.SyncPresentationFromResolvedBody
|
||||
/// (D-P2's closing paragraph) must refuse to touch presentation once a
|
||||
/// reentrant guid-reuse callback has already replaced the incarnation it
|
||||
/// was called for — a stale ack must never write through to the
|
||||
/// replacement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork()
|
||||
public void SyncPresentation_ReentrantGuidReuseNeverTouchesTheReplacement()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
||||
|
|
@ -1450,22 +1445,28 @@ public sealed class ProjectileControllerTests
|
|||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
first,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(44f, 0f, 0f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// The trigger: a landblock unload is the SAME visibility-loss edge
|
||||
// LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan
|
||||
// uses. RebucketLiveEntity is deliberately NOT the trigger here — it
|
||||
// suppresses ProjectionVisibilityChanged for its OWN guid mid-call
|
||||
// (LiveEntityRuntime.OnSpatialVisibilityChanged's `_rebucketingGuid`
|
||||
// guard), which is exactly why the real placement seam's reentrant
|
||||
// hazard is observable at the SPATIAL edge, not through a caller's
|
||||
// own rebucket.
|
||||
fixture.Spatial.RemoveLandblock(0x0101FFFFu);
|
||||
Assert.True(replaced);
|
||||
|
||||
// The stale ack for the SUPERSEDED incarnation must refuse.
|
||||
Assert.False(fixture.Controller.SyncPresentationFromResolvedBody(first, 1.1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
// The replacement's own spawn position (Fixture.Spawn's default),
|
||||
// untouched by the stale ack — the positive half of the assertion,
|
||||
// not merely "the ack returned false".
|
||||
Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
|
@ -1580,10 +1581,11 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
// C4 route 5: Position is retired from this enum — see the comment on
|
||||
// AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction.
|
||||
public enum AuthoritativeMutation
|
||||
{
|
||||
Vector,
|
||||
Position,
|
||||
State,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,322 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 5 (D-P1, REVISED after the review round — A2/R1):
|
||||
/// <see cref="RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition"/>
|
||||
/// derives <c>RuntimePositionEntityKind</c> from
|
||||
/// <c>canonical.FinalPhysicsState & PhysicsStateFlags.Missile</c>
|
||||
/// CONJOINED with a bound, body-agreeing <c>RuntimeProjectile</c> — never
|
||||
/// the Missile bit alone. Retail places every non-player object
|
||||
/// unconditionally; a Missile-flagged record with no bound projectile
|
||||
/// (<c>TryBind</c> permanently refused, or has not run yet) must still
|
||||
/// classify Remote so it keeps tracking through the ordinary remote
|
||||
/// placement path, exactly like the deleted <c>ApplyAuthoritativePosition</c>'s
|
||||
/// <c>TryGetCurrent</c> fall-through. Mirrors
|
||||
/// <see cref="RuntimeRemoteTeleportClassificationTests"/>'s fixture shape.
|
||||
/// </summary>
|
||||
public sealed class RuntimeProjectilePositionKindTests
|
||||
{
|
||||
private const uint Cell = 0x0101FFFFu;
|
||||
private const uint OtherCell = 0x0102FFFFu;
|
||||
|
||||
[Fact]
|
||||
public void MissileBitSetAndBound_ClassifiesProjectileAuthoritative()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime();
|
||||
lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL);
|
||||
const uint guid = 0x70006001u;
|
||||
RuntimeEntityRecord canonical =
|
||||
lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!;
|
||||
lifetime.Entities.SetFinalPhysicsState(
|
||||
canonical,
|
||||
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
||||
BindProjectile(lifetime, canonical, Cell);
|
||||
|
||||
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
||||
guid, OtherCell, positionSequence: 2, teleportSequence: 0);
|
||||
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps timestamps));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after));
|
||||
Assert.True((after.FinalPhysicsState & PhysicsStateFlags.Missile) != 0);
|
||||
Assert.NotNull(after.Projectile);
|
||||
RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition(
|
||||
after, update, disposition, timestamps, playerDistance: 10f);
|
||||
|
||||
Assert.NotNull(route);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
route!.Value.OperationKind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2/R1: the Missile bit alone is not sufficient. TryBind's permanent
|
||||
/// refusal (an unsupported multi-sphere Setup) or the pre-bind window
|
||||
/// leaves <c>record.Projectile</c> null while the bit stays set — this
|
||||
/// must still classify Remote so the packet is placed by the ordinary
|
||||
/// remote path, never silently dropped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileBitSetButUnbound_ClassifiesRemoteAuthoritative()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime();
|
||||
lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL);
|
||||
const uint guid = 0x70006005u;
|
||||
RuntimeEntityRecord canonical =
|
||||
lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!;
|
||||
lifetime.Entities.SetFinalPhysicsState(
|
||||
canonical,
|
||||
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
|
||||
// Deliberately never bind a RuntimeProjectile.
|
||||
Assert.Null(canonical.Projectile);
|
||||
|
||||
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
||||
guid, OtherCell, positionSequence: 2, teleportSequence: 0);
|
||||
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps timestamps));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after));
|
||||
Assert.True((after.FinalPhysicsState & PhysicsStateFlags.Missile) != 0);
|
||||
Assert.Null(after.Projectile);
|
||||
RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition(
|
||||
after, update, disposition, timestamps, playerDistance: 10f);
|
||||
|
||||
Assert.NotNull(route);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
route!.Value.OperationKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissileBitClear_ClassifiesRemoteAuthoritative()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime();
|
||||
lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL);
|
||||
const uint guid = 0x70006002u;
|
||||
RuntimeEntityRecord canonical =
|
||||
lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!;
|
||||
Assert.True((canonical.FinalPhysicsState & PhysicsStateFlags.Missile) == 0);
|
||||
|
||||
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
||||
guid, OtherCell, positionSequence: 2, teleportSequence: 0);
|
||||
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps timestamps));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after));
|
||||
RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition(
|
||||
after, update, disposition, timestamps, playerDistance: 10f);
|
||||
|
||||
Assert.NotNull(route);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
route!.Value.OperationKind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trap T4 / invariant 8's mutual-exclusion proof, exercised at the flip
|
||||
/// itself: a State packet installing Missile mid-life (ACE's ordinary
|
||||
/// arrow-becomes-live-missile edge, or its converse on impact) makes the
|
||||
/// VERY NEXT Position packet classify the OTHER kind — no stale
|
||||
/// classification survives the flip.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissileBitFlipMidLife_NextPositionReclassifies()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime();
|
||||
lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL);
|
||||
const uint guid = 0x70006003u;
|
||||
RuntimeEntityRecord canonical =
|
||||
lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!;
|
||||
Assert.True((canonical.FinalPhysicsState & PhysicsStateFlags.Missile) == 0);
|
||||
|
||||
WorldSession.EntityPositionUpdate firstUpdate = PositionUpdate(
|
||||
guid, OtherCell, positionSequence: 2, teleportSequence: 0);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
firstUpdate,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition firstDisposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps firstTimestamps));
|
||||
Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord beforeFlip));
|
||||
RuntimeAuthoritativePositionRoute? beforeRoute =
|
||||
lifetime.ClassifyRemoteAcceptedPosition(
|
||||
beforeFlip, firstUpdate, firstDisposition, firstTimestamps, playerDistance: 10f);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
beforeRoute!.Value.OperationKind);
|
||||
|
||||
// A State packet (0x0013-family) installs Missile — the classifier
|
||||
// itself never sees a State packet; only the NEXT Position does.
|
||||
lifetime.Entities.SetFinalPhysicsState(
|
||||
beforeFlip,
|
||||
beforeFlip.FinalPhysicsState | PhysicsStateFlags.Missile);
|
||||
// TryBind's production ordering: a State packet setting Missile is
|
||||
// immediately followed by binding (ApplyAuthoritativeState ->
|
||||
// TryBind). A2/R1 pins the classifier on the BOUND shape, so this
|
||||
// scenario's flip is only complete once the component exists too.
|
||||
BindProjectile(lifetime, beforeFlip, OtherCell);
|
||||
|
||||
WorldSession.EntityPositionUpdate secondUpdate = PositionUpdate(
|
||||
guid, Cell, positionSequence: 3, teleportSequence: 0);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
secondUpdate,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition secondDisposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps secondTimestamps));
|
||||
Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord afterFlip));
|
||||
RuntimeAuthoritativePositionRoute? afterRoute =
|
||||
lifetime.ClassifyRemoteAcceptedPosition(
|
||||
afterFlip, secondUpdate, secondDisposition, secondTimestamps, playerDistance: 10f);
|
||||
|
||||
Assert.NotNull(afterRoute);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
afterRoute!.Value.OperationKind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches a canonical body (if not already present) and binds a
|
||||
/// <c>RuntimeProjectile</c> to it through the SAME production entry
|
||||
/// point <c>ProjectileController.TryBind</c> eventually calls
|
||||
/// (<c>RuntimePhysicsState.BindProjectile</c>) — the classifier's
|
||||
/// conjunctive test (A2/R1) reads exactly this state.
|
||||
/// </summary>
|
||||
private static void BindProjectile(
|
||||
RuntimeEntityObjectLifetime lifetime,
|
||||
RuntimeEntityRecord record,
|
||||
uint cellId)
|
||||
{
|
||||
if (record.PhysicsBody is not { } body)
|
||||
{
|
||||
body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(10f, 20f, 5f),
|
||||
Orientation = Quaternion.Identity,
|
||||
LastUpdateTime = 1d,
|
||||
State = record.FinalPhysicsState,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
body.SnapToCell(cellId, body.Position, body.Position);
|
||||
lifetime.Entities.SetPhysicsBody(record, body);
|
||||
}
|
||||
lifetime.Physics.BindProjectile(
|
||||
record, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
||||
uint guid,
|
||||
uint cellId,
|
||||
ushort positionSequence,
|
||||
ushort teleportSequence) =>
|
||||
new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
cellId, 12f, 14f, 7f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
TeleportSequence: teleportSequence,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
uint cellId,
|
||||
ushort instance)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
cellId, 10f, 20f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: instance);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: 0x408u,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"remote-projectile-kind",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: 0x408u,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
|
@ -117,9 +117,16 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
|||
/// teleport branches (<c>RuntimeSetPositionOperationKind.LocalAuthoritative</c>),
|
||||
/// not only for remotes. The static predicate itself is exercised
|
||||
/// directly (no entity/body needed) since it takes only the route.
|
||||
///
|
||||
/// <para>
|
||||
/// C4 route 5 (D-P3): <c>ProjectileAuthoritative</c> is REMOVED from this
|
||||
/// negative list — the widening makes it a positively-owned kind now
|
||||
/// (see <see cref="OwnsPlacement_TrueForProjectileAuthoritative_SetPositionAndSetPositionSimple"/>).
|
||||
/// Only the two kinds that stay excluded remain here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative()
|
||||
public void OwnsPlacement_FalseWhenOperationKindIsNotRemoteOrProjectileAuthoritative()
|
||||
{
|
||||
// RuntimeSetPositionOperationKind is internal, so a public [Theory]
|
||||
// cannot take it as a parameter (CS0051) — iterate directly instead,
|
||||
|
|
@ -130,7 +137,6 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
|||
{
|
||||
RuntimeSetPositionOperationKind.InitialLogin,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative,
|
||||
RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
})
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
|
|
@ -2256,6 +2262,845 @@ public sealed class RuntimeRemotePlacementDriveControllerTests
|
|||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
// ── C4 route 5: projectile arm (D-P2/D-P3/D-P4/D-P5) ───────────────────
|
||||
|
||||
/// <summary>
|
||||
/// D-P3: the widening itself, isolated from any entity/body — mirrors
|
||||
/// <see cref="OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative"/>'s
|
||||
/// shape but for the positive case.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OwnsPlacement_TrueForProjectileAuthoritative_SetPositionAndSetPositionSimple()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70004001u);
|
||||
|
||||
foreach (RuntimeAuthoritativePositionDisposition disposition in
|
||||
new[]
|
||||
{
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
})
|
||||
{
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
disposition,
|
||||
DestinationCell,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
Assert.True(RuntimeRemotePlacementDriveController.OwnsPlacement(route));
|
||||
}
|
||||
|
||||
// A projectile Create (InitialCreateFlags, no Teleport bit) is still
|
||||
// excluded — the same Teleport-flag discriminator that excludes a
|
||||
// remote top-level Create.
|
||||
RuntimeAuthoritativePositionRoute createRoute = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
setPositionFlags: PhysicsSetPositionFlags.Placement
|
||||
| PhysicsSetPositionFlags.Slide);
|
||||
Assert.False(RuntimeRemotePlacementDriveController.OwnsPlacement(createRoute));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_Null_WhenOperationKindIsNotProjectileAuthoritative()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
(RuntimeEntityRecord record, _) = CreateProjectileRecord(
|
||||
lifetime, 0x70004002u, SourceCell);
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
operationKind: RuntimeSetPositionOperationKind.RemoteAuthoritative);
|
||||
|
||||
Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_Null_WhenNoProjectileComponentIsBound()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70004003u);
|
||||
AttachBody(lifetime, record, SourceCell);
|
||||
// Deliberately never BindProjectile — record.Projectile stays null.
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-P2's teleport/cell-less row + D-P4's force-end + D-P5's no-velocity,
|
||||
/// asserted together on the ONE committed outcome (process rule 4 —
|
||||
/// assert the full observable surface, not a subset). The body moves to
|
||||
/// the resolved (world-frame-shifted) destination, the prediction version
|
||||
/// advances (trap T3's guard), an in-flight nonzero velocity survives
|
||||
/// bit-identical (D-P5), no <c>RemoteMotion</c>/constraint host exists
|
||||
/// anywhere for the entity (D-P4's never-armed pin), and the collision
|
||||
/// table the teleport hook reduction force-ends is empty afterward
|
||||
/// (proof obligation P5).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_TeleportCommit_MovesBodyForceEndsCollisionNoVelocityNoConstraint()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
CommitLandblockCollision(lifetime, DestinationLandblock);
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(lifetime, 0x70004004u, SourceCell);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
var inFlightVelocity = new Vector3(5f, 0f, -2f);
|
||||
body.set_velocity(inFlightVelocity);
|
||||
ulong predictionBefore = projectile.PredictionAuthorityVersion;
|
||||
SeedCollisionOwner(lifetime, record, 0x70004104u, SourceCell);
|
||||
Assert.Equal(
|
||||
1, lifetime.Physics.CollisionReports.CaptureOwnership().OwnerCount);
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
// Airborne (well above CommitLandblockCollision's flat terrain at
|
||||
// SpawnHeight): landing in ground contact would legitimately let the
|
||||
// shared placement pipeline's ordinary contact response touch
|
||||
// velocity (retail landing behaviour, not this route's concern) —
|
||||
// an airborne destination isolates the no-velocity-FROM-THE-PACKET
|
||||
// assertion from that confound.
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight + 10f);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
RuntimeRemotePlacementExecutionStatus? status =
|
||||
drive.ApplyAcceptedProjectilePosition(record, route);
|
||||
|
||||
Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status);
|
||||
Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position);
|
||||
Assert.NotEqual(predictionBefore, projectile.PredictionAuthorityVersion);
|
||||
Assert.Equal(inFlightVelocity, body.Velocity);
|
||||
Assert.Null(record.RemoteMotion);
|
||||
Assert.Equal(
|
||||
0, lifetime.Physics.CollisionReports.CaptureOwnership().OwnerCount);
|
||||
// A4 fix (review round): the shadow-sync half of
|
||||
// SyncProjectilePresentation, asserted directly rather than left
|
||||
// vacuous — the shadow row moves to the RESOLVED body position.
|
||||
Assert.True(body.InWorld);
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == record.Key!.Value.LocalEntityId);
|
||||
Assert.Equal(body.Position, shadowEntry.Position);
|
||||
DrainPlacementFifo(lifetime);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>D-P2's far row, mirroring the teleport commit's assertions.</summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_FarCommit_MovesBodyNoVelocityNoConstraint()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
CommitLandblockCollision(lifetime, DestinationLandblock);
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(lifetime, 0x70004005u, SourceCell);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
var inFlightVelocity = new Vector3(0f, 7f, 1f);
|
||||
body.set_velocity(inFlightVelocity);
|
||||
ulong predictionBefore = projectile.PredictionAuthorityVersion;
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight + 10f);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
RuntimeRemotePlacementExecutionStatus? status =
|
||||
drive.ApplyAcceptedProjectilePosition(record, route);
|
||||
|
||||
Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status);
|
||||
Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position);
|
||||
Assert.NotEqual(predictionBefore, projectile.PredictionAuthorityVersion);
|
||||
Assert.Equal(inFlightVelocity, body.Velocity);
|
||||
Assert.Null(record.RemoteMotion);
|
||||
Assert.True(body.InWorld);
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == record.Key!.Value.LocalEntityId);
|
||||
Assert.Equal(body.Position, shadowEntry.Position);
|
||||
DrainPlacementFifo(lifetime);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A4 fix (review round): the spatial+hidden branch — the entity stays
|
||||
/// <c>InWorld</c> (retail keeps a Hidden object as a retained live
|
||||
/// <c>CPhysicsObj</c>, not a leave-world) but its shadow row is
|
||||
/// suspended, not published at the resolved pose.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_TeleportCommit_HiddenSuspendsShadowStaysInWorld()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
CommitLandblockCollision(lifetime, DestinationLandblock);
|
||||
(RuntimeEntityRecord record, _) =
|
||||
CreateProjectileRecord(lifetime, 0x7000400Bu, SourceCell);
|
||||
Assert.Equal(1, lifetime.Physics.Engine.ShadowObjects.TotalRegistered);
|
||||
lifetime.Entities.SetFinalPhysicsState(
|
||||
record, record.FinalPhysicsState | PhysicsStateFlags.Hidden);
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight + 10f);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Committed,
|
||||
drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
|
||||
Assert.True(record.PhysicsBody!.InWorld);
|
||||
Assert.Equal(0, lifetime.Physics.Engine.ShadowObjects.TotalRegistered);
|
||||
DrainPlacementFifo(lifetime);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A4 fix (review round): the non-spatial branch — a record that never
|
||||
/// became a spatial root (e.g. still pending a landblock) is left
|
||||
/// <c>InWorld = false</c>, its <c>Active</c> transient flag cleared, and
|
||||
/// its shadow suspended.
|
||||
///
|
||||
/// <para>
|
||||
/// Uses the STORE (<c>Refused</c>) path rather than a commit: a
|
||||
/// successful canonical commit re-establishes spatial-root status as
|
||||
/// part of entering the world, so the non-spatial branch is reachable
|
||||
/// only through the outcomes that never touch spatial registration —
|
||||
/// exactly the store fallback's shape.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_Refused_NonSpatialDeactivatesAndSuspends()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false);
|
||||
(RuntimeEntityRecord record, _) =
|
||||
CreateProjectileRecord(lifetime, 0x7000400Cu, SourceCell);
|
||||
Assert.Equal(1, lifetime.Physics.Engine.ShadowObjects.TotalRegistered);
|
||||
// Withdraw spatial-root status — AcknowledgeSpatialProjection
|
||||
// (spatial: false) is a no-op (only its `true` branch touches
|
||||
// _spatialRoots); RemoveSpatialProjection is the actual withdrawal.
|
||||
lifetime.Physics.RemoveSpatialProjection(record);
|
||||
var window = new FakeServiceWindow();
|
||||
// Deliberately NOT allowed — the pre-flight refuses, so the store
|
||||
// fallback runs without ever touching spatial registration.
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight + 10f);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Refused,
|
||||
drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
|
||||
Assert.False(record.PhysicsBody!.InWorld);
|
||||
Assert.Equal(
|
||||
TransientStateFlags.None,
|
||||
record.PhysicsBody.TransientState & TransientStateFlags.Active);
|
||||
Assert.Equal(0, lifetime.Physics.Engine.ShadowObjects.TotalRegistered);
|
||||
DrainPlacementFifo(lifetime);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6 fix (review round): the re-entry activation edge. A projectile
|
||||
/// that had left the world (suspended, <c>InWorld = false</c>) and comes
|
||||
/// back through a committed accepted Position must be re-flagged
|
||||
/// <c>Active</c> and have its legacy <c>LastUpdateTime</c> rebased — the
|
||||
/// exact branch that reading <c>body.InWorld</c> AFTER the placement
|
||||
/// (instead of capturing it before) made permanently dead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_TeleportCommit_ReenteringWorldReactivatesBody()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
CommitLandblockCollision(lifetime, DestinationLandblock);
|
||||
(RuntimeEntityRecord record, _) =
|
||||
CreateProjectileRecord(lifetime, 0x7000400Du, SourceCell);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
body.InWorld = false;
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
body.LastUpdateTime = -1d;
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight + 10f);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Committed,
|
||||
drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
|
||||
Assert.True(body.InWorld);
|
||||
Assert.Equal(
|
||||
TransientStateFlags.Active,
|
||||
body.TransientState & TransientStateFlags.Active);
|
||||
Assert.NotEqual(-1d, body.LastUpdateTime);
|
||||
DrainPlacementFifo(lifetime);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D3's <c>Refused</c> row: the destination is outside the service
|
||||
/// window, so the pre-flight declines before the engine ever runs — but
|
||||
/// the accepted destination STILL advances through
|
||||
/// <c>StoreAcceptedDestinationPose</c> (4b-3 invariant 1, extended by
|
||||
/// D-P3 to the projectile column). Positive assertions throughout
|
||||
/// (round-2 finding B1): the pose moved, the entity stayed in-world, and
|
||||
/// prediction still invalidated once (the store fallback is a body write
|
||||
/// too).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_Refused_StillAdvancesPoseNoParkPredictionInvalidated()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
// StoreAcceptedDestinationPose resolves the destination through
|
||||
// Runtime's OWN world frame (never a caller-supplied position) —
|
||||
// establish it exactly like CommitLandblockCollision's first step,
|
||||
// without needing the destination's collision generation to commit
|
||||
// (this test never reaches the engine).
|
||||
lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false);
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(lifetime, 0x70004006u, SourceCell);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
ulong predictionBefore = projectile.PredictionAuthorityVersion;
|
||||
var window = new FakeServiceWindow();
|
||||
// Deliberately NOT allowed — the pre-flight refuses.
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
RuntimeRemotePlacementExecutionStatus? status =
|
||||
drive.ApplyAcceptedProjectilePosition(record, route);
|
||||
|
||||
Assert.Equal(RuntimeRemotePlacementExecutionStatus.Refused, status);
|
||||
// The store fallback resolves through Runtime's world frame, the
|
||||
// same +192m shift on X the committed-outcome tests observe.
|
||||
Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position);
|
||||
Assert.NotEqual(predictionBefore, projectile.PredictionAuthorityVersion);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
// Residual 2 close (round-2 review): the store path publishes the
|
||||
// shadow row too, not only the commit path the teleport/far commit
|
||||
// tests already assert — SyncProjectilePresentation runs on every
|
||||
// storing outcome, Refused included.
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == record.Key!.Value.LocalEntityId);
|
||||
Assert.Equal(body.Position, shadowEntry.Position);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-P4's pinned no-op pair: <c>Interpolate</c> (near) and
|
||||
/// <c>NoPositionOperation</c> (airborne) write nothing and do not
|
||||
/// invalidate prediction — the positive fact that a straddling quantum
|
||||
/// may complete over either.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_PinnedNoOps_BodyAndPredictionUnchanged()
|
||||
{
|
||||
// RuntimeAuthoritativePositionDisposition is internal, so a public
|
||||
// [Theory] cannot take it as a parameter (CS0051) — iterate directly,
|
||||
// mirroring OwnsPlacement_FalseWhenOperationKindIsNotRemoteAuthoritative's
|
||||
// own foreach-over-internal-enum shape.
|
||||
foreach (RuntimeAuthoritativePositionDisposition disposition in
|
||||
new[]
|
||||
{
|
||||
RuntimeAuthoritativePositionDisposition.Interpolate,
|
||||
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
||||
})
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(lifetime, 0x70004007u, SourceCell);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Vector3 positionBefore = body.Position;
|
||||
Quaternion orientationBefore = body.Orientation;
|
||||
ulong predictionBefore = projectile.PredictionAuthorityVersion;
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
disposition,
|
||||
DestinationCell,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
|
||||
Assert.Equal(positionBefore, body.Position);
|
||||
Assert.Equal(orientationBefore, body.Orientation);
|
||||
Assert.Equal(predictionBefore, projectile.PredictionAuthorityVersion);
|
||||
Assert.Null(record.RemoteMotion);
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D-P4's swallow rule (trap T5): a <c>RejectedAuthority</c>/
|
||||
/// <c>RejectedData</c> classification for a missile packet writes
|
||||
/// nothing — no body write, no store, no fall-through to any remote arm
|
||||
/// (there is none reachable from this method regardless).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_RejectedClassification_Swallowed()
|
||||
{
|
||||
foreach (RuntimeAuthoritativePositionDisposition disposition in
|
||||
new[]
|
||||
{
|
||||
RuntimeAuthoritativePositionDisposition.RejectedAuthority,
|
||||
RuntimeAuthoritativePositionDisposition.RejectedData,
|
||||
})
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(lifetime, 0x70004008u, SourceCell);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Vector3 positionBefore = body.Position;
|
||||
ulong predictionBefore = projectile.PredictionAuthorityVersion;
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
disposition,
|
||||
DestinationCell,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
Assert.Null(drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
Assert.Equal(positionBefore, body.Position);
|
||||
Assert.Equal(predictionBefore, projectile.PredictionAuthorityVersion);
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-plan item 6 / proof obligation-11: the SAME "retryable
|
||||
/// preparation" shape <see cref="Teleport_LedgerConverges_AfterDetachRouteClearsARetainedRetry"/>
|
||||
/// uses, driven through the projectile arm — proves the shared
|
||||
/// <c>_pending</c>/<c>_awaitingAcknowledgement</c> ledgers converge for a
|
||||
/// projectile operation with no new code (trap T9: no second map).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_LedgerConverges_AfterDetachRouteClearsARetainedRetry()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
CommitLandblockCollision(lifetime, DestinationLandblock);
|
||||
(RuntimeEntityRecord record, _) = CreateProjectileRecord(
|
||||
lifetime, 0x70004009u, SourceCell);
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
var routeOwner = new object();
|
||||
drive.AttachRoute(routeOwner);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
|
||||
RuntimeRemotePlacementExecutionStatus? status =
|
||||
drive.ApplyAcceptedProjectilePosition(record, route);
|
||||
|
||||
Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status);
|
||||
// A committed placement's Place receipt is never acknowledged in
|
||||
// this bare fixture (no host subscription wired) — exactly the
|
||||
// awaiting-acknowledgement dimension the ledger must also converge.
|
||||
Assert.Equal(
|
||||
1, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount);
|
||||
|
||||
drive.DetachRoute(routeOwner);
|
||||
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
Assert.Equal(
|
||||
0, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round-3 architecture review C1: <c>Advance()</c>'s projectile branch
|
||||
/// (parked at round 2 / R3, reordered at round 2 / B5) had never been
|
||||
/// executed by any test — all six pre-existing <c>drive.Advance()</c>
|
||||
/// call sites in this file are remote-kind. This is the re-parked-
|
||||
/// <c>Contention</c> half, mirroring
|
||||
/// <see cref="FarSnap_RetryablePreparation_StoresThePoseAndStillRetainsTheRetry"/>
|
||||
/// against a projectile instead of a remote: <c>UnusedCollisionSource</c>
|
||||
/// never resolves a nonzero Setup id, so BOTH the entry-point call and
|
||||
/// the retry keep returning <c>RetrySetupUnavailable</c> —
|
||||
/// <c>Contention</c> — and the pending entry never drains on its own.
|
||||
///
|
||||
/// <para>
|
||||
/// The B5 semantic change under test: the entry-point call invalidates
|
||||
/// prediction unconditionally BEFORE the write (the existing, already-
|
||||
/// asserted behaviour); the RETRY call must NOT invalidate a second time
|
||||
/// when it re-parks, because a re-parked <c>Contention</c> writes
|
||||
/// nothing (no store, no commit) — invalidating for it would violate
|
||||
/// "the no-op dispositions invalidate nothing" on an arm that wrote
|
||||
/// nothing. <see cref="RuntimeProjectile.PredictionAuthorityVersion"/>
|
||||
/// captured immediately before and after <c>Advance()</c> must be equal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Advance_ProjectileRetryReParksAsContention_PredictionNotInvalidatedASecondTime()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
// Deliberately NOT committing DestinationLandblock's collision
|
||||
// generation — CanAttemptDestination only tests the service window
|
||||
// and the collision PREFIX quiescence, neither of which this
|
||||
// scenario needs to fail; the retryable failure comes from the
|
||||
// Setup read below, exactly like FarSnap_RetryablePreparation_….
|
||||
lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false);
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(
|
||||
lifetime,
|
||||
0x7000400Bu,
|
||||
SourceCell,
|
||||
setupTableId: 0x02000001u);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Vector3 positionBefore = body.Position;
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
stopInterpolating: true);
|
||||
|
||||
// Entry point: Contention, retained, and prediction invalidated
|
||||
// exactly once (the pre-existing, already-tested entry-point
|
||||
// behaviour — asserted again here only as the retry's baseline).
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Contention,
|
||||
drive.ApplyAcceptedProjectilePosition(record, route));
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position);
|
||||
Assert.NotEqual(positionBefore, body.Position);
|
||||
ulong predictionAfterEntry = projectile.PredictionAuthorityVersion;
|
||||
|
||||
// The retry: Setup is still unresolved, so SubmitAndResolve returns
|
||||
// Contention again and re-parks — the B5 no-invalidate branch.
|
||||
drive.Advance();
|
||||
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
Assert.Equal(
|
||||
predictionAfterEntry, projectile.PredictionAuthorityVersion);
|
||||
// The re-park wrote nothing — the stored pose from the entry point
|
||||
// is untouched.
|
||||
Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position);
|
||||
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
lifetime.Physics.SetPosition.Forget(record);
|
||||
if (cancellation.IsValid)
|
||||
lifetime.Physics.SetPosition.PublishCancellation(cancellation);
|
||||
drive.Advance();
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round-3 architecture review C1, the second half: a retained
|
||||
/// projectile retry whose destination leaves the service window before
|
||||
/// the next cadence pump — mirroring
|
||||
/// <see cref="Advance_DestinationLeavesTheWindow_StoresTheNewestDestinationPose"/>
|
||||
/// against a projectile. This exercises the STORING side C1 named as
|
||||
/// unexercised: the retry's window-drop branch invalidates prediction
|
||||
/// unconditionally (unlike the re-parked-<c>Contention</c> branch above)
|
||||
/// because it runs <c>StoreAcceptedDestinationPose</c> — a real body
|
||||
/// write — and it is the second call site (besides the entry point) that
|
||||
/// must run <see cref="RuntimeRemotePlacementDriveController"/>'s
|
||||
/// <c>SyncProjectilePresentation</c>, so the shadow row must follow the
|
||||
/// body here too, closing B3's remaining retry-arm gap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Advance_ProjectileRetryDestinationLeavesTheWindow_StoresNewestPoseInvalidatesPredictionSyncsShadow()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
lifetime.Physics.ObserveLocalWorldFrame(SourceCell, teleportAdvanced: false);
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(
|
||||
lifetime,
|
||||
0x7000400Cu,
|
||||
SourceCell,
|
||||
setupTableId: 0x02000001u);
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
|
||||
var firstDestination = new Vector3(12f, 14f, SpawnHeight);
|
||||
Assert.Equal(
|
||||
RuntimeRemotePlacementExecutionStatus.Contention,
|
||||
drive.ApplyAcceptedProjectilePosition(
|
||||
record,
|
||||
MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
DestinationCell,
|
||||
firstDestination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative,
|
||||
stopInterpolating: true)));
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
ulong predictionAfterEntry = projectile.PredictionAuthorityVersion;
|
||||
|
||||
// The server keeps broadcasting while the retry sits retained: the
|
||||
// accepted snapshot moves on, and the destination falls out of the
|
||||
// service window before the next cadence pump.
|
||||
var newestDestination = new Vector3(40f, 50f, SpawnHeight);
|
||||
record.Snapshot = record.Snapshot with
|
||||
{
|
||||
Position = new CreateObject.ServerPosition(
|
||||
DestinationCell,
|
||||
newestDestination.X,
|
||||
newestDestination.Y,
|
||||
newestDestination.Z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
};
|
||||
window.Forbid(DestinationLandblock);
|
||||
|
||||
drive.Advance();
|
||||
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
Assert.Equal(
|
||||
newestDestination + new Vector3(192f, 0f, 0f), body.Position);
|
||||
Assert.NotEqual(
|
||||
predictionAfterEntry, projectile.PredictionAuthorityVersion);
|
||||
// SyncProjectilePresentation ran on the retry arm too — the shadow
|
||||
// row followed the body to the newest stored pose.
|
||||
ShadowEntry shadowEntry = Assert.Single(
|
||||
lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == record.Key!.Value.LocalEntityId);
|
||||
Assert.Equal(body.Position, shadowEntry.Position);
|
||||
Assert.Equal(
|
||||
0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-plan item 4 (trap T3): a split quantum straddling an accepted
|
||||
/// far/teleport Position must abort at <c>Complete</c> rather than
|
||||
/// clobber the committed placement — the scenario invisible from reading
|
||||
/// the classifier alone, and the one this route's App-level predecessor
|
||||
/// test (<c>AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction</c>)
|
||||
/// used to cover before its Position case retired.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyAcceptedProjectilePosition_DuringOpenQuantum_CompleteAbortsAfterPredictionInvalidated()
|
||||
{
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine());
|
||||
CommitLandblockCollision(lifetime, DestinationLandblock);
|
||||
(RuntimeEntityRecord record, RuntimeProjectile projectile) =
|
||||
CreateProjectileRecord(lifetime, 0x7000400Au, SourceCell);
|
||||
var updater = new RuntimeProjectilePhysicsUpdater(lifetime.Physics);
|
||||
Assert.True(updater.TryBegin(
|
||||
record,
|
||||
quantum: 0.05f,
|
||||
record.ObjectClockEpoch,
|
||||
externalOwnerValid: null,
|
||||
out RuntimeProjectilePhysicsCommit commit));
|
||||
|
||||
var window = new FakeServiceWindow();
|
||||
window.Allow(DestinationLandblock);
|
||||
RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window);
|
||||
var destination = new Vector3(12f, 14f, SpawnHeight);
|
||||
RuntimeAuthoritativePositionRoute route = MakeRoute(
|
||||
record,
|
||||
RuntimeAuthoritativePositionDisposition.SetPosition,
|
||||
DestinationCell,
|
||||
destination,
|
||||
operationKind: RuntimeSetPositionOperationKind.ProjectileAuthoritative);
|
||||
RuntimeRemotePlacementExecutionStatus? status =
|
||||
drive.ApplyAcceptedProjectilePosition(record, route);
|
||||
Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status);
|
||||
Vector3 committedPosition = record.PhysicsBody!.Position;
|
||||
|
||||
bool completed = updater.Complete(
|
||||
commit,
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0,
|
||||
acknowledgeProjection: static _ => true);
|
||||
|
||||
Assert.False(completed);
|
||||
Assert.Equal(committedPosition, record.PhysicsBody.Position);
|
||||
DrainPlacementFifo(lifetime);
|
||||
AssertConverged(lifetime);
|
||||
}
|
||||
|
||||
private static (RuntimeEntityRecord Record, RuntimeProjectile Projectile) CreateProjectileRecord(
|
||||
RuntimeEntityObjectLifetime lifetime,
|
||||
uint guid,
|
||||
uint cellId,
|
||||
bool registerShadow = true,
|
||||
// C1 fix (round-3 architecture review): a nonzero setupTableId is
|
||||
// what makes CanonicalSetupTableId != 0, which is what makes
|
||||
// TryPrepareAuthoredMover actually consult UnusedCollisionSource
|
||||
// (RuntimeSetPositionState.cs:1900-1918) instead of taking the
|
||||
// ResolvedAbsent no-Setup path every other projectile fixture in
|
||||
// this file relies on. Default null preserves every existing
|
||||
// caller's behaviour exactly (id 0, ResolvedAbsent, always
|
||||
// Prepared) — only the two new retry-arm tests pass a real id to
|
||||
// deliberately provoke RetrySetupUnavailable.
|
||||
uint? setupTableId = null)
|
||||
{
|
||||
RuntimeEntityRecord record = CreateRemoteRecord(
|
||||
lifetime, guid, setupTableId);
|
||||
lifetime.Entities.SetFinalPhysicsState(
|
||||
record,
|
||||
PhysicsStateFlags.Gravity
|
||||
| PhysicsStateFlags.Missile
|
||||
| PhysicsStateFlags.ReportCollisions);
|
||||
PhysicsBody body = AttachBody(lifetime, record, cellId);
|
||||
var sphere = new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f);
|
||||
var projectile = (RuntimeProjectile)lifetime.Physics.BindProjectile(
|
||||
record, body, sphere);
|
||||
// A4 fix (review round): a shadow registration is the prerequisite
|
||||
// for ShadowObjectRegistry.UpdatePosition to do anything at all
|
||||
// (it early-returns "not registered" otherwise) — without this, a
|
||||
// test could assert the shadow-sync branch ran while
|
||||
// SyncProjectilePresentation's shadow write was silently a no-op.
|
||||
if (registerShadow)
|
||||
{
|
||||
lifetime.Physics.Engine.ShadowObjects.Register(
|
||||
record.Key!.Value.LocalEntityId,
|
||||
gfxObjId: 0u,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
radius: 0.1f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
cellId & 0xFFFF0000u,
|
||||
ShadowCollisionType.Sphere,
|
||||
state: (uint)record.FinalPhysicsState,
|
||||
seedCellId: cellId,
|
||||
isStatic: false);
|
||||
}
|
||||
return (record, projectile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds one collision-table owner row on <paramref name="owner"/> via
|
||||
/// a peer entity's dynamic shadow + one reported collision — the same
|
||||
/// mechanism <c>RuntimeCollisionReportingStateTests</c> uses, reduced to
|
||||
/// the minimum this file's proof obligation P5 needs.
|
||||
/// </summary>
|
||||
private static void SeedCollisionOwner(
|
||||
RuntimeEntityObjectLifetime lifetime,
|
||||
RuntimeEntityRecord owner,
|
||||
uint peerGuid,
|
||||
uint cellId)
|
||||
{
|
||||
RuntimeEntityRecord peer = CreateRemoteRecord(lifetime, peerGuid);
|
||||
AttachBody(lifetime, peer, cellId);
|
||||
uint peerLocalId = peer.Key!.Value.LocalEntityId;
|
||||
lifetime.Physics.Engine.ShadowObjects.Register(
|
||||
peerLocalId,
|
||||
gfxObjId: 0u,
|
||||
peer.PhysicsBody!.Position,
|
||||
Quaternion.Identity,
|
||||
radius: 0.4f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
cellId & 0xFFFF0000u,
|
||||
ShadowCollisionType.Sphere,
|
||||
state: (uint)peer.FinalPhysicsState,
|
||||
seedCellId: cellId,
|
||||
isStatic: false);
|
||||
var report = new PhysicsSetPositionCollisionReport(
|
||||
ContactPlaneValid: false,
|
||||
ContactPlane: default,
|
||||
ContactPlaneCellId: 0u,
|
||||
ContactPlaneIsWater: false,
|
||||
LastKnownContactPlaneValid: false,
|
||||
LastKnownContactPlane: default,
|
||||
LastKnownContactPlaneCellId: 0u,
|
||||
LastKnownContactPlaneIsWater: false,
|
||||
SlidingNormalValid: false,
|
||||
SlidingNormal: default,
|
||||
CollisionNormalValid: false,
|
||||
CollisionNormal: default,
|
||||
CollidedWithEnvironment: false,
|
||||
FramesStationaryFall: 0,
|
||||
AdjustOffset: default,
|
||||
LastCollidedObjectId: peerLocalId,
|
||||
CollidedObjectIds: System.Collections.Immutable.ImmutableArray
|
||||
.Create(peerLocalId));
|
||||
Assert.True(lifetime.Physics.HandleSetPositionCollisions(
|
||||
owner,
|
||||
owner.PositionAuthorityVersion,
|
||||
owner.SpatialAuthorityVersion,
|
||||
owner.VelocityAuthorityVersion,
|
||||
physicsTime: 1d,
|
||||
previousContact: false,
|
||||
previousOnWalkable: false,
|
||||
report));
|
||||
}
|
||||
|
||||
// ── Fixture ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue