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

@ -159,7 +159,7 @@ readiness/requeue adaptation. See
---
## 3. Documented approximation (AP) — 90 active rows (AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
## 3. Documented approximation (AP) — 93 active rows (AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -239,7 +239,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only the 4 m guard is the load-bearing backstop and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. The legacy far/cell-less/rejected fallbacks in that file still carry their own pre-existing copies of these constants until C4 route 4b deletes them | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 translucency`, applied to all 4 D3D9 material alpha channels) |
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
| AP-92 | Private creature viewports (paperdoll and examination) render through isolated `IGpuRenderTarget`s and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose. **V6l narrowing (2026-07-28), V11 update (2026-07-29):** the target is backend-neutral and the blit's V origin is not assumed — `IUiViewportRenderer.TextureIsBottomUp` derives it from the backend that made the texture. With GL deleted the only answer in the tree is Vulkan's top-left origin, but the seam is kept rather than folded flat, because it costs one property and it is what let the origin question be answered by data instead of by assumption. | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | Vulkan render-to-texture is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | Alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation. The origin half of this risk is closed; a FUTURE backend would have to answer `TextureIsBottomUp` for itself | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
@ -281,6 +281,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 |
| AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 |
| AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` |
| AP-135 | **Filed 2026-08-03 (C4 route 4a).** Retail `CPhysicsObj::MoveOrTeleport` 0x00516330 writes NOTHING on the airborne no-op (`arg4 == 0` -> `return 0` @0x0051636D), and `SmartBox::HandleReceivedPosition` 0x00453FD0 skips `ConstrainTo` with it (@0x00454272 sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254). acdream honours that for every retail-modeled write — body pose, interpolation queue, leash, render entity, collision shadow, and the AP-80 velocity-derived animation cycle — but deliberately KEEPS two acdream-only per-packet bookkeeping writes on that branch: `RemoteMotion.CellId = wire landblock` and the `LastServerPos`/`LastServerPosTime` sample. This was pre-existing player-remote behaviour; route 4a extends it to NPC remotes so both arms are identical | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, both remote airborne-no-op returns) | The cell id is what acdream's OWN per-tick free-fall `ResolveWithTransition` sweep gates on (`rm.CellId != 0`); without it an airborne remote's sphere sweep is skipped and it falls through the floor (#42's neighbourhood). The server sample is what the first grounded packet after the arc synthesizes its velocity from; dropping it would make that velocity span the whole jump. Neither is a retail `CPhysicsObj` field being written | A remote's cell membership tracks the server's landblock during an arc where retail would keep the cell its own physics last resolved. Visible only if the server's mid-arc landblock disagrees with the client's swept cell — the wire cell is authoritative in every case acdream has observed. Retire together with the free-fall sweep gate, when the remote arc is resolved by the same transition machinery the local player uses | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x0051636D `return 0`); `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00454254/@0x00454272) |
## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`**narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
@ -309,7 +310,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset``InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick. **Scope re-affirmed by C4 route 4a (2026-08-03):** the gate stays NPC-only and stays in the App caller. The shared Runtime seam route 4a introduced is deliberately indifferent to the sticky lease, because folding the check into it would have silently extended TS-44 to player remotes — which are stickable but have never had this suppression, and whose far branch would still not have it | NPC-only caller gate, `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`snapSuppressedByStick`) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |

View file

@ -270,6 +270,35 @@ same commit) → docs/handoff commit. No workarounds; no fused slices.
Position-time service-window guard, #277's broken bound, N3 (headless never
calls `RetryPending`), and the third divergence (`ConstrainTo` never armed
on the remote teleport branch) all live.
**4b also inherits 4a's ownership remainder — scheduled here, not implied
by code comments.** Two independent reviews flagged that 4a satisfies
contract items 1 and 2 only partially, and the plan must carry that rather
than leaving it in `// 4b deletes this fallback` comments:
- Runtime owns the classification, the request construction (one shared
builder, `RuntimeAcceptedPositionRouteRequests`), the near-InterpolateTo
decision with AP-87, and the post-operation `ConstrainTo`. **App still
owns** branch selection, the airborne early return, the
`RemoteMotion.CellId` write, the `WorldEntity` pose write, and the
collision-shadow publish — all in `LiveEntityNetworkUpdateController`.
- Item 2 ("both hosts drive the identical Runtime entry point") is
satisfied only VACUOUSLY: `RuntimeLiveEntitySessionController` returns
early for remotes, so no no-window host exercises this path at all and
nothing can diverge yet. That stops being true the moment a headless
host needs remote motion.
- Every legacy fallback 4a deliberately left in place is 4b's to delete:
the pre-operation unconditional `ConstrainTo`, the player arm's
`!update.IsGrounded` no-op, the player and NPC legacy near/far routing
(each still carrying its own duplicate `96f` / `4f` constants), and the
airborne-precedence carve-out
(`LiveEntityNetworkUpdateController.ApplyRemoteContactRouting`) that
keeps a landing body snapping. Retiring the last one is a real behaviour
decision — retail makes no player/NPC distinction there — and needs its
own live evidence, not a silent convergence.
- Register row **AP-135** (the airborne no-op's retained acdream
bookkeeping: the server cell id for the free-fall sweep gate, and the
last-server-position sample) retires with 4b's transition machinery, not
before.
Note the route-4 Create half is ALREADY DONE (C3b/C3c); the remaining work is
steady-state remote Position plus the deletions. AP-131 is NOT retired by
either sub-slice — see the scoping doc for why route 4 alone cannot.

View file

@ -81,11 +81,24 @@ a nonzero return, anchored post-move. No register row exists.
Only the parts reachable from the two 4a branches:
- The generic tail's remote writes (`entity.SetPosition` / `ParentCellId` /
`Rotation` / `RebucketLiveEntity`) for a remote whose classification is
`Interpolate` or `NoPositionOperation`. The committed result must reach the
render entity through the existing placement-projection sink instead — the
same substitution route 2 made.
- The generic tail's remote RENDER-POSE writes (`entity.SetPosition` /
`ParentCellId` / `Rotation`) for a remote whose classification is
`Interpolate` or `NoPositionOperation`.
> **CORRECTED 2026-08-03 (review finding R1,
> [`2026-08-03-c4-route-4a-review-findings.md`](2026-08-03-c4-route-4a-review-findings.md)).**
> This bullet originally also listed `RebucketLiveEntity`, and said the
> result "must reach the render entity through the existing
> placement-projection sink instead — the same substitution route 2 made."
> That instruction was copied from route 2 and does not transfer: route 2
> performs a placement and therefore has a committed receipt to project,
> while NEITHER 4a branch performs a placement, so nothing substitutes for
> the bucket transaction. **The rebucket keeps running for both 4a
> classifications.** It is the only site that moves an ordinary moving
> remote's draw bucket, commits its canonical `FullCellId`, and recovers a
> pending bucket promotion (`GpuWorldState`, the 2026-07-03 invisible-player
> fix); deleting it produced the #184-class invisible-but-solid creature
> through a different door.
- The player-remote near/far routing at `:1653-1702` and the NPC copy at
`:1826-1871`**the near half only.** Each has its own duplicated copies of
`MaxPhysicsDistance = 96f` and `BodySnapThreshold = 4f`; the far half stays
@ -122,6 +135,19 @@ to the classifier is the failure mode.** Say which you chose.
transfer.
5. The airborne branch writes NOTHING (retail returns 0). Not the body, not the
render entity, not the cell.
> **AMENDED 2026-08-03/04 (review finding R4).** As shipped, the branch
> retains exactly two acdream bookkeeping writes — `rmState.CellId` and
> `LastServerPos`/`LastServerPosTime` — on both arms. These are not part of
> retail's model (retail's `MoveOrTeleport` has no catch-up sweep and no
> staleness timer to keep alive), and dropping them would break the
> per-tick free-fall sweep and the staleness timer respectively. Verified
> NOT a canonical cell commit for ordinary remotes: `RemoteMotion.CellId`
> only delegates to `RuntimePhysicsState` when `_canonicalCellWriter` is
> bound, which is projectiles-only, and projectiles return earlier.
> Recorded as register row **AP-135**. Everything else the branch used to
> do — body pose, queue, leash, render entity, shadow publish, and the
> velocity-derived animation cycle — is genuinely skipped.
6. `ConstrainTo` moves to after the operation, anchored post-move, and does not
run on the airborne branch.
7. No behaviour change to the far, teleport, or cell-less branches.
@ -145,6 +171,16 @@ to the classifier is the failure mode.** Say which you chose.
AP-87 signature). Then pull a drudge, let it chase, melee it, let it die.
- Divergence ledger: D1 and D2 retired by fix in the same commit. AP-131 stays.
AP-87 / TS-44 either stay with justification or are retired with evidence.
- **Added 2026-08-03 (review finding R15).** D1 makes ACE's wire `IsGrounded`
newly load-bearing for NPC remotes: ACE emits it from
`TransientState & OnWalkable` (`PositionPack.cs:73`), while acdream's NPC
free-fall was gated on the client-tracked `rmState.Airborne` (set only by
`0xF74E` VectorUpdate or `!Body.OnWalkable`, never from the wire bit). A
creature ACE reports as not-in-contact while the client believes it grounded
now receives NO correction where the legacy routing pulled it. **Push a
monster off a ledge / pull one down a cliff**, and confirm it falls and
lands without hovering, without a mid-air correction, and without an
invisible-but-solid body.
## Budget

View file

