feat(physics): C4 route 4a — remote steady-state Position through the seam
Routes the classifier's two NO-PLACEMENT remote branches — Interpolate (contact, PlayerDistance < 96 m) and NoPositionOperation (no contact) — through a Runtime-owned seam, and fixes the two divergences they carried. Teleport, far-snap and cell-less stay on the legacy App path; 4b owns them. Route 4 was split into 4a/4b after scoping put the whole route at 1,500-2,500 lines against a ~400 budget. 4a's branches perform no SetPosition, so this slice carries no deferred-cell park, no service-window guard and no allocation exposure — which is what made the split worth doing. Divergences fixed, both previously unfiled: * D1 — the NPC airborne branch hard-snapped Body.Position/Orientation and branched on the client-tracked rmState.Airborne, never consulting the wire IsGrounded bit. Retail's MoveOrTeleport @0x00516330 returns 0 at 0x0051636D and writes nothing. Player remotes were already correct; NPCs were not. * D2 — ConstrainTo was armed before the operation, unconditionally, so it fired on the airborne no-op retail skips and anchored to the PRE-move position. Retail arms it at 0x00454272, only when MoveOrTeleport returns nonzero, anchored to &arg2->m_position read live, i.e. post-move. AP-87 and TS-44 were carried deliberately, not delegated away. AP-87's three conditions — including firstUp, which one round silently dropped — are preserved as an explicit acdream policy layer applied AFTER the classifier commits to Interpolate; the two previously separate player/NPC copies are now one. TS-44 stays an NPC-only caller gate; extending sticky suppression to player remotes has no retail basis and no live evidence, so it was declined rather than absorbed. Landing is explicitly carved out of 4a's ownership on both arms. A landing packet classifies Interpolate, so an ordering slip would ENQUEUE a body that must PLANT and a creature knocked off a ledge would glide down over a packet interval. The carve-out is a named entry point returning AirborneSnap/SteadyStateInterpolate/ Legacy precisely so the PRECEDENCE is observable and testable rather than implied by statement order — that is how the slip happened once and was caught. The player/NPC asymmetry on landing is real and NOT resolved here: retail draws no such distinction, but converging them is a behaviour decision needing its own evidence. Filed into the 4b plan. Register: AP-135 filed for the two bookkeeping writes the airborne branch deliberately retains (rmState.CellId, LastServerPos/Time) — not retail's model, but load-bearing for our catch-up sweep and staleness timer, and verified not to be a canonical cell commit for ordinary remotes. AP-87 and TS-44 rewritten to describe the code. Honest remainder: App still owns branch selection, the airborne return, the cell write, the entity write and the shadow publish, and headless satisfies "both hosts drive the identical entry point" only vacuously since it returns early for remotes. That is written into the 4b bullet rather than left implicit. Cost: 364 non-comment production lines, 91% of the ~400 budget — the split did isolate the cheap half, but not by much. Do not carry "well under" into 4b's scoping. Gates: complete Release solution 10,938 passed / 4 skipped / 0 failed (pre-4a baseline 10,909). Four review rounds; the first three each introduced a new behavioural defect while fixing another, and each left a comment asserting behaviour that no longer matched — the final round's precedence matrix was traced cell-by-cell against HEAD with only the D1-intended difference. App tests call production entry points against a real WorldEntity and real classifier output, closing route 2's #292 gap rather than repeating it. Connected acceptance NOT run — needs a live second character. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
19d9509497
commit
44830a0eb3
12 changed files with 1854 additions and 150 deletions
|
|
@ -817,6 +817,163 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
&& runtime.IsCurrentSpatialRemoteMotion(record, remote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4a: asks Runtime to classify one remote's accepted Position.
|
||||
/// App contributes only retail's <c>player_distance</c> — the live
|
||||
/// physics-controller distance, and <see langword="null"/> (never a
|
||||
/// fabricated <c>Vector3.Zero</c>) when no controller exists yet, which
|
||||
/// makes Runtime decline and leaves the legacy path untouched. Callers
|
||||
/// must only invoke this for a genuinely remote (never local-player)
|
||||
/// entity whose <c>remotePlacementRequired</c> gate is already false.
|
||||
/// </summary>
|
||||
private RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition(
|
||||
AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
|
||||
RuntimeEntityRecord canonical,
|
||||
AcDream.Core.Physics.PositionTimestampDisposition timestampDisposition,
|
||||
AcceptedPhysicsTimestamps timestamps,
|
||||
System.Numerics.Vector3 worldPos) =>
|
||||
_liveEntities.ClassifyRemoteAcceptedPosition(
|
||||
canonical,
|
||||
update,
|
||||
timestampDisposition,
|
||||
timestamps,
|
||||
_playerController is { } controller
|
||||
? System.Numerics.Vector3.Distance(worldPos, controller.Position)
|
||||
: null);
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4a: the generic top-of-<c>OnPosition</c> render-pose write and
|
||||
/// its ONE suppression rule, extracted so the rule is exercised by
|
||||
/// production and by test through the same entry point rather than
|
||||
/// restated in a test body.
|
||||
///
|
||||
/// <para>
|
||||
/// For the two classifications route 4a owns, the canonical body — not
|
||||
/// the raw wire packet — is the only writer of the render entity: the
|
||||
/// near-interpolate branch's tail syncs the entity to the resolved body,
|
||||
/// and the airborne no-op writes nothing at all (retail
|
||||
/// <c>MoveOrTeleport</c> 0x00516330 returns 0 @0x0051636D). Writing the
|
||||
/// wire pose here first would be the second writer route 2's original
|
||||
/// defect consisted of. Every other classification keeps the pre-existing
|
||||
/// write, unchanged, until route 4b.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The spatial bucket transaction deliberately does NOT live behind this
|
||||
/// gate: unlike route 2, neither 4a branch performs a placement, so there
|
||||
/// is no committed placement receipt to project in its stead. The per-UP
|
||||
/// <c>RebucketLiveEntity</c> is the only site that moves an ordinary
|
||||
/// moving remote's draw bucket, commits its canonical <c>FullCellId</c>,
|
||||
/// and recovers a pending bucket promotion, and it must keep running for
|
||||
/// both 4a classifications.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see langword="bool"/> result is this seam's observable outcome and
|
||||
/// is what the acceptance tests assert on; production does not need it.
|
||||
/// Do not delete it as dead — returning nothing would leave the
|
||||
/// suppression rule unobservable, which is the #292 gap this closes.
|
||||
/// Returns true when the wire pose was written.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool TryApplyGenericRemoteRenderPose(
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
RuntimeAuthoritativePositionRoute? route,
|
||||
System.Numerics.Vector3 worldPos,
|
||||
uint landblockId,
|
||||
System.Numerics.Quaternion rotation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (RuntimeRemoteSteadyStatePosition.OwnsSteadyState(route))
|
||||
return false;
|
||||
|
||||
entity.SetPosition(worldPos);
|
||||
entity.ParentCellId = landblockId;
|
||||
entity.Rotation = rotation;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Which arm of the non-player-remote contact routing claimed a
|
||||
/// packet. The value is the seam's observable outcome, asserted by the
|
||||
/// acceptance tests; production only distinguishes
|
||||
/// <see cref="RemoteContactArm.Legacy"/>, but the finer result is what
|
||||
/// makes the PRECEDENCE testable and must not be collapsed to a
|
||||
/// bool.</summary>
|
||||
internal enum RemoteContactArm : byte
|
||||
{
|
||||
/// <summary>The body was airborne. Hard-snapped, exactly as before
|
||||
/// route 4a. Gated on <c>remote.Airborne</c> ALONE — the wire contact
|
||||
/// bit is never read here. The common case is the landing packet, but
|
||||
/// a not-in-contact packet also reaches this arm whenever the
|
||||
/// classification is one route 4a does not own (null, cell-less
|
||||
/// <c>SetPosition</c>, or rejected), because the
|
||||
/// <c>IsAirborneNoOperation</c> early return fires only for
|
||||
/// classifications it does own. That matches pre-4a behaviour.</summary>
|
||||
AirborneSnap,
|
||||
|
||||
/// <summary>Route 4a's near InterpolateTo branch.</summary>
|
||||
SteadyStateInterpolate,
|
||||
|
||||
/// <summary>Neither: the caller runs its own untouched legacy
|
||||
/// near/far routing.</summary>
|
||||
Legacy,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4a: the ORDERING carve-out for the non-player-remote arm. An
|
||||
/// airborne body's contact packet keeps its pre-existing authoritative
|
||||
/// hard-snap and is decided BEFORE route 4a's near-Interpolate branch can
|
||||
/// claim it — the player-remote arm gets the same precedence structurally,
|
||||
/// because its landing block sits ahead of its own routing and returns.
|
||||
///
|
||||
/// <para>
|
||||
/// This exists as one entry point precisely so the precedence is
|
||||
/// observable: a landing packet classifies <c>Interpolate</c>, so if the
|
||||
/// 4a test came first it would ENQUEUE a body that must PLANT, and a
|
||||
/// creature knocked off a ledge would glide down over a packet interval.
|
||||
/// Landing is not a behaviour route 4a is scoped to change.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static RemoteContactArm ApplyRemoteContactRouting(
|
||||
RemoteMotion remote,
|
||||
RuntimeAuthoritativePositionRoute? route,
|
||||
System.Numerics.Vector3 worldPos,
|
||||
System.Numerics.Quaternion rotation,
|
||||
bool willBeDrTicked)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
if (remote.Airborne)
|
||||
{
|
||||
// Verbatim from the pre-4a branch, queue deliberately NOT
|
||||
// cleared: the arc integrates locally (K-fix15), and clearing
|
||||
// stale waypoints is owned by the per-tick LANDING detection
|
||||
// (RuntimeRemotePhysicsUpdater.cs:497-502), not by this snap.
|
||||
//
|
||||
// Do NOT restate this as "the queue is already empty here" — it
|
||||
// is not. Nothing that sets Airborne clears the queue except the
|
||||
// teleport hook's StopInterpolating: neither the 0xF74E
|
||||
// VectorUpdate (:1059) nor the three `Airborne = !Body.OnWalkable`
|
||||
// sites do. A walking NPC can enqueue a near waypoint and then
|
||||
// step off a lip, arriving here with a populated queue. That is
|
||||
// exactly why the landing clear exists, and a reader who believes
|
||||
// the queue is empty here could delete it.
|
||||
remote.Body.Position = worldPos;
|
||||
remote.Body.Orientation = rotation;
|
||||
return RemoteContactArm.AirborneSnap;
|
||||
}
|
||||
|
||||
if (!RuntimeRemoteSteadyStatePosition.IsNearInterpolate(route))
|
||||
return RemoteContactArm.Legacy;
|
||||
|
||||
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
||||
remote,
|
||||
worldPos,
|
||||
rotation,
|
||||
isMovingTo: remote.Movement.IsMovingTo(),
|
||||
willBeDrTicked);
|
||||
return RemoteContactArm.SteadyStateInterpolate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps.
|
||||
/// The payload seeds the world-space launch velocity and angular velocity.
|
||||
|
|
@ -1302,15 +1459,41 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
return;
|
||||
}
|
||||
|
||||
// Capture the pre-update render position for the soft-snap residual
|
||||
// calculation below. Assign entity.Position to the server truth up
|
||||
// front; if we then compute a snap residual, we restore the rendered
|
||||
// position by adding the residual back (so the visual doesn't jerk
|
||||
// for one frame before the residual decay kicks in on the next tick).
|
||||
System.Numerics.Vector3 preSnapPos = entity.Position;
|
||||
entity.SetPosition(worldPos);
|
||||
entity.ParentCellId = p.LandblockId;
|
||||
entity.Rotation = rot;
|
||||
// C4 route 4a: classify BEFORE the generic write below so a remote
|
||||
// whose accepted Position resolves to NoPositionOperation (retail's
|
||||
// airborne no-op — writes nothing at all) or Interpolate (retail's
|
||||
// near InterpolateTo queue — no direct body write here) never
|
||||
// receives it. remotePlacementRequired guarantees the classifier's
|
||||
// teleport (SetPosition) disposition never reaches here — that stays
|
||||
// route 4b. The local player never reaches this generic-remote code
|
||||
// path at all. Every classification 4a does NOT own — the >=96 m
|
||||
// far snap, a cell-less remote, a rejected authority or payload, and
|
||||
// "no classification at all" — falls through to the pre-existing
|
||||
// legacy routing completely unchanged; 4b deletes that fallback.
|
||||
RuntimeAuthoritativePositionRoute? earlyRemoteRoute =
|
||||
update.Guid != _playerServerGuid && !remotePlacementRequired
|
||||
? ClassifyRemoteAcceptedPosition(
|
||||
update,
|
||||
acceptedPositionCanonical,
|
||||
timestampDisposition,
|
||||
timestamps,
|
||||
worldPos)
|
||||
: null;
|
||||
|
||||
TryApplyGenericRemoteRenderPose(
|
||||
entity,
|
||||
earlyRemoteRoute,
|
||||
worldPos,
|
||||
p.LandblockId,
|
||||
rot);
|
||||
// The spatial bucket transaction runs for EVERY classification,
|
||||
// including both 4a branches: it is the only site that moves an
|
||||
// ordinary moving remote's draw bucket, commits its canonical
|
||||
// FullCellId (which feeds back as the classifier's own
|
||||
// CommittedCellId and as the ConstraintDistance cell key), and
|
||||
// recovers a pending bucket promotion. Neither 4a branch performs a
|
||||
// placement, so unlike route 2 there is no committed placement
|
||||
// receipt that could project this in its stead.
|
||||
if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId)
|
||||
|| !_liveEntities.TryGetRecord(
|
||||
update.Guid,
|
||||
|
|
@ -1519,13 +1702,19 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// leash at zero displacement on a fresh accepted Position, matching
|
||||
// retail's per-packet re-anchor.
|
||||
// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
|
||||
if (rmState.Host is { } remoteConstraintHost)
|
||||
//
|
||||
// C4 route 4a / D2: retail arms this AFTER the operation, only on
|
||||
// a nonzero MoveOrTeleport return (@0x00454272, inside the
|
||||
// `if (MoveOrTeleport(...) != 0)` at @0x00454254) — so this
|
||||
// pre-operation, unconditional arming is now the LEGACY shape and
|
||||
// runs only for the classifications 4a does not own. The two 4a
|
||||
// classifications arm it after their own operation instead, or
|
||||
// (the airborne no-op) not at all. 4b deletes this fallback.
|
||||
if (!RuntimeRemoteSteadyStatePosition.OwnsSteadyState(earlyRemoteRoute)
|
||||
&& rmState.Host is { } remoteConstraintHost)
|
||||
{
|
||||
AcDream.Core.Physics.Position anchor = remoteConstraintHost.Position;
|
||||
remoteConstraintHost.PositionManager.ConstrainTo(
|
||||
anchor,
|
||||
AcDream.Core.Physics.Motion.ConstraintDistance.GetStartConstraintDistance(anchor.ObjCellId),
|
||||
AcDream.Core.Physics.Motion.ConstraintDistance.GetMaxConstraintDistance(anchor.ObjCellId));
|
||||
RuntimeRemoteSteadyStatePosition.ArmConstraintAfterOperation(
|
||||
remoteConstraintHost);
|
||||
}
|
||||
|
||||
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
||||
|
|
@ -1546,7 +1735,11 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// ResolveWithTransition gate (rm.CellId != 0); without this
|
||||
// an airborne player remote falls through the floor because
|
||||
// the sphere sweep is skipped. Note: enabling the sweep also
|
||||
// exposes a pre-existing depenetration bug — see #42.
|
||||
// exposes a pre-existing depenetration bug — see #42. This is
|
||||
// acdream's OWN free-fall sweep bookkeeping, not a retail
|
||||
// CPhysicsObj field, so the D1 "writes nothing" rule below
|
||||
// deliberately does not reach it — register row AP-135, and
|
||||
// the NPC arm keeps the identical pair for the same reason.
|
||||
rmState.CellId = p.LandblockId;
|
||||
|
||||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
|
||||
|
|
@ -1581,19 +1774,33 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
rmState.LastServerPosTime = nowSecDiag;
|
||||
}
|
||||
|
||||
// ── AIRBORNE NO-OP ────────────────────────────────────────────
|
||||
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
|
||||
// when has_contact==0, return false (don't touch body, don't queue).
|
||||
// body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps
|
||||
// integrating gravity via per-frame UpdatePhysicsInternal. Server is
|
||||
// authoritative for the arc; we don't predict it locally.
|
||||
// ── AIRBORNE NO-OP (C4 route 4a / D1+D2) ─────────────────────
|
||||
// Retail CPhysicsObj::MoveOrTeleport (0x00516330): arg4 == 0
|
||||
// (the wire has_contact bit) falls straight to `return 0`
|
||||
// @0x0051636D and writes NOTHING — not the body, not the
|
||||
// interpolation queue, not the render entity, and (because
|
||||
// ConstrainTo sits inside `if (MoveOrTeleport(...) != 0)` at
|
||||
// @0x00454254) not the ConstraintManager leash either. The
|
||||
// classifier read the SAME wire bit this packet carries, so
|
||||
// there is nothing left to undo: the generic render-pose
|
||||
// write above was suppressed for this exact classification.
|
||||
if (RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation(
|
||||
earlyRemoteRoute))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!update.IsGrounded)
|
||||
{
|
||||
// Undo the unconditional entity hard-snap at the top of the
|
||||
// function (entity.SetPosition(worldPos)): the body is mid-arc
|
||||
// and TickAnimations will write entity = body next frame
|
||||
// anyway. Setting entity = body now prevents a 1-frame
|
||||
// LEGACY airborne no-op, unchanged, for the packets 4a
|
||||
// does not own — a cell-less remote (route 4b's
|
||||
// SetPosition), a rejected authority/payload, or no
|
||||
// classification at all. Those DID take the generic
|
||||
// render-pose write above, so this still undoes it: the
|
||||
// body is mid-arc and TickAnimations will write
|
||||
// entity = body next frame anyway, and setting
|
||||
// entity = body now prevents a 1-frame
|
||||
// teleport-to-server-then-yank-back rubber-band.
|
||||
// 4b deletes this fallback.
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1612,6 +1819,32 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
rmState.Body.Position = worldPos;
|
||||
rmState.Body.Orientation = rot;
|
||||
|
||||
// C4 route 4a / D2: a landing packet is a GROUNDED
|
||||
// correction, so retail's MoveOrTeleport returns nonzero
|
||||
// and SmartBox::HandleReceivedPosition does arm the leash
|
||||
// (@0x00454272). This block returns before the grounded
|
||||
// routing below, so it arms its own — post-move, matching
|
||||
// the anchor retail reads. Only for a classification 4a
|
||||
// owns: every other one already armed the legacy
|
||||
// pre-operation call above.
|
||||
RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
earlyRemoteRoute,
|
||||
rmState);
|
||||
|
||||
// C4 route 4a: a landing packet classifies Interpolate, so
|
||||
// the generic render-pose write was suppressed for it —
|
||||
// this block must therefore commit the landing pose to the
|
||||
// render entity itself, from the RESOLVED body, exactly as
|
||||
// the two branch tails below do. Before route 4a the
|
||||
// generic write had already put the entity at worldPos,
|
||||
// which is the same value the snap above just installed;
|
||||
// without this the rendered pose lags one frame until
|
||||
// RemotePhysicsUpdater re-projects it. rmState.CellId is
|
||||
// the server cell adopted at the top of this arm.
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.ParentCellId = rmState.CellId;
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
|
||||
// #161: retail landing = MovementManager::HitGround
|
||||
// (minterp → moveto, 0x00524300 — the R5-V5 facade
|
||||
// relay) with the Gravity state bit STILL SET
|
||||
|
|
@ -1650,56 +1883,72 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
}
|
||||
|
||||
// ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ────────────
|
||||
const float MaxPhysicsDistance = 96f; // retail player_distance far-snap
|
||||
const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap
|
||||
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
|
||||
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
|
||||
// #184 Slice 2b: the player UP routing gains the SAME placement-snap
|
||||
// backstop the NPC routing got in Slice 1 (AP-87). Now that grounded
|
||||
// PLAYER remotes run the sweep, an unplaced / stale-cell body — a
|
||||
// UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP
|
||||
// in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover
|
||||
// — would enqueue and the per-tick sweep would run from a cell that does
|
||||
// not contain the body -> garbage resolved pos -> the digest's
|
||||
// INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the
|
||||
// LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor
|
||||
// signal for players — it is already set by the VEL_DIAG block above);
|
||||
// !willBeDrTicked snaps a no-Sequencer player whose queue nothing would
|
||||
// consume; dist>96 is retail's far-snap. Placed + near corrections still
|
||||
// enqueue for the smooth catch-up.
|
||||
float bodyToTarget = System.Numerics.Vector3.Distance(
|
||||
rmState.Body.Position, worldPos);
|
||||
bool willBeDrTicked = WillAdvanceRemoteMotion(
|
||||
update.Guid,
|
||||
rmState);
|
||||
|
||||
if (dist > MaxPhysicsDistance || !willBeDrTicked
|
||||
|| bodyToTarget > BodySnapThreshold)
|
||||
// C4 route 4a: the near (Interpolate) decision — including the
|
||||
// AP-87 placement-snap backstop — now lives in
|
||||
// RuntimeRemoteSteadyStatePosition.ApplyInterpolate, shared
|
||||
// with the NPC branch below. Every OTHER classification (the
|
||||
// >=96 m far snap, a cell-less remote, a rejection, or none at
|
||||
// all) keeps the pre-existing legacy near/far routing below,
|
||||
// unchanged, until route 4b — "not Interpolate" is NOT "far".
|
||||
bool willBeDrTicked = WillAdvanceRemoteMotion(update.Guid, rmState);
|
||||
if (RuntimeRemoteSteadyStatePosition.IsNearInterpolate(
|
||||
earlyRemoteRoute))
|
||||
{
|
||||
// Beyond view bubble / large correction / unplaced body:
|
||||
// SetPositionSimple slide-snap. Clear queue.
|
||||
rmState.Interp.Clear();
|
||||
rmState.Body.Position = worldPos;
|
||||
rmState.Body.Orientation = rot;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Within view bubble, placed + near: enqueue waypoint for
|
||||
// adjust_offset to walk to. The per-frame TickAnimations player-
|
||||
// remote path drives the actual body advancement via
|
||||
// InterpolationManager.AdjustOffset. Pass body's current position so
|
||||
// the InterpolationManager can detect a far-distance enqueue (>100 m
|
||||
// from body) and pre-arm an immediate blip.
|
||||
System.Numerics.Quaternion? immediateOrientation =
|
||||
rmState.Interp.Enqueue(
|
||||
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
|
||||
rmState,
|
||||
worldPos,
|
||||
rot,
|
||||
isMovingTo: rmState.Movement.IsMovingTo(),
|
||||
currentBodyPosition: rmState.Body.Position,
|
||||
currentBodyOrientation: rmState.Body.Orientation);
|
||||
if (immediateOrientation is { } closeOrientation)
|
||||
rmState.Body.Orientation = closeOrientation;
|
||||
willBeDrTicked);
|
||||
|
||||
// D2: ConstrainTo arms strictly AFTER the operation,
|
||||
// anchored post-move — retail arms it only once
|
||||
// MoveOrTeleport returns nonzero (@0x00454254/@0x00454272).
|
||||
RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
earlyRemoteRoute,
|
||||
rmState);
|
||||
}
|
||||
else
|
||||
{
|
||||
// LEGACY near/far routing, unchanged. Its leash was
|
||||
// already armed by the legacy pre-operation call above.
|
||||
const float MaxPhysicsDistance = 96f; // retail player_distance far-snap
|
||||
const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap
|
||||
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
|
||||
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
|
||||
// #184 Slice 2b: the player UP routing gains the SAME placement-snap
|
||||
// backstop the NPC routing got in Slice 1 (AP-87). The 4 m
|
||||
// bodyToTarget guard is the LOAD-BEARING backstop;
|
||||
// !willBeDrTicked snaps a no-Sequencer player whose queue nothing
|
||||
// would consume; dist>96 is retail's far-snap.
|
||||
float bodyToTarget = System.Numerics.Vector3.Distance(
|
||||
rmState.Body.Position, worldPos);
|
||||
|
||||
if (dist > MaxPhysicsDistance || !willBeDrTicked
|
||||
|| bodyToTarget > BodySnapThreshold)
|
||||
{
|
||||
// Beyond view bubble / large correction / unplaced body:
|
||||
// SetPositionSimple slide-snap. Clear queue.
|
||||
rmState.Interp.Clear();
|
||||
rmState.Body.Position = worldPos;
|
||||
rmState.Body.Orientation = rot;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Within view bubble, placed + near: enqueue waypoint for
|
||||
// adjust_offset to walk to.
|
||||
System.Numerics.Quaternion? immediateOrientation =
|
||||
rmState.Interp.Enqueue(
|
||||
worldPos,
|
||||
rot,
|
||||
isMovingTo: rmState.Movement.IsMovingTo(),
|
||||
currentBodyPosition: rmState.Body.Position,
|
||||
currentBodyOrientation: rmState.Body.Orientation);
|
||||
if (immediateOrientation is { } closeOrientation)
|
||||
rmState.Body.Orientation = closeOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
// Track the UP-derived synth velocity for diagnostics
|
||||
// ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the
|
||||
// #39-era cycle-refinement call that used to live here is
|
||||
|
|
@ -1754,6 +2003,35 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
}
|
||||
|
||||
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
|
||||
// ── AIRBORNE NO-OP (C4 route 4a / D1) ────────────────────────────
|
||||
// The exact mirror of the player-remote arm above. Retail's
|
||||
// MoveOrTeleport makes no `this == player` distinction: arg4 == 0
|
||||
// returns 0 @0x0051636D and writes nothing, so an NPC remote's
|
||||
// wire not-in-contact packet must no longer hard-snap the body,
|
||||
// decide an animation cycle from a synthesized arc velocity, sync
|
||||
// the render entity, or publish a collision shadow. This branch
|
||||
// is now driven by the WIRE has_contact bit through the
|
||||
// classifier, not by the client-tracked rmState.Airborne flag
|
||||
// (which is what D1 was).
|
||||
//
|
||||
// Two acdream-only per-packet bookkeeping writes are deliberately
|
||||
// KEPT here, exactly as the player arm has always kept them (see
|
||||
// register row AP-135): the server cell id, which acdream's own
|
||||
// per-tick free-fall ResolveWithTransition sweep gates on
|
||||
// (rm.CellId != 0) and without which an airborne remote falls
|
||||
// through the floor, and the last-server-position sample, without
|
||||
// which the first grounded packet after the arc would synthesize
|
||||
// its velocity across the whole jump.
|
||||
if (RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation(
|
||||
earlyRemoteRoute))
|
||||
{
|
||||
rmState.CellId = p.LandblockId;
|
||||
rmState.LastServerPos = worldPos;
|
||||
rmState.LastServerPosTime = nowSec;
|
||||
return;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3? serverVelocity = update.Velocity;
|
||||
if (serverVelocity is null
|
||||
&& !IsPlayerGuid(update.Guid)
|
||||
|
|
@ -1801,28 +2079,44 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
}
|
||||
if (!snapSuppressedByStick)
|
||||
{
|
||||
// #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330)
|
||||
// for grounded NPC remotes. The body is PLACED (hard-snapped) whenever
|
||||
// it is not already tracking NEAR the server position — the first UP,
|
||||
// a large correction / teleport, an out-of-view creature (>96 m from
|
||||
// the local player), or an entity the DR loop won't tick — otherwise the
|
||||
// server point is a GENTLE dead-reckoning TARGET the per-tick interp
|
||||
// catch-up walks to, and the KEPT sweep de-overlaps that movement.
|
||||
// C4 route 4a: the near (Interpolate) decision — including the
|
||||
// AP-87 placement-snap backstop — is now the SAME Runtime
|
||||
// owner the player-remote branch above calls; retail's
|
||||
// MoveOrTeleport (0x00516330) makes no `this == player`
|
||||
// distinction, so the two per-kind copies became one. TS-44's
|
||||
// sticky suppression stays an NPC-only CALLER gate (this
|
||||
// `if`), which is what its register row describes and what
|
||||
// the player arm has never had.
|
||||
//
|
||||
// The placement-snap is LOAD-BEARING: the earlier attempt (reverted)
|
||||
// enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped
|
||||
// over a huge distance into the sweep -> a resolve started in a cell that
|
||||
// did not contain the body -> garbage resolved pos -> INVISIBLE monster
|
||||
// while its shadow (synced to server truth) stayed put -> player stuck on
|
||||
// nothing. Airborne keeps the authoritative hard-snap (arc integrates
|
||||
// locally, K-fix15). Physics digest 2026-07-07 banner.
|
||||
if (rmState.Airborne)
|
||||
{
|
||||
rmState.Body.Position = worldPos;
|
||||
rmState.Body.Orientation = rot;
|
||||
}
|
||||
else
|
||||
// Every OTHER classification (the >=96 m far snap, a
|
||||
// cell-less remote, a rejection, or none at all) keeps the
|
||||
// pre-existing legacy routing below, unchanged, until route
|
||||
// 4b — "not Interpolate" is NOT "far".
|
||||
// #184 (2026-07-07): an AIRBORNE body keeps its authoritative
|
||||
// hard-snap (the arc integrates locally, K-fix15), and that
|
||||
// decision is taken FIRST — a landing packet classifies
|
||||
// Interpolate, so letting route 4a's branch see it before the
|
||||
// airborne test would enqueue a body that must plant, and a
|
||||
// creature knocked off a ledge would glide down over a packet
|
||||
// interval. Physics digest 2026-07-07 banner.
|
||||
if (ApplyRemoteContactRouting(
|
||||
rmState,
|
||||
earlyRemoteRoute,
|
||||
worldPos,
|
||||
rot,
|
||||
WillAdvanceRemoteMotion(update.Guid, rmState))
|
||||
is RemoteContactArm.Legacy)
|
||||
{
|
||||
// LEGACY NPC near/far routing, unchanged. Its leash was
|
||||
// already armed by the legacy pre-operation call above.
|
||||
// The body is PLACED (hard-snapped) whenever it is not
|
||||
// already tracking NEAR the server position — the first
|
||||
// UP, a large correction / teleport, an out-of-view
|
||||
// creature (>96 m from the local player), or an entity the
|
||||
// DR loop won't tick — otherwise the server point is a
|
||||
// GENTLE dead-reckoning TARGET the per-tick interp
|
||||
// catch-up walks to, and the KEPT sweep de-overlaps that
|
||||
// movement.
|
||||
const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
|
||||
const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
|
||||
var localPlayerPosNpc = _playerController?.Position
|
||||
|
|
@ -1830,18 +2124,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc);
|
||||
float bodyToTargetNpc = System.Numerics.Vector3.Distance(
|
||||
rmState.Body.Position, worldPos);
|
||||
// The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop:
|
||||
// it snaps any body not already near the target (an unplaced first-UP
|
||||
// body sits at the origin / spawn pos, far from worldPos). firstUpNpc is
|
||||
// a belt-and-suspenders hint only — it is NOT a reliable "never placed"
|
||||
// signal because a UM that enters a locomotion cycle can stamp
|
||||
// LastServerPosTime before the first UP (~:5340). Don't tune the 4 m
|
||||
// threshold down without re-checking the unplaced-body case.
|
||||
bool firstUpNpc = rmState.LastServerPosTime <= 0.0;
|
||||
// Enqueue only if the canonical ordinary-object workset
|
||||
// will consume the queue. Animation is optional in retail;
|
||||
// LiveEntityAnimationScheduler advances a spatial
|
||||
// RemoteMotion even when no LiveEntityAnimationState exists.
|
||||
bool willBeDrTickedNpc = WillAdvanceRemoteMotion(
|
||||
update.Guid,
|
||||
rmState);
|
||||
|
|
@ -1871,6 +2154,21 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D2: ConstrainTo arms strictly AFTER the operation above,
|
||||
// anchored post-move (@0x00454272, inside the
|
||||
// `if (MoveOrTeleport(...) != 0)` at @0x00454254), and only for a
|
||||
// classification route 4a owns — every other one already armed
|
||||
// the legacy pre-operation call above, exactly as before. Retail's
|
||||
// ConstraintManager leash is independent of the acdream-only TS-44
|
||||
// sticky suppression (which only concerns the enqueue/snap above),
|
||||
// so this deliberately sits OUTSIDE the snapSuppressedByStick
|
||||
// gate: a stuck NPC's leash still re-arms every accepted Position
|
||||
// exactly as it did before this route split the single call into a
|
||||
// per-branch pair.
|
||||
RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
|
||||
earlyRemoteRoute,
|
||||
rmState);
|
||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
|
||||
|
|
|
|||
|
|
@ -2396,6 +2396,27 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
out timestamps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4a: borrows the canonical Runtime classification for one
|
||||
/// remote's accepted Position. App supplies only the two things it owns —
|
||||
/// the canonical record and the live local-player distance — and never
|
||||
/// assembles an authority, a generation token, or a route request of its
|
||||
/// own. Returns <see langword="null"/> when Runtime declines to classify;
|
||||
/// see <see cref="RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition"/>.
|
||||
/// </summary>
|
||||
internal RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition(
|
||||
RuntimeEntityRecord canonical,
|
||||
in AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
|
||||
AcDream.Core.Physics.PositionTimestampDisposition disposition,
|
||||
in AcceptedPhysicsTimestamps timestamps,
|
||||
float? playerDistance) =>
|
||||
_entityObjects.ClassifyRemoteAcceptedPosition(
|
||||
canonical,
|
||||
update,
|
||||
disposition,
|
||||
timestamps,
|
||||
playerDistance);
|
||||
|
||||
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
||||
_directory.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
/// <summary>C4 route 2: see <see cref="RegisterAcceptedPositionDriveOwnership"/>.</summary>
|
||||
private readonly List<Func<int>> _acceptedPositionDriveOwnership = [];
|
||||
/// <summary>
|
||||
/// C4 route 4a: captured by <see cref="BindEventContext"/> alongside the
|
||||
/// other generation-consuming children so
|
||||
/// <see cref="ClassifyRemoteAcceptedPosition"/> can build a real
|
||||
/// <c>RuntimeAuthoritativePositionAuthority</c> from the SAME generation
|
||||
/// source every other accepted-position authority in this lifetime uses.
|
||||
/// Never exposed: a host must not be able to read a generation token out
|
||||
/// of this lifetime and assemble its own authority beside it.
|
||||
/// </summary>
|
||||
private Func<RuntimeGenerationToken>? _generation;
|
||||
/// <summary>
|
||||
/// #297 (review round 2, preferred fix): keeps every canonical
|
||||
/// snapshot's <c>ObjectDescriptionFlags</c> live against
|
||||
/// <c>ClientObjectTable.PublicWeenieBitfield</c> — see
|
||||
|
|
@ -530,12 +540,62 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
Func<ulong> frameNumber)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
_generation = generation;
|
||||
Events.BindContext(generation, frameNumber);
|
||||
Placements.BindGeneration(generation);
|
||||
InitialCreateResidences.BindGeneration(generation);
|
||||
InitialCreateExecution.BindGeneration(generation);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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>
|
||||
/// 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
|
||||
/// to derive retail's <c>player_distance</c> from. In every one of those
|
||||
/// cases the caller's pre-existing legacy path runs completely unchanged
|
||||
/// — a null here is "route 4a has no opinion", never "rejected".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition(
|
||||
RuntimeEntityRecord canonical,
|
||||
in WorldSession.EntityPositionUpdate update,
|
||||
PositionTimestampDisposition disposition,
|
||||
in AcceptedPhysicsTimestamps timestamps,
|
||||
float? playerDistance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(canonical);
|
||||
if (_generation is not { } generation)
|
||||
return null;
|
||||
if (!RuntimeAcceptedPositionRouteRequests.TryBuild(
|
||||
generation(),
|
||||
canonical,
|
||||
update,
|
||||
RuntimePositionEntityKind.Remote,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
disposition,
|
||||
timestamps.PreviousTeleport,
|
||||
timestamps.Teleport,
|
||||
playerDistance,
|
||||
// Retail's UsePositionFromServer is consumed by the local
|
||||
// player branch only; the Remote branch never reads it.
|
||||
usePositionFromServer: false,
|
||||
out RuntimeAcceptedPositionRouteRequest request))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return RuntimeAuthoritativePositionRouteClassifier
|
||||
.ClassifyAcceptedPosition(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c: registers one host callback fired for every FRESH initial-create
|
||||
/// residence begin (never for a same-generation FIFO append). Multicast,
|
||||
|
|
@ -2020,6 +2080,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
finally
|
||||
{
|
||||
_disposed = true;
|
||||
_generation = null;
|
||||
_pvpBitfieldSync.Dispose();
|
||||
Events.Dispose();
|
||||
Physics.Dispose();
|
||||
|
|
|
|||
|
|
@ -1904,46 +1904,26 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
}
|
||||
else
|
||||
{
|
||||
var authority = new RuntimeAuthoritativePositionAuthority(
|
||||
CurrentGeneration(),
|
||||
key,
|
||||
canonical.PositionAuthorityVersion,
|
||||
update.PositionSequence,
|
||||
action.PreviousTeleportSequence,
|
||||
action.AcceptedTimestamps.Teleport,
|
||||
action.PositionDisposition);
|
||||
|
||||
// Round 3 A3: contact comes SOLELY from the retained wire
|
||||
// packet's own IsGrounded bit (PositionPack bit 0x4,
|
||||
// server-asserted contact at admission time) - never a live
|
||||
// body query, never an Inputs fallback.
|
||||
bool hasContact = update.IsGrounded;
|
||||
// Round 3 B5: HasAnimations is the SAME data-driven proxy for
|
||||
// every position source, SameIncarnationCreate included - no
|
||||
// PositionSource short-circuit. Round 4 R4-13: fall back to the
|
||||
// nested PhysicsSpawnData's own MotionTableId when the
|
||||
// top-level snapshot field is null (WeenieDescription/ObjDesc
|
||||
// merges only ever populate one of the two, depending on
|
||||
// which stage last touched appearance vs description).
|
||||
bool hasAnimations = (canonical.Snapshot.MotionTableId
|
||||
?? canonical.Snapshot.Physics?.MotionTableId) is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
|
||||
var request = new RuntimeAcceptedPositionRouteRequest(
|
||||
authority,
|
||||
entityKind,
|
||||
action.PositionSource,
|
||||
update.Position,
|
||||
update.PlacementId,
|
||||
update.Velocity,
|
||||
canonical.FullCellId,
|
||||
hasContact,
|
||||
inputs.PlayerDistance,
|
||||
inputs.UsePositionFromServer,
|
||||
hasAnimations,
|
||||
new RuntimePositionPlacementFacts(
|
||||
canonical.FinalPhysicsState,
|
||||
canonical.Snapshot.SetupTableId is not null));
|
||||
// Round 3 A3 / B5 and Round 4 R4-13 (contact from the retained
|
||||
// wire packet's own IsGrounded bit only; the data-driven
|
||||
// HasAnimations proxy with its PhysicsSpawnData fallback) now
|
||||
// live in the ONE shared request builder, which C4 route 4a's
|
||||
// remote classification also uses - see
|
||||
// RuntimeAcceptedPositionRouteRequests for why a second
|
||||
// hand-written copy of this construction is not allowed.
|
||||
RuntimeAcceptedPositionRouteRequest request =
|
||||
RuntimeAcceptedPositionRouteRequests.Build(
|
||||
CurrentGeneration(),
|
||||
canonical,
|
||||
key,
|
||||
update,
|
||||
entityKind,
|
||||
action.PositionSource,
|
||||
action.PositionDisposition,
|
||||
action.PreviousTeleportSequence,
|
||||
action.AcceptedTimestamps.Teleport,
|
||||
inputs.PlayerDistance,
|
||||
inputs.UsePositionFromServer);
|
||||
|
||||
route = RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(request);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
using System;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE construction of a <see cref="RuntimeAcceptedPositionRouteRequest"/>
|
||||
/// from a canonical record plus an accepted wire packet. Every caller that
|
||||
/// classifies an accepted Position goes through here — the deferred
|
||||
/// initial-create continuation (<c>RuntimeInitialCreateContinuationExecutor</c>)
|
||||
/// and C4 route 4a's remote steady state
|
||||
/// (<see cref="RuntimeRemoteSteadyStatePosition"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// This exists because the two independent hand-written copies had already
|
||||
/// diverged: #307 (the always-zero <c>PreviousTeleport</c>) and the fabricated
|
||||
/// <c>Vector3.Zero</c> player position both entered through a second copy of
|
||||
/// this construction rather than through the original. Adding a caller means
|
||||
/// adding an overload here, never a third copy.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class RuntimeAcceptedPositionRouteRequests
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the request for a caller that has already resolved the exact
|
||||
/// incarnation key and a real <c>player_distance</c>.
|
||||
/// </summary>
|
||||
internal static RuntimeAcceptedPositionRouteRequest Build(
|
||||
RuntimeGenerationToken generation,
|
||||
RuntimeEntityRecord canonical,
|
||||
RuntimeEntityKey key,
|
||||
in WorldSession.EntityPositionUpdate update,
|
||||
RuntimePositionEntityKind entityKind,
|
||||
RuntimeAcceptedPositionSource source,
|
||||
PositionTimestampDisposition disposition,
|
||||
ushort previousTeleportSequence,
|
||||
ushort acceptedTeleportSequence,
|
||||
float playerDistance,
|
||||
bool usePositionFromServer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(canonical);
|
||||
var authority = new RuntimeAuthoritativePositionAuthority(
|
||||
generation,
|
||||
key,
|
||||
canonical.PositionAuthorityVersion,
|
||||
update.PositionSequence,
|
||||
previousTeleportSequence,
|
||||
acceptedTeleportSequence,
|
||||
disposition);
|
||||
|
||||
// Contact comes SOLELY from the retained wire packet's own IsGrounded
|
||||
// bit (PositionPack bit 0x4, server-asserted contact at admission
|
||||
// time) - never a live body query. HasAnimations is the same
|
||||
// data-driven proxy for every position source: WeenieDescription and
|
||||
// ObjDesc merges only ever populate one of the two MotionTableId
|
||||
// fields, depending on which stage last touched appearance vs
|
||||
// description.
|
||||
bool hasAnimations = (canonical.Snapshot.MotionTableId
|
||||
?? canonical.Snapshot.Physics?.MotionTableId) is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
|
||||
return new RuntimeAcceptedPositionRouteRequest(
|
||||
authority,
|
||||
entityKind,
|
||||
source,
|
||||
update.Position,
|
||||
update.PlacementId,
|
||||
update.Velocity,
|
||||
canonical.FullCellId,
|
||||
update.IsGrounded,
|
||||
playerDistance,
|
||||
usePositionFromServer,
|
||||
hasAnimations,
|
||||
new RuntimePositionPlacementFacts(
|
||||
canonical.FinalPhysicsState,
|
||||
canonical.Snapshot.SetupTableId is not null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the request for a caller whose incarnation key or local-player
|
||||
/// position may not exist yet. Returns <see langword="false"/> in both
|
||||
/// cases rather than inventing a substitute: a null local-player position
|
||||
/// MUST yield "no request", never a fabricated <c>Vector3.Zero</c> that
|
||||
/// would report every remote as implausibly far — <c>GameRuntime</c>'s
|
||||
/// <c>BindLiveInputs</c> call states exactly that rule for exactly this
|
||||
/// field, and <c>RuntimeInitialCreateContinuationExecutor.ResolveInputs</c>
|
||||
/// honours it. A nonfinite distance is NOT filtered here: the classifier
|
||||
/// owns that policy explicitly (<c>RejectedData</c>), and one
|
||||
/// classification means one owner.
|
||||
/// </summary>
|
||||
internal static bool TryBuild(
|
||||
RuntimeGenerationToken generation,
|
||||
RuntimeEntityRecord canonical,
|
||||
in WorldSession.EntityPositionUpdate update,
|
||||
RuntimePositionEntityKind entityKind,
|
||||
RuntimeAcceptedPositionSource source,
|
||||
PositionTimestampDisposition disposition,
|
||||
ushort previousTeleportSequence,
|
||||
ushort acceptedTeleportSequence,
|
||||
float? playerDistance,
|
||||
bool usePositionFromServer,
|
||||
out RuntimeAcceptedPositionRouteRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(canonical);
|
||||
if (canonical.Key is not { } key || playerDistance is not { } distance)
|
||||
{
|
||||
request = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
request = Build(
|
||||
generation,
|
||||
canonical,
|
||||
key,
|
||||
update,
|
||||
entityKind,
|
||||
source,
|
||||
disposition,
|
||||
previousTeleportSequence,
|
||||
acceptedTeleportSequence,
|
||||
distance,
|
||||
usePositionFromServer);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
198
src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs
Normal file
198
src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4a (2026-08-03): the Runtime-owned decision for the two
|
||||
/// <see cref="RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition"/>
|
||||
/// remote branches that perform NO SetPosition — retail
|
||||
/// <c>CPhysicsObj::MoveOrTeleport</c> (0x00516330)'s airborne no-op
|
||||
/// (<c>arg4==0</c> -> return 0 @0x0051636D, nothing written at all) and its
|
||||
/// near <c>InterpolateTo</c> queue (<c>player_distance < 96 m</c>
|
||||
/// @0x005163AF). Both classify identically for player-remote and NPC-remote
|
||||
/// incarnations — retail's disassembly makes no <c>this==player</c>
|
||||
/// distinction on this path (see <c>ConstraintDistance</c>) — so one Runtime
|
||||
/// owner decides and applies both, replacing the two independent per-kind
|
||||
/// copies that used to live in <c>LiveEntityNetworkUpdateController</c>. The
|
||||
/// far (>=96 m) and teleport/cell-less branches remain the legacy App path
|
||||
/// until C4 route 4b.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Exactly two dispositions are owned here.</b> Everything else —
|
||||
/// <c>SetPositionSimple</c>, <c>SetPosition</c>, <c>RejectedAuthority</c>,
|
||||
/// <c>RejectedData</c>, and "not classified at all" (<see langword="null"/>) —
|
||||
/// falls through to the untouched legacy App routing. "Not Interpolate" must
|
||||
/// never be read as "far".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class RuntimeRemoteSteadyStatePosition
|
||||
{
|
||||
/// <summary>
|
||||
/// AP-87 (register row, carried forward — not retired): retail's
|
||||
/// InterpolateTo has no concept of "the body isn't already tracking the
|
||||
/// target". acdream's catch-up + per-tick sweep needs one — an unplaced
|
||||
/// body (a first-UP / spawn-seed origin, or any large correction)
|
||||
/// enqueued instead of snapped would let InterpolationManager's 100 m
|
||||
/// far-blip fire and the per-tick sweep run from a cell that does not
|
||||
/// contain the body, producing the reverted #184 invisible-but-solid
|
||||
/// monster.
|
||||
/// </summary>
|
||||
private const float BodySnapThreshold = 4f;
|
||||
|
||||
internal enum Action : byte
|
||||
{
|
||||
/// <summary>AP-87 backstop: the body wasn't already tracking the
|
||||
/// target closely enough, has no consumer to walk the queue, or has
|
||||
/// never received a server sample.</summary>
|
||||
Snapped,
|
||||
|
||||
/// <summary>The ordinary near catch-up: queued for the per-tick
|
||||
/// InterpolationManager/ConstraintManager chain to walk toward.</summary>
|
||||
Enqueued,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when route 4a owns this classification outright, so the legacy App
|
||||
/// path must not run for it AT ALL — not partially, not "just the render
|
||||
/// write". This is the staged cutover's ONLY discriminator: the
|
||||
/// classification itself, never a heuristic or a flag.
|
||||
/// </summary>
|
||||
internal static bool OwnsSteadyState(RuntimeAuthoritativePositionRoute? route) =>
|
||||
IsAirborneNoOperation(route) || IsNearInterpolate(route);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>arg4 == 0</c> return-0 branch: the accepted wire packet
|
||||
/// reports no ground contact, so <c>MoveOrTeleport</c> writes nothing and
|
||||
/// its caller <c>SmartBox::HandleReceivedPosition</c> (0x00453FD0) skips
|
||||
/// <c>ConstrainTo</c> (@0x00454272, inside
|
||||
/// <c>if (MoveOrTeleport(...) != 0)</c>) as well.
|
||||
/// </summary>
|
||||
internal static bool IsAirborneNoOperation(
|
||||
RuntimeAuthoritativePositionRoute? route) =>
|
||||
route is
|
||||
{
|
||||
Disposition: RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
||||
};
|
||||
|
||||
/// <summary>Retail's <c>player_distance < 96f</c> InterpolateTo
|
||||
/// branch.</summary>
|
||||
internal static bool IsNearInterpolate(
|
||||
RuntimeAuthoritativePositionRoute? route) =>
|
||||
route is
|
||||
{
|
||||
Disposition: RuntimeAuthoritativePositionDisposition.Interpolate,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Applies the retail near-InterpolateTo branch for one remote whose
|
||||
/// accepted Position has already classified to
|
||||
/// <see cref="RuntimeAuthoritativePositionDisposition.Interpolate"/>.
|
||||
/// Callers must NOT invoke this for
|
||||
/// <see cref="RuntimeAuthoritativePositionDisposition.NoPositionOperation"/>
|
||||
/// — that branch writes nothing at all (retail returns 0) and has no
|
||||
/// operation for this method to perform.
|
||||
///
|
||||
/// <para>
|
||||
/// The acdream-only TS-44 sticky suppression is deliberately NOT here: it
|
||||
/// is an NPC-only caller gate and stays one, so this seam is exactly the
|
||||
/// kind-agnostic retail decision plus AP-87.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The returned <see cref="Action"/> is this seam's observable outcome and
|
||||
/// is what the focused tests assert AP-87's snap against its enqueue with.
|
||||
/// Production has no use for it and deliberately discards it at both call
|
||||
/// sites — do not delete it as dead, because collapsing it to
|
||||
/// <see langword="void"/> would make the two AP-87 outcomes
|
||||
/// indistinguishable from outside.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static Action ApplyInterpolate(
|
||||
RemoteMotion remote,
|
||||
Vector3 worldPosition,
|
||||
Quaternion orientation,
|
||||
bool isMovingTo,
|
||||
bool willBeDrTicked)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
// AP-87, all three conditions, verbatim from the NPC copy this
|
||||
// replaces. `firstUp` is a belt hint, not the load-bearing guard: it
|
||||
// is unreliable because a UM that enters a locomotion cycle can stamp
|
||||
// LastServerPosTime before the first UP. It is retained rather than
|
||||
// silently dropped, and is exact for BOTH kinds — the player-remote
|
||||
// caller stamps LastServerPosTime before it routes (its diagnostic
|
||||
// roll-forward block), so `firstUp` is structurally false there and
|
||||
// this evaluates to exactly the player copy's own two conditions.
|
||||
bool firstUp = remote.LastServerPosTime <= 0.0;
|
||||
float bodyToTarget = Vector3.Distance(remote.Body.Position, worldPosition);
|
||||
if (firstUp || !willBeDrTicked || bodyToTarget > BodySnapThreshold)
|
||||
{
|
||||
remote.Interp.Clear();
|
||||
remote.Body.Position = worldPosition;
|
||||
remote.Body.Orientation = orientation;
|
||||
return Action.Snapped;
|
||||
}
|
||||
|
||||
Quaternion? immediate = remote.Interp.Enqueue(
|
||||
worldPosition,
|
||||
orientation,
|
||||
isMovingTo,
|
||||
remote.Body.Position,
|
||||
remote.Body.Orientation);
|
||||
if (immediate is { } close)
|
||||
remote.Body.Orientation = close;
|
||||
return Action.Enqueued;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D2: retail arms <c>CPhysicsObj::ConstrainTo</c> strictly AFTER
|
||||
/// <c>MoveOrTeleport</c> returns nonzero, anchored to the object's own
|
||||
/// CURRENT (i.e. post-move) position — <c>SmartBox::HandleReceivedPosition</c>
|
||||
/// 0x00453FD0 reads <c>&arg2->m_position</c> at 0x00454272, inside
|
||||
/// the <c>if (MoveOrTeleport(...) != 0)</c> at 0x00454254. It therefore
|
||||
/// does NOT run on the airborne no-op.
|
||||
///
|
||||
/// <para>
|
||||
/// Route-gated so it can only fire for a classification route 4a owns:
|
||||
/// every other classification still arms the leash through the legacy
|
||||
/// pre-operation call site, unchanged, until 4b moves them too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool TryArmConstraintAfterOperation(
|
||||
RuntimeAuthoritativePositionRoute? route,
|
||||
RemoteMotion remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
if (route is not { } selected
|
||||
|| !OwnsSteadyState(selected)
|
||||
|| !selected.ConstrainAfterRouting
|
||||
|| remote.Host is not { } host)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ArmConstraintAfterOperation(host);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The leash arming itself: <c>ConstraintPosOffset</c> is captured as
|
||||
/// distance(anchor, host.Position) at call time, and the anchor here IS
|
||||
/// host.Position read live, so a fresh accepted Position always restarts
|
||||
/// the leash at zero displacement — retail's per-packet re-anchor.
|
||||
/// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
|
||||
/// </summary>
|
||||
internal static void ArmConstraintAfterOperation(EntityPhysicsHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
Position anchor = host.Position;
|
||||
host.PositionManager.ConstrainTo(
|
||||
anchor,
|
||||
ConstraintDistance.GetStartConstraintDistance(anchor.ObjCellId),
|
||||
ConstraintDistance.GetMaxConstraintDistance(anchor.ObjCellId));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue