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:
Erik 2026-08-04 00:19:05 +02:00
parent 19d9509497
commit 44830a0eb3
12 changed files with 1854 additions and 150 deletions

View file

@ -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();