@ -0,0 +1,269 @@
# C4 route 4a — dual review findings, and a contract correction (2026-08-03)
Both mandated reviews returned **FAIL**. Nothing is committed. This supersedes
the 4a contract where they disagree, and **corrects an error in that contract
that caused one of the findings.**
Reviews: retail-conformance (Opus) and adversarial/architecture (Opus), run
independently against the same uncommitted diff. Suite was green at 10,917/4/0,
which again proves nothing.
## Verified correct — do not churn
- The Runtime seam `RuntimeRemoteSteadyStatePosition.cs` itself: pure,
stateless, correctly decomp-anchored, genuinely well tested. Both reviewers
passed it standalone.
- Retail truth re-verified independently: `MoveOrTeleport` @0x00516330
`arg4 == 0` -> `return 0` writing nothing; `player_distance < 96f` ->
`InterpolateTo`; `ConstrainTo` @0x00454272 only inside
`if (MoveOrTeleport(...) != 0)`, anchored post-move.
- AP-87's two headline conditions survive with identical semantics and no
bypass path.
- `skipGenericPositionWrite` IS derived purely from classification (the staged-
cutover discriminator condition is met).
- Per-entity currency on the callee is sound; the caller re-validates
`IsCurrentPositionAuthority` between classify and apply.
- Budget respected: ~343 production lines against ~400.
---
## R0 — CRITICAL, and it is a defect in ALREADY-COMMITTED route 2
**`AcceptedPhysicsTimestamps.PreviousTeleport` is always 0 on the live Position
path.** `InboundPhysicsStateController.cs:636` calls `Current(gate,
teleportAdvanced: ...)` and omits the `previousTeleport` argument, which
defaults to 0 (`:1119`). The ONLY site that populates it is `:926-941`, the
deferred initial-create path — which is why the continuation executor is correct
and every newer consumer is not. Verified first-hand.
Two live consequences:
**(a) Route 2, at HEAD, silently drops force corrections.**
`LiveEntityNetworkUpdateController.cs:1199` and
`RuntimeLiveEntitySessionController.cs:268` feed the always-zero value into the
route-2 drive, where `ValidAcceptedAuthority` (classifier `:524-525`) requires
`Previous == Accepted` for `ForcePosition`. For any local player whose
TELEPORT_TS is nonzero — i.e. anyone who has portalled or recalled this session
— the authority is rejected and the correction is dropped. The user's `@pklite`
acceptance was genuine but narrow: that character had not teleported, so the
stamp was still 0.
**(b) Route 4a misclassifies every previously-teleported remote.**
`TeleportAdvanced` becomes `IsNewer(0, T)`, true for any `T` in `[1, 0x7FFF]`,
so the classifier returns `SetPosition` — a disposition 4a does not own — and
(per R3) the entity falls into the far-snap arm and hard-snaps on EVERY packet
at 5-10 Hz. That is worse than the pre-4a behaviour it replaced, and it is
exactly the per-packet stepping the connected gate screens for. Reachable the
first time an observed character portals or recalls, permanently thereafter.
**Fix at the source**: capture `previousTeleport = gate.TeleportTimestamp`
BEFORE `TryAcceptPositionEvent` mutates it, and pass it into `Current` — the
shape `:926` already uses. Then audit every consumer.
**Commit this separately and first.** It is a shipped-code defect independent of
4a, and it needs its own bisectable commit and its own issue.
---
## R1 — HIGH — my contract was wrong: the rebucket must NOT be deleted
The 4a contract listed `RebucketLiveEntity` among the generic-tail writes to
delete, and said the result "must reach the render entity through the existing
placement-projection sink instead." **That instruction was copied from route 2
and does not transfer.** Route 2 performs a placement and therefore has a
committed receipt to project. 4a's two branches perform NO placement, so there
is no receipt and nothing substitutes for the bucket transaction. The
implementer followed the contract precisely; the contract was wrong.
`skipGenericPositionWrite` currently also gates
`_liveEntities.RebucketLiveEntity` (`:1400`), which is the ONLY site that moves
an ordinary moving remote's spatial bucket (the nine other call sites are
player, projectile, teleport, hydration, materialization, equipped-child and
rescue paths; the per-tick DR writers deliberately do not rebucket — see
`LiveEntityRuntime.cs:856-858`). So for every grounded remote inside 96 m none
of these runs again: the GPU draw bucket, `IsSpatiallyVisible` +
`RefreshPresentation`, `CommitRebucket` (the canonical `FullCellId`), the
`prepare_to_enter_world` clock rebase, and `PublishProjectionVisibilityChanged`.
Failure: a creature chasing you across a landblock boundary keeps its body and
collision shadow but leaves its draw bucket behind, is frustum-culled, and goes
**invisible-but-solid** — the #184 class AP-87 exists to prevent, through a
different door. Secondary: the stale `FullCellId` feeds back as the classifier's
own `CommittedCellId` and as the `ConstraintDistance` cell key.
**Required: keep the rebucket running for both 4a classifications.** Delete only
the three render-pose writes. Also note R12 below: the per-UP rebucket doubles
as the pending-bucket promotion recovery (`GpuWorldState.cs:1113-1126`, the
2026-07-03 invisible-player fix) — the cell-change-gated canonical commit does
not cover it.
---
## R2 — HIGH — the App acceptance tests are tautologies
`LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs:75-86` recomputes
`skipGenericPositionWrite` in the test body, asserts it true, then places the
mutation inside `if (!skipGenericPositionWrite)` — a branch the preceding assert
proves unreachable. The second test writes the tail sync itself and then asserts
its own arithmetic. **Both pass unchanged with the production file reverted to
HEAD**, and the fixture supplies a correct `Previous/Accepted` teleport pair
that production never produces, so the suite is structurally incapable of
catching R0(b).
This is #292 again, in a WEAKER form than route 2's: a source-text pin at least
fails when the source changes; this never observes production at all. The
contract's acceptance bullet said "behavioural, not a source-text pin… do not
repeat it."
**Required: a test that fails if the generic tail double-writes a remote.** If
`LiveEntityNetworkUpdateController` genuinely cannot be constructed, then extract
the decision+mutation into something that CAN be tested and have production call
it — do not simulate production in the test body.
---
## R3 — HIGH — "not Interpolate" is treated as "far", mis-owning five dispositions
Player `:1723-1740`, NPC `:1867-1888` use `if (Interpolate) … else <far snap>`.
The `else` also swallows `SetPosition` (cell-less half — `remotePlacementRequired`
covers only the teleport half), `RejectedAuthority`, `RejectedData`, and `null`.
So a wire quaternion failing validation, or a cell-less remote, now hard-snaps
every packet where the legacy code did distance-based routing — a behaviour
change to branches contract item 7 forbids touching. `ClassifyRemoteAcceptedPosition`'s
own doc comment (`:47-50`) claims null leaves the legacy path "completely
unchanged"; that is false.
**Required: test explicitly for the two dispositions 4a owns. Everything else
falls through to untouched legacy routing.** One classification, one owner.
---
## R4 — HIGH — the NPC airborne branch still writes cell, render entity and shadow
Contract item 5 says the airborne branch writes NOTHING. The player arm complies
(`:1624-1628` returns). The NPC arm only skips the snap and falls through to
`rmState.CellId = p.LandblockId` (`:1931` — which commits the canonical cell via
`RemoteMotion.cs:169-177` and can rebucket), `LastServerPos/Time`,
`RemoteServerControlledVelocityCycle.Apply` (an animation decision from an
airborne packet), the `entity.SetPosition`/`ParentCellId`/`Rotation` tail
(`:1976-1978`), and `LiveEntityShadowPublisher.TryPublishRemote` (`:1979`).
Worse than before *because* the body no longer moves: previously body, cell,
entity and shadow all agreed; now the body stays put while the cell jumps to the
server's and the shadow publishes against a cell that need not contain it.
(Contract-precision note: `:1616` writes `rmState.CellId` before the PLAYER
return too. That is pre-existing free-fall bookkeeping the contract's "not the
cell" wording did not intend to forbid — decide deliberately and record it,
rather than leaving the contract and the code silently disagreeing.)
---
## R5 — MEDIUM — `ConstrainTo` lost on the player-remote landing packet
The deleted unconditional arm sat before `if (IsPlayerGuid(...))`, so it ran for
the landing transition. The new arm at `:1747-1752` is after the landing block's
return at `:1710`. A landing UP is a grounded near correction where retail's
`MoveOrTeleport` returns nonzero, so retail DOES arm the leash. Behaviour loss
on a path 4a was to leave alone. Also: when the route is null or `Rejected*`,
`ConstrainAfterRouting` is false so the leash never re-arms, yet the `else` arm
still hard-snaps — a hard move with a stale anchor.
---
## R6 — MEDIUM — AP-87 and TS-44 changed silently; both register rows now misdescribe the code
**AP-87:** the NPC variant's third condition `firstUpNpc`
(`LastServerPosTime <= 0`) was dropped with no replacement, and the updated row
still describes it as a "belt hint". Low blast radius (a UP-created
`RemoteMotion` is seeded so `bodyToTarget == 0`), but the contract demanded the
choice be stated, and the original code carried an explicit comment about why
`LastServerPosTime` is unreliable.
**TS-44:** the sticky check moved inside `ApplyInterpolate`, which BOTH kinds
call, so it now suppresses player-remote near corrections that previously had no
sticky check at all (the old `snapSuppressedByStick` gate was structurally
unreachable for players). Player remotes are stickable
(`LiveEntityMotionRuntimeController.cs:144-145`, `:342-352`). The updated row
still says "NPC UpdatePosition enqueue is suppressed" and calls the App gate an
"NPC-only caller gate" — both now false. The player arm is also internally
inconsistent: near is suppressed, far is not.
**Required: decide each deliberately, and make the rows true.**
---
## R7 — MEDIUM — contract items 1 and 2 are not met; this is a helper extraction, not an ownership transfer
The Runtime "owner" is two stateless statics. App still owns request assembly
(`ClassifyRemoteAcceptedPosition` is private to the App controller), branch
selection, the airborne early return, the cell write, the entity write, and the
shadow publish. No second host can reuse any of it. The headless claim is
factually true (`RuntimeLiveEntitySessionController.cs:212-216` returns early
for remotes, so nothing diverges) but it satisfies item 2 by redefinition.
Note Runtime ALREADY builds this exact request in
`RuntimeInitialCreateContinuationExecutor.ApplyPositionAction:1907-1945`. The
new App builder is a third copy of the same construction — which is precisely
how R0 and R8 diverged from it. **Prefer sharing that builder over maintaining a
third copy.**
---
## R8 — MEDIUM-LOW — the request builder fabricates `Vector3.Zero`, violating a documented invariant
`:75-77` uses `_playerController?.Position ?? Vector3.Zero`. `GameRuntime.cs:288-290`
states the rule for this exact field: a null controller "must yield null, never a
fabricated Vector3.Zero that would misclassify every remote entity as
implausibly far." `RuntimeInitialCreateContinuationExecutor.ResolveInputs:510-521`
honours it. The new builder does the opposite.
---
## R9 — LOW-MEDIUM — the new generation plumbing is decorative
`RuntimeEntityObjectLifetime.CurrentGeneration()` and `LiveEntityRuntime.Generation`
return the lifetime's CURRENT generation, not the classified record's. The
classifier only tests `Generation.Value != 0`, never compares it — so the token
cannot detect a generation change, and the currency it claims to add is supplied
entirely by the surrounding `IsCurrentPositionAuthority` checks. Two new public
members for a non-zero placeholder. Also `_generation` is assigned in
`BindEventContext` but never cleared in `Dispose`, unlike its siblings.
Either make it load-bearing or drop it.
---
## R10 — LOW — residue
- **R11:** `ApplyInterpolate`'s return value is discarded at both call sites, and
the NPC caller still wraps it in its own `if (!snapSuppressedByStick)`
duplicating the check that moved into the seam.
- **R12:** the deleted per-UP rebucket also lost the pending-bucket promotion
recovery (`GpuWorldState.cs:1113-1126`, the 2026-07-03 invisible-player fix).
Covered by fixing R1, but note it explicitly.
- **R13:** HTML entity escapes leaked into plain `//` comments (`&gt;=96 m`) at
`:1718`, `:1735`, `:1864`, `:1883`.
- **R14:** `preSnapPos` (`:1394`) assigned, never read, with a comment describing
behaviour that no longer exists.
---
## R15 — live-gate addition, not a code defect
D1 makes ACE's wire `IsGrounded` newly load-bearing for NPC remotes. ACE emits it
from `TransientState & OnWalkable` (`PositionPack.cs:73`), while acdream's NPC
free-fall is gated on the client-tracked `rmState.Airborne`, set only by
`0xF74E` VectorUpdate or `!Body.OnWalkable` — never from the wire bit. A creature
ACE reports as not-in-contact while the client believes it grounded now receives
NO correction where the legacy routing pulled it.
**Add to the connected gate: push a monster off a ledge / pull one down a
cliff.**
---
## Gate
Complete Release suite, not a subset. Pre-4a baseline is 10,909/4/0. R2 means the
current green number is not evidence for the thing it claims to test.

