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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue