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:
Erik 2026-08-04 21:03:41 +02:00
parent 30d3d114b0
commit 36255af0f6
19 changed files with 5390 additions and 393 deletions

View file

@ -589,14 +589,38 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
}
/// <summary>
/// C4 route 4a: classifies one REMOTE incarnation's accepted Position
/// through <see cref="RuntimeAuthoritativePositionRouteClassifier"/>, so
/// the graphical host and any future no-window remote-motion host make
/// C4 route 4a: classifies one non-local-player incarnation's accepted
/// Position through <see cref="RuntimeAuthoritativePositionRouteClassifier"/>,
/// so the graphical host and any future no-window remote-motion host make
/// the SAME airborne-no-op / near-interpolate decision from the same
/// generation, the same authority shape, and the same request builder the
/// deferred initial-create continuation uses.
///
/// <para>
/// C4 route 5 (D-P1, REVISED after the retail/architecture review round —
/// A2/R1): the caller's incarnation may be an ordinary remote OR a live
/// missile — this method derives <c>RuntimePositionEntityKind</c> from
/// <c>canonical.FinalPhysicsState &amp; PhysicsStateFlags.Missile</c>
/// **conjoined with a bound, body-agreeing <see cref="RuntimeProjectile"/>**,
/// never the Missile bit alone. Retail's <c>MoveOrTeleport</c> places
/// EVERY non-player object unconditionally — it has no concept of
/// "client-side machinery not yet bound". A Missile-flagged record whose
/// <c>ProjectileController.TryBind</c> permanently refused (an
/// unsupported multi-sphere Setup) or has not yet run (the pre-bind
/// window) still classifies <c>Remote</c> here, so it takes the SAME
/// generic remote placement path retail's client would drive for it and
/// keeps tracking the server — exactly the pre-route-5 behaviour, which
/// fell through the deleted <c>ApplyAuthoritativePosition</c>'s
/// <c>TryGetCurrent</c> failure to the remote tail. The classifier's own
/// disposition/flag shape is unchanged either way (Projectile and Remote
/// are disposition-identical); only the returned route's
/// <c>OperationKind</c> differs, which is what lets the App dispatch
/// (route 5's <c>OnPosition</c> arm) and
/// <see cref="AcDream.Runtime.Session.RuntimeRemotePlacementDriveController.OwnsPlacement"/>
/// tell the two apart.
/// </para>
///
/// <para>
/// Returns <see langword="null"/> when no classification can honestly be
/// made: the lifetime has no bound generation yet, the canonical record
/// has not claimed a local id, or there is no live local-player position
@ -635,11 +659,32 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
{
return null;
}
// C4 route 5 (D-P1, revised — A2/R1): the Missile bit alone is
// data-driven but not sufficient — retail places every non-player
// object regardless of what client-side machinery exists for it, so
// this packet must classify Projectile ONLY when the arm can
// actually own the placement (a bound RuntimeProjectile whose Body
// is the exact canonical PhysicsBody). Anything else — no
// component bound (TryBind refused or has not run yet), or a
// component bound to a stale/displaced body — classifies Remote,
// matching retail's unconditional placement and the pre-route-5
// fall-through the deleted ApplyAuthoritativePosition performed via
// its own TryGetCurrent failure. A mid-life Missile flip (ACE clears
// it on impact; a State packet installs it, and TryBind subsequently
// binds the component) is handled by construction: whichever
// FinalPhysicsState AND binding state the canonical record carries
// for THIS packet decides the packet's kind.
RuntimePositionEntityKind kind =
(canonical.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
&& canonical.Projectile is RuntimeProjectile boundProjectile
&& ReferenceEquals(canonical.PhysicsBody, boundProjectile.Body)
? RuntimePositionEntityKind.Projectile
: RuntimePositionEntityKind.Remote;
if (!RuntimeAcceptedPositionRouteRequests.TryBuild(
generation(),
canonical,
update,
RuntimePositionEntityKind.Remote,
kind,
RuntimeAcceptedPositionSource.PositionEvent,
disposition,
timestamps.PreviousTeleport,