View file

@ -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

View file

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

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

View file

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

View file

@ -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;
}
}

View 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> -&gt; return 0 @0x0051636D, nothing written at all) and its
/// near <c>InterpolateTo</c> queue (<c>player_distance &lt; 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 (&gt;=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 &lt; 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>&amp;arg2-&gt;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));
}
}

View file

@ -0,0 +1,316 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
namespace AcDream.App.Tests.Physics;
/// <summary>
/// C4 route 4a: behavioural proof that the generic top-of-<c>OnPosition</c>
/// render-pose write no longer double-writes a remote whose accepted Position
/// classifies to
/// <see cref="RuntimeAuthoritativePositionDisposition.NoPositionOperation"/> or
/// <see cref="RuntimeAuthoritativePositionDisposition.Interpolate"/>, and
/// still writes it for every classification route 4a does not own.
///
/// <para>
/// These tests call the PRODUCTION decision-and-mutation entry point
/// (<see cref="LiveEntityNetworkUpdateController.TryApplyGenericRemoteRenderPose"/>)
/// against a real <see cref="WorldEntity"/> and real
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/> output. Nothing
/// is re-derived in the test body: revert the suppression inside that method
/// and the two suppression tests fail; widen it to <c>route is not null</c>
/// and four of the five legacy-write tests fail (the fifth,
/// <see cref="NoClassificationAtAll_StillTakesTheLegacyRenderPoseWrite"/>,
/// passes a null route and so survives that particular widening — it
/// discriminates the OTHER direction, an over-narrow guard). That is the gap
/// #292 recorded for route 2's source-text pin.
/// </para>
///
/// <para>
/// The suite also pins the ORDERING carve-out
/// (<see cref="LiveEntityNetworkUpdateController.ApplyRemoteContactRouting"/>):
/// an airborne body's contact packet classifies <c>Interpolate</c>, so
/// precedence — not classification — is the only thing keeping a landing
/// creature planting instead of gliding.
/// </para>
/// </summary>
public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
{
private const uint OldCell = 0x01010001u;
private const uint WireCell = 0x01010002u;
private static readonly Vector3 OldPosition = new(5f, 5f, 5f);
private static readonly Vector3 WirePosition = new(6f, 7f, 8f);
[Fact]
public void NoPositionOperation_LeavesTheRenderEntityPoseAndCellUntouched()
{
// Retail's airborne no-op (D1): MoveOrTeleport @0x00516330 returns 0
// for arg4 == 0 and writes nothing. CommittedCellId is nonzero so the
// cell-less SetPosition branch cannot be what is selected here.
RuntimeAuthoritativePositionRoute route =
Classify(hasContact: false, playerDistance: 1f);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
route.Disposition);
AssertRenderPoseSuppressed(route);
}
[Fact]
public void Interpolate_LeavesTheRenderEntityPoseAndCellUntouched()
{
// Retail's near InterpolateTo queue @0x005163AF. The resolved body,
// not the raw wire packet, is the branch tail's only writer of the
// render entity — writing the wire pose here first would be the
// second writer route 2's original defect consisted of.
RuntimeAuthoritativePositionRoute route =
Classify(hasContact: true, playerDistance: 10f);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.Interpolate,
route.Disposition);
AssertRenderPoseSuppressed(route);
}
[Fact]
public void FarSetPositionSimple_StillTakesTheLegacyRenderPoseWrite()
{
RuntimeAuthoritativePositionRoute route =
Classify(hasContact: true, playerDistance: 200f);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
route.Disposition);
AssertRenderPoseWritten(route);
}
[Fact]
public void CellLessRemote_StillTakesTheLegacyRenderPoseWrite()
{
RuntimeAuthoritativePositionRoute route = Classify(
hasContact: false,
playerDistance: 1f,
committedCellId: 0u);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.SetPosition,
route.Disposition);
AssertRenderPoseWritten(route);
}
[Fact]
public void RejectedAuthority_StillTakesTheLegacyRenderPoseWrite()
{
RuntimeAuthoritativePositionRoute route = Classify(
hasContact: true,
playerDistance: 10f,
disposition: PositionTimestampDisposition.Rejected);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.RejectedAuthority,
route.Disposition);
AssertRenderPoseWritten(route);
}
[Fact]
public void RejectedData_StillTakesTheLegacyRenderPoseWrite()
{
RuntimeAuthoritativePositionRoute route =
Classify(hasContact: true, playerDistance: float.NaN);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.RejectedData,
route.Disposition);
AssertRenderPoseWritten(route);
}
[Fact]
public void NoClassificationAtAll_StillTakesTheLegacyRenderPoseWrite()
{
// The login window (no local-player controller yet) and a canonical
// record that has not claimed a local id both produce a null route.
// Its doc comment promises the legacy path runs unchanged — so the
// write must still happen.
AssertRenderPoseWritten(null);
}
// ── Ordering: the airborne snap outranks route 4a's near branch ─────────
[Fact]
public void AirborneBodyWithAContactPacket_SnapsInsteadOfEnqueuing()
{
// The NPC LANDING packet: the client still believes the body airborne
// and ACE reports it in contact, close by. That classifies
// Interpolate — and AP-87's conditions are all benign (0.5 m, sampled,
// DR-ticked) — so route 4a's branch would ENQUEUE it. It must SNAP:
// a drudge knocked off a ledge plants, it does not glide to the
// ground over a packet interval.
RuntimeAuthoritativePositionRoute route =
Classify(hasContact: true, playerDistance: 10f);
Assert.Equal(
RuntimeAuthoritativePositionDisposition.Interpolate,
route.Disposition);
RemoteMotion remote = MakeLandingRemote();
var landing = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m.
LiveEntityNetworkUpdateController.RemoteContactArm arm =
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
remote,
route,
landing,
Quaternion.Identity,
willBeDrTicked: true);
Assert.Equal(
LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap,
arm);
Assert.Equal(landing, remote.Body.Position);
}
[Fact]
public void GroundedBodyWithANearContactPacket_TakesRoute4asInterpolateBranch()
{
// The same packet on a body the client already considers grounded is
// route 4a's own branch: queued for the per-tick catch-up, body
// untouched. This is what proves the airborne test above is a
// PRECEDENCE carve-out and not a blanket disable of route 4a.
RemoteMotion remote = MakeLandingRemote();
remote.Airborne = false;
var target = new Vector3(10.5f, 10f, 5f);
LiveEntityNetworkUpdateController.RemoteContactArm arm =
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
remote,
Classify(hasContact: true, playerDistance: 10f),
target,
Quaternion.Identity,
willBeDrTicked: true);
Assert.Equal(
LiveEntityNetworkUpdateController.RemoteContactArm
.SteadyStateInterpolate,
arm);
Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position);
}
[Fact]
public void GroundedBodyOnAClassificationRoute4aDoesNotOwn_FallsThroughToLegacy()
{
RemoteMotion remote = MakeLandingRemote();
remote.Airborne = false;
Vector3 before = remote.Body.Position;
LiveEntityNetworkUpdateController.RemoteContactArm arm =
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
remote,
Classify(hasContact: true, playerDistance: 200f),
new Vector3(10.5f, 10f, 5f),
Quaternion.Identity,
willBeDrTicked: true);
Assert.Equal(
LiveEntityNetworkUpdateController.RemoteContactArm.Legacy,
arm);
// Nothing was written — the caller's own legacy routing owns it.
Assert.Equal(before, remote.Body.Position);
}
private static RemoteMotion MakeLandingRemote()
{
var remote = new RemoteMotion();
remote.Body.Position = new Vector3(10f, 10f, 5f);
remote.Body.Orientation = Quaternion.Identity;
// Past the AP-87 firstUp hint, so the near case really is near.
remote.LastServerPosTime = 1_700_000_000d;
remote.Airborne = true;
return remote;
}
private static void AssertRenderPoseSuppressed(
RuntimeAuthoritativePositionRoute? route)
{
WorldEntity entity = MakeEntity();
var beforeRotation = entity.Rotation;
bool written = LiveEntityNetworkUpdateController
.TryApplyGenericRemoteRenderPose(
entity,
route,
WirePosition,
WireCell,
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.1f));
Assert.False(written);
Assert.Equal(OldPosition, entity.Position);
Assert.Equal(OldCell, entity.ParentCellId);
Assert.Equal(beforeRotation, entity.Rotation);
}
private static void AssertRenderPoseWritten(
RuntimeAuthoritativePositionRoute? route)
{
WorldEntity entity = MakeEntity();
var wireRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.1f);
bool written = LiveEntityNetworkUpdateController
.TryApplyGenericRemoteRenderPose(
entity,
route,
WirePosition,
WireCell,
wireRotation);
Assert.True(written);
Assert.Equal(WirePosition, entity.Position);
Assert.Equal(WireCell, entity.ParentCellId);
Assert.Equal(wireRotation, entity.Rotation);
}
private static WorldEntity MakeEntity() => new()
{
Id = 1u,
SourceGfxObjOrSetupId = 1u,
Position = OldPosition,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = OldCell,
};
private const uint Cell = 0x0101FFFFu;
private static RuntimeAuthoritativePositionRoute Classify(
bool hasContact,
float playerDistance,
uint committedCellId = OldCell,
PositionTimestampDisposition disposition =
PositionTimestampDisposition.Apply) =>
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
new RuntimeAcceptedPositionRouteRequest(
new RuntimeAuthoritativePositionAuthority(
new RuntimeGenerationToken(7),
new RuntimeEntityKey(0x70000001u, 3),
PositionAuthorityVersion: 11UL,
AcceptedPositionSequence: 20,
PreviousTeleportSequence: 10,
AcceptedTeleportSequence: 10,
disposition),
RuntimePositionEntityKind.Remote,
RuntimeAcceptedPositionSource.PositionEvent,
new CreateObject.ServerPosition(
Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f),
PlacementFrame: 0u,
PositionPackVelocity: Vector3.Zero,
committedCellId,
hasContact,
playerDistance,
UsePositionFromServer: false,
HasAnimations: false,
default));
}

View file

@ -0,0 +1,368 @@
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Physics;
/// <summary>
/// C4 route 4a: focused tests for the Runtime-owned decision that replaces
/// the two independent per-kind (player-remote / NPC-remote) copies that
/// used to live in <c>LiveEntityNetworkUpdateController</c>. Each test below
/// was verified to actually discriminate its own fix by temporarily
/// reverting the corresponding condition in
/// <see cref="RuntimeRemoteSteadyStatePosition"/> and confirming the
/// matching test failed, then restoring it — see the route 4a implementation
/// report for the revert/restore log.
/// </summary>
public sealed class RuntimeRemoteSteadyStatePositionTests
{
private const uint Cell = 0x0101FFFFu;
/// <summary>A remote that has already received at least one server
/// sample, so AP-87's <c>firstUp</c> hint is false and the other two
/// conditions are the ones under test.</summary>
private static RemoteMotion MakeSampledRemote(Vector3 bodyPosition)
{
var remote = new RemoteMotion();
remote.Body.Position = bodyPosition;
remote.Body.Orientation = Quaternion.Identity;
remote.LastServerPosTime = 1_700_000_000d;
return remote;
}
private static (RemoteMotion Remote, EntityPhysicsHost Host) MakeRemoteWithHost(
Vector3 bodyPosition)
{
RemoteMotion remote = MakeSampledRemote(bodyPosition);
EntityPhysicsHost host = new(
id: 0x70000001u,
getPosition: () => new Position(0x0001u, remote.Body.Position, remote.Body.Orientation),
getVelocity: () => remote.Body.Velocity,
getRadius: () => 0.48f,
inContact: () => remote.Body.InContact,
minterpMaxSpeed: () => null,
curTime: () => 0d,
physicsTimerTime: () => 0d,
getObjectA: _ => null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
remote.BindCanonicalRuntime(
() => host,
() => 0x0001u,
_ => { });
remote.MarkFullPhysicsHostBound();
return (remote, host);
}
// ── AP-87 (register row, carried forward) ──────────────────────────────
[Fact]
public void ApplyInterpolate_BodyFarFromTarget_SnapsRatherThanEnqueues()
{
// AP-87's load-bearing condition: |Body.Position - worldPos| > 4 m.
// An unplaced body (spawn-seed origin) must SNAP, never enqueue —
// enqueuing here is exactly the #184 invisible-but-solid regression
// this backstop exists to prevent.
RemoteMotion remote = MakeSampledRemote(new Vector3(0f, 0f, 0f));
var target = new Vector3(50f, 0f, 0f); // 50 m away — far beyond 4 m.
RuntimeRemoteSteadyStatePosition.Action action =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
remote,
target,
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: true);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
Assert.Equal(target, remote.Body.Position);
}
[Fact]
public void ApplyInterpolate_NotDrTicked_SnapsEvenWhenClose()
{
// AP-87's second condition: !willBeDrTicked. A body with no consumer
// to walk the queue must snap even for a 1 m correction, or it never
// reaches the target at all.
RemoteMotion remote = MakeSampledRemote(new Vector3(10f, 10f, 5f));
var target = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m.
RuntimeRemoteSteadyStatePosition.Action action =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
remote,
target,
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: false);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
Assert.Equal(target, remote.Body.Position);
}
[Fact]
public void ApplyInterpolate_FirstUpBeforeAnyServerSample_Snaps()
{
// AP-87's third condition, carried forward from the NPC copy rather
// than silently dropped: a remote that has never been stamped with a
// server sample snaps even when both other conditions are benign.
// Structurally unreachable for player remotes, whose caller stamps
// LastServerPosTime before it routes — which is exactly why keeping
// it costs the player arm nothing.
var remote = new RemoteMotion();
remote.Body.Position = new Vector3(10f, 10f, 5f);
remote.Body.Orientation = Quaternion.Identity;
Assert.Equal(0d, remote.LastServerPosTime);
var target = new Vector3(10.5f, 10f, 5f);
RuntimeRemoteSteadyStatePosition.Action action =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
remote,
target,
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: true);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
Assert.Equal(target, remote.Body.Position);
}
[Fact]
public void ApplyInterpolate_NearAndDrTicked_EnqueuesWithoutTouchingBodyPosition()
{
// The ordinary retail near case: no AP-87 condition holds, so the
// body is queued for the per-tick catch-up, not hard-snapped.
RemoteMotion remote = MakeSampledRemote(new Vector3(10f, 10f, 5f));
var target = new Vector3(10.5f, 10f, 5f);
RuntimeRemoteSteadyStatePosition.Action action =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
remote,
target,
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: true);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Enqueued, action);
// The body itself is not hard-moved by the enqueue path — the
// per-tick InterpolationManager/adjust_offset chain walks it there.
Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position);
}
/// <summary>
/// TS-44 stays an NPC-only CALLER gate: the seam itself is the
/// kind-agnostic retail decision, so a sticky-armed host does NOT change
/// what it does. Folding the sticky check in here would have silently
/// extended TS-44 to player remotes, which have never had one.
/// </summary>
[Fact]
public void ApplyInterpolate_IsIndifferentToTheStickyLease()
{
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
new Vector3(0f, 0f, 0f));
host.PositionManager.StickTo(objectId: 0x70000002u, radius: 1f, height: 1f);
Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId());
RuntimeRemoteSteadyStatePosition.Action action =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
remote,
new Vector3(50f, 0f, 0f),
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: true);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, action);
}
// ── Per-entity currency ─────────────────────────────────────────────────
// Contract item 4: N remotes, no shared pending slot — unlike route 2's
// single-slot RuntimeAcceptedPositionDriveController, this decision
// carries no state of its own between calls. Two remotes classified in
// the same tick must not observe or mutate each other.
[Fact]
public void TwoRemotesInTheSameTick_ApplyIndependentlyWithNoCrossContamination()
{
RemoteMotion farRemote = MakeSampledRemote(new Vector3(0f, 0f, 0f)); // will snap.
RemoteMotion nearRemote = MakeSampledRemote(new Vector3(10f, 10f, 5f)); // will enqueue.
RuntimeRemoteSteadyStatePosition.Action farAction =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
farRemote,
new Vector3(50f, 0f, 0f),
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: true);
RuntimeRemoteSteadyStatePosition.Action nearAction =
RuntimeRemoteSteadyStatePosition.ApplyInterpolate(
nearRemote,
new Vector3(10.5f, 10f, 5f),
Quaternion.Identity,
isMovingTo: false,
willBeDrTicked: true);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Snapped, farAction);
Assert.Equal(RuntimeRemoteSteadyStatePosition.Action.Enqueued, nearAction);
Assert.Equal(new Vector3(50f, 0f, 0f), farRemote.Body.Position);
// nearRemote's body is untouched by farRemote's snap — no shared state.
Assert.Equal(new Vector3(10f, 10f, 5f), nearRemote.Body.Position);
}
// ── Ownership: exactly two dispositions, everything else falls through ──
[Fact]
public void OwnsSteadyState_IsTrueForExactlyTheTwoNoPlacementDispositions()
{
Assert.True(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(
Classify(hasContact: false, playerDistance: 1f)));
Assert.True(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(
Classify(hasContact: true, playerDistance: 10f)));
}
[Theory]
// Far (>=96 m) — SetPositionSimple, route 4b.
[InlineData(true, 200f, Cell, 10, 10, PositionTimestampDisposition.Apply)]
// Cell-less canonical body — SetPosition, route 4b.
[InlineData(true, 10f, 0u, 10, 10, PositionTimestampDisposition.Apply)]
// Fresh TELEPORT_TS — SetPosition, route 4b.
[InlineData(true, 10f, Cell, 10, 11, PositionTimestampDisposition.Apply)]
// Rejected admission — RejectedAuthority.
[InlineData(true, 10f, Cell, 10, 10, PositionTimestampDisposition.Rejected)]
// Nonfinite derived distance — RejectedData.
[InlineData(true, float.NaN, Cell, 10, 10, PositionTimestampDisposition.Apply)]
public void OwnsSteadyState_IsFalseForEveryOtherClassification(
bool hasContact,
float playerDistance,
uint committedCellId,
ushort previousTeleport,
ushort acceptedTeleport,
PositionTimestampDisposition disposition)
{
RuntimeAuthoritativePositionRoute route = Classify(
hasContact,
playerDistance,
committedCellId,
previousTeleport,
acceptedTeleport,
disposition);
Assert.NotEqual(
RuntimeAuthoritativePositionDisposition.Interpolate,
route.Disposition);
Assert.NotEqual(
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
route.Disposition);
Assert.False(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(route));
}
[Fact]
public void OwnsSteadyState_IsFalseWhenNothingWasClassified()
{
// A null route is "route 4a has no opinion" — the legacy path runs
// completely unchanged, exactly as its doc comment claims.
Assert.False(RuntimeRemoteSteadyStatePosition.OwnsSteadyState(null));
Assert.False(RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation(null));
Assert.False(RuntimeRemoteSteadyStatePosition.IsNearInterpolate(null));
}
// ── D2: ConstrainTo timing/anchor ───────────────────────────────────────
[Fact]
public void ArmConstraintAfterOperation_AnchorsToTheHostsCurrentLivePosition()
{
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
new Vector3(1f, 2f, 3f));
// Simulate the operation having already moved the body (a Snapped
// outcome) BEFORE ConstrainTo arms — D2 requires the anchor to read
// the POST-move position, never the pre-move one.
remote.Body.Position = new Vector3(9f, 9f, 9f);
RuntimeRemoteSteadyStatePosition.ArmConstraintAfterOperation(host);
ConstraintManager? constraint = host.PositionManager.Constraint;
Assert.NotNull(constraint);
Assert.True(constraint!.IsConstrained);
Assert.Equal(new Vector3(9f, 9f, 9f), constraint.ConstraintPos.Frame.Origin);
// Anchored to itself at arm time -> zero initial offset, matching
// retail's per-packet re-anchor.
Assert.Equal(0f, constraint.ConstraintPosOffset, 3);
}
[Fact]
public void TryArmConstraintAfterOperation_ArmsForTheNearInterpolateBranch()
{
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
new Vector3(1f, 2f, 3f));
Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
Classify(hasContact: true, playerDistance: 10f),
remote));
Assert.True(host.PositionManager.Constraint?.IsConstrained);
}
[Fact]
public void TryArmConstraintAfterOperation_SkipsTheAirborneNoOperation()
{
// MoveOrTeleport returns 0 for arg4 == 0, so HandleReceivedPosition's
// `if (MoveOrTeleport(...) != 0)` never reaches ConstrainTo.
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
new Vector3(1f, 2f, 3f));
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
Classify(hasContact: false, playerDistance: 10f),
remote));
Assert.Null(host.PositionManager.Constraint);
}
[Fact]
public void TryArmConstraintAfterOperation_SkipsClassificationsRoute4aDoesNotOwn()
{
// The far branch still arms its leash — through the untouched legacy
// PRE-operation call site, not through here. Arming here too would
// double-arm it.
(RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost(
new Vector3(1f, 2f, 3f));
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
Classify(hasContact: true, playerDistance: 200f),
remote));
Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation(
null,
remote));
Assert.Null(host.PositionManager.Constraint);
}
private static RuntimeAuthoritativePositionRoute Classify(
bool hasContact,
float playerDistance,
uint committedCellId = Cell,
ushort previousTeleport = 10,
ushort acceptedTeleport = 10,
PositionTimestampDisposition disposition =
PositionTimestampDisposition.Apply) =>
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
new RuntimeAcceptedPositionRouteRequest(
new RuntimeAuthoritativePositionAuthority(
new RuntimeGenerationToken(7),
new RuntimeEntityKey(0x70000001u, 3),
PositionAuthorityVersion: 11UL,
AcceptedPositionSequence: 20,
previousTeleport,
acceptedTeleport,
disposition),
RuntimePositionEntityKind.Remote,
RuntimeAcceptedPositionSource.PositionEvent,
new CreateObject.ServerPosition(
Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f),
PlacementFrame: 0u,
PositionPackVelocity: Vector3.Zero,
committedCellId,
hasContact,
playerDistance,
UsePositionFromServer: false,
HasAnimations: false,
default));